Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemath
GitHub Repository: sagemath/sage
Path: blob/develop/src/sage_docbuild/builders.py
4052 views
1
# sage.doctest: needs sphinx
2
"""
3
Documentation builders
4
5
This module is the starting point for building documentation, and is
6
responsible to figure out what to build and with which options. The actual
7
documentation build for each individual document is then done in a subprocess
8
call to Sphinx, see :func:`builder_helper`. Note that
9
10
* The builders are configured with ``build_options.py``;
11
* The Sphinx subprocesses are configured in ``conf.py``.
12
13
:class:`DocBuilder` is the base class of all Builders. It has builder helpers
14
:meth:`html()`, :meth:`latex`, :meth:`pdf`, :meth:`inventory`, etc, which are
15
invoked depending on the output type. Each type corresponds with the Sphinx
16
builder format, except that ``pdf`` is Sphinx latex builder plus compiling
17
latex to pdf. Note that Sphinx inventory builder is not native to Sphinx
18
but provided by Sage. See ``sage_docbuild/ext/inventory_builder.py``. The
19
Sphinx inventory builder is a dummy builder with no actual output but produces
20
doctree files in ``local/share/doctree`` and ``inventory.inv`` inventory files
21
in ``local/share/inventory``.
22
23
The reference manual is built in two passes, first by :class:`ReferenceBuilder`
24
with ``inventory`` output type and secondly with``html`` output type. The
25
:class:`ReferenceBuilder` itself uses :class:`ReferenceTopBuilder` and
26
:class:`ReferenceSubBuilder` to build subcomponents of the reference manual.
27
The :class:`ReferenceSubBuilder` examines the modules included in the
28
subcomponent by comparing the modification times of the module files with the
29
times saved in ``local/share/doctree/reference.pickle`` from the previous
30
build. Then new rst files are generated for new and updated modules. See
31
:meth:`get_new_and_updated_modules()`.
32
33
After :issue:`31948`, when Sage is built, :class:`ReferenceBuilder` is not used
34
and its responsibility is now taken by the ``Makefile`` in ``$SAGE_ROOT/src/doc``.
35
"""
36
37
# ****************************************************************************
38
# Copyright (C) 2008-2009 Mike Hansen <[email protected]>
39
# 2009-2010 Mitesh Patel <[email protected]>
40
# 2009-2015 J. H. Palmieri <[email protected]>
41
# 2009 Carl Witty <[email protected]>
42
# 2010-2017 Jeroen Demeyer <[email protected]>
43
# 2012 William Stein <[email protected]>
44
# 2012-2014 Nicolas M. Thiery <[email protected]>
45
# 2012-2015 André Apitzsch <[email protected]>
46
# 2012 Florent Hivert <[email protected]>
47
# 2013-2014 Volker Braun <[email protected]>
48
# 2013 R. Andrew Ohana <[email protected]>
49
# 2015 Thierry Monteil <[email protected]>
50
# 2015 Marc Mezzarobba <[email protected]>
51
# 2015 Travis Scrimshaw <tscrim at ucdavis.edu>
52
# 2016-2017 Frédéric Chapoton <[email protected]>
53
# 2016 Erik M. Bray <[email protected]>
54
# 2017 Kwankyu Lee <[email protected]>
55
# 2017 François Bissey <[email protected]>
56
# 2018 Julian Rüth <[email protected]>
57
#
58
# This program is free software: you can redistribute it and/or modify
59
# it under the terms of the GNU General Public License as published by
60
# the Free Software Foundation, either version 2 of the License, or
61
# (at your option) any later version.
62
# https://www.gnu.org/licenses/
63
# ****************************************************************************
64
65
import logging
66
import os
67
import pickle
68
import re
69
import shlex
70
import shutil
71
import subprocess
72
import sys
73
import time
74
import warnings
75
from pathlib import Path
76
from typing import Generator, Literal
77
78
from . import build_options
79
from .build_options import BuildOptions
80
from .utils import build_many as _build_many
81
82
logger = logging.getLogger(__name__)
83
84
85
##########################################
86
# Parallel Building Ref Manual #
87
##########################################
88
89
def build_ref_doc(args):
90
doc = args[0]
91
lang = args[1]
92
format = args[2]
93
kwds = args[3]
94
args = args[4:]
95
if format == 'inventory': # you must not use the inventory to build the inventory
96
kwds['use_multidoc_inventory'] = False
97
getattr(ReferenceSubBuilder(doc, lang), format)(*args, **kwds)
98
99
100
##########################################
101
# Builders #
102
##########################################
103
104
def builder_helper(type):
105
"""
106
Return a function which builds the documentation for
107
output type ``type``.
108
"""
109
def f(self, *args, **kwds):
110
output_dir = self._output_dir(type)
111
112
options = build_options.ALLSPHINXOPTS
113
114
if self.name == 'website':
115
# WEBSITESPHINXOPTS is either empty or " -A hide_pdf_links=1 "
116
options += build_options.WEBSITESPHINXOPTS
117
118
if kwds.get('use_multidoc_inventory', True) and type != 'inventory':
119
options += ' -D multidoc_first_pass=0'
120
else:
121
options += ' -D multidoc_first_pass=1'
122
123
build_command = '-b %s -d %s %s %s %s' % (type, self._doctrees_dir(),
124
options, self.dir,
125
output_dir)
126
127
# Provide "pdf" tag to be used with "only" directive as an alias of "latex"
128
if type == 'latex':
129
build_command = '-t pdf ' + build_command
130
131
logger.debug(build_command)
132
133
# Run Sphinx with Sage's special logger
134
sys.argv = ["sphinx-build"] + build_command.split()
135
from .sphinxbuild import runsphinx
136
try:
137
runsphinx()
138
except Exception:
139
if build_options.ABORT_ON_ERROR:
140
raise
141
except BaseException as e:
142
# We need to wrap a BaseException that is not an Exception in a
143
# regular Exception. Otherwise multiprocessing.Pool.get hangs, see
144
# #25161
145
if build_options.ABORT_ON_ERROR:
146
raise Exception("Non-exception during docbuild: %s" % (e,), e)
147
148
if type == 'latex':
149
logger.warning(f"LaTeX files can be found in {output_dir}.")
150
elif type != 'inventory':
151
logger.warning(f"Build finished. The built documents can be found in {output_dir}.")
152
153
f.is_output_format = True
154
return f
155
156
157
class DocBuilder():
158
def __init__(self, name: str, options: BuildOptions):
159
"""
160
INPUT:
161
162
- ``name`` -- the name of a subdirectory in ``doc/<lang>``, such as
163
'tutorial' or 'installation'
164
"""
165
self.name = name
166
self.dir = options.source_dir / self.name
167
self._options = options
168
169
def _output_dir(self, type):
170
"""
171
Return the directory where the output of type ``type`` is stored.
172
173
If the directory does not exist, then it will automatically be
174
created.
175
176
EXAMPLES::
177
178
sage: from sage_docbuild.builders import DocBuilder
179
sage: from sage_docbuild.build_options import BuildOptions
180
sage: import tempfile
181
sage: with tempfile.TemporaryDirectory() as directory:
182
....: options = BuildOptions(output_dir=Path(directory), source_dir=Path('src/doc'))
183
....: builder = DocBuilder('en/tutorial', options)
184
....: builder._output_dir('html')
185
...Path('.../html/en/tutorial')
186
"""
187
dir = self._options.output_dir / type / self.name
188
dir.mkdir(parents=True, exist_ok=True)
189
return dir
190
191
def _doctrees_dir(self) -> Path:
192
"""
193
Return the directory where the doctrees are stored.
194
195
If the directory does not exist, then it will automatically be
196
created.
197
198
EXAMPLES::
199
200
sage: from sage_docbuild.builders import DocBuilder
201
sage: from sage_docbuild.build_options import BuildOptions
202
sage: import tempfile
203
sage: with tempfile.TemporaryDirectory() as directory:
204
....: options = BuildOptions(output_dir=Path(directory), source_dir=Path('src/doc'))
205
....: builder = DocBuilder('en/tutorial', options)
206
....: builder._doctrees_dir()
207
...Path('.../doctrees/en/tutorial')
208
"""
209
dir = self._options.output_dir / 'doctrees' / self.name
210
dir.mkdir(parents=True, exist_ok=True)
211
return dir
212
213
def _output_formats(self):
214
"""
215
Return a list of the possible output formats.
216
217
EXAMPLES::
218
219
sage: from sage_docbuild.builders import DocBuilder
220
sage: from sage_docbuild.build_options import BuildOptions
221
sage: options = BuildOptions(source_dir=Path('src/doc'))
222
sage: builder = DocBuilder('tutorial', options)
223
sage: builder._output_formats()
224
['changes', 'html', 'htmlhelp', 'inventory', 'json', 'latex', 'linkcheck', 'pickle', 'web']
225
"""
226
# Go through all the attributes of self and check to
227
# see which ones have an 'is_output_format' attribute. These
228
# are the ones created with builder_helper.
229
output_formats = []
230
for attr in dir(self):
231
if hasattr(getattr(self, attr), 'is_output_format'):
232
output_formats.append(attr)
233
output_formats.sort()
234
return output_formats
235
236
def pdf(self):
237
"""
238
Build the PDF files for this document.
239
240
This is done by first (re)-building the LaTeX output, going
241
into that LaTeX directory, and running 'make all-pdf' there.
242
243
EXAMPLES::
244
245
sage: from sage_docbuild.builders import DocBuilder
246
sage: from sage_docbuild.build_options import BuildOptions
247
sage: options = BuildOptions(source_dir = Path('src/doc'))
248
sage: builder = DocBuilder('tutorial', options)
249
sage: builder.pdf() #not tested
250
"""
251
self.latex()
252
tex_dir = self._output_dir('latex')
253
pdf_dir = self._output_dir('pdf')
254
255
if self.name == 'reference':
256
# recover maths in tex, undoing what Sphinx did (trac #29993)
257
tex_file = tex_dir / 'reference.tex'
258
with open(tex_file) as f:
259
ref = f.read()
260
ref = re.sub(r'\\textbackslash{}', r'\\', ref)
261
ref = re.sub(r'\\textbackslash{}', r'\\', ref)
262
ref = re.sub(r'\\{', r'{', ref)
263
ref = re.sub(r'\\}', r'}', ref)
264
ref = re.sub(r'\\_', r'_', ref)
265
ref = re.sub(r'\\textasciicircum{}', r'^', ref)
266
with open(tex_file, 'w') as f:
267
f.write(ref)
268
269
make_cmd = os.environ.get('MAKE', 'make')
270
command = shlex.split(make_cmd) + ['all-pdf']
271
logger.debug(f"Running {' '.join(command)} in {tex_dir}")
272
273
proc = subprocess.run(
274
command,
275
check=False, cwd=tex_dir,
276
capture_output=True,
277
text=True,
278
)
279
280
if proc.returncode != 0:
281
logger.error(f"stdout from {make_cmd}:\n{proc.stdout}")
282
logger.error(f"stderr from {make_cmd}:\n{proc.stderr}")
283
raise RuntimeError(f"failed to run {' '.join(command)} in {tex_dir}")
284
285
if proc.stdout:
286
logger.debug(f"make stdout:\n{proc.stdout}")
287
if proc.stderr:
288
# Still surface stderr even on success, but at debug level
289
logger.debug(f"make stderr:\n{proc.stderr}")
290
291
# Move generated PDFs
292
for pdf in tex_dir.glob("*.pdf"):
293
try:
294
shutil.move(str(pdf), pdf_dir)
295
except Exception as e:
296
logger.error(f"Failed moving {pdf} to {pdf_dir}: {e}")
297
raise
298
299
logger.info(f"Build finished. The built documents can be found in {pdf_dir}.")
300
301
def clean(self, *args):
302
shutil.rmtree(self._doctrees_dir())
303
output_formats = list(args) if args else self._output_formats()
304
for format in output_formats:
305
shutil.rmtree(self._output_dir(format), ignore_errors=True)
306
307
html = builder_helper('html')
308
pickle = builder_helper('pickle')
309
web = pickle
310
json = builder_helper('json')
311
htmlhelp = builder_helper('htmlhelp')
312
latex = builder_helper('latex')
313
changes = builder_helper('changes')
314
linkcheck = builder_helper('linkcheck')
315
# import the customized builder for object.inv files
316
inventory = builder_helper('inventory')
317
318
319
def build_many(target, args, processes=None):
320
"""
321
Thin wrapper around `sage_docbuild.utils.build_many` which uses the
322
docbuild settings ``NUM_THREADS`` and ``ABORT_ON_ERROR``.
323
"""
324
if processes is None:
325
processes = build_options.NUM_THREADS
326
try:
327
_build_many(target, args, processes=processes)
328
except BaseException:
329
if build_options.ABORT_ON_ERROR:
330
raise
331
332
333
##########################################
334
# Parallel Building Ref Manual #
335
##########################################
336
class WebsiteBuilder(DocBuilder):
337
def html(self):
338
"""
339
After we have finished building the website index page, we copy
340
everything one directory up, that is, to the base diectory ``html/en``.
341
342
In addition, an index file is installed into the root doc directory.
343
344
Thus we have three index.html files:
345
346
html/en/website/index.html (not used)
347
html/en/index.html (base directory)
348
index.html (root doc directory)
349
"""
350
super().html()
351
html_output_dir = self._output_dir('html')
352
353
# This file is used by src/doc/common/static/jupyter-sphinx-furo.js
354
# for doc version selector
355
shutil.copy2(os.path.join(self.dir, 'versions.txt'), html_output_dir)
356
357
for f in os.listdir(html_output_dir):
358
src = os.path.join(html_output_dir, f)
359
dst = os.path.join(html_output_dir, '..', f)
360
if os.path.isdir(src):
361
shutil.rmtree(dst, ignore_errors=True)
362
shutil.copytree(src, dst)
363
else:
364
shutil.copy2(src, dst)
365
366
shutil.copy2(os.path.join(self.dir, 'root_index.html'),
367
os.path.join(html_output_dir, '../../../index.html'))
368
369
def pdf(self):
370
"""
371
Build the website hosting pdf docs.
372
"""
373
super().pdf()
374
375
# If the website exists, update it.
376
377
from sage.env import SAGE_DOC
378
website_dir = os.path.join(SAGE_DOC, 'html', 'en', 'website')
379
380
if os.path.exists(os.path.join(website_dir, 'index.html')):
381
# Rebuild WITHOUT --no-pdf-links, which is translated to
382
# "-A hide_pdf_links=1" Sphinx argument. Thus effectively
383
# the index page SHOWS links to pdf docs.
384
self.html()
385
386
def clean(self):
387
"""
388
When we clean the output for the website index, we need to
389
remove all of the HTML that were placed in the parent
390
directory.
391
392
In addition, remove the index file installed into the root doc directory.
393
"""
394
html_output_dir = self._output_dir('html')
395
parent_dir = os.path.realpath(os.path.join(html_output_dir, '..'))
396
for filename in os.listdir(html_output_dir):
397
parent_filename = os.path.join(parent_dir, filename)
398
if not os.path.exists(parent_filename):
399
continue
400
if os.path.isdir(parent_filename):
401
shutil.rmtree(parent_filename, ignore_errors=True)
402
else:
403
os.unlink(parent_filename)
404
405
root_index_file = os.path.join(html_output_dir, '../../../index.html')
406
if os.path.exists(root_index_file):
407
os.remove(root_index_file)
408
409
DocBuilder.clean(self)
410
411
412
class ReferenceBuilder():
413
"""
414
This class builds the reference manual. It uses DocBuilder to
415
build the top-level page and ReferenceSubBuilder for each
416
sub-component.
417
"""
418
def __init__(self, name:str, options: BuildOptions):
419
"""
420
Record the reference manual's name, in case it's not
421
identical to 'reference'.
422
"""
423
self.name = name
424
self.options = options
425
426
def _output_dir(self, type: Literal['html', 'latex', 'pdf']) -> Path:
427
"""
428
Return the directory where the output of type ``type`` is stored.
429
430
If the directory does not exist, then it will automatically be
431
created.
432
433
EXAMPLES::
434
435
sage: from sage_docbuild.builders import ReferenceBuilder
436
sage: from sage_docbuild.build_options import BuildOptions
437
sage: import tempfile
438
sage: with tempfile.TemporaryDirectory() as directory:
439
....: options = BuildOptions(output_dir = Path(directory))
440
....: builder = ReferenceBuilder('reference', options)
441
....: builder._output_dir('html')
442
...Path('.../html/reference')
443
"""
444
dir = self.options.output_dir / type / self.name
445
dir.mkdir(parents=True, exist_ok=True)
446
return dir
447
448
def _source_dir(self) -> Path:
449
return self.options.source_dir / self.name
450
451
def _build_bibliography(self, format, *args, **kwds):
452
"""
453
Build the bibliography only
454
455
The bibliography references.aux is referenced by the other
456
manuals and needs to be built first.
457
"""
458
references = [
459
(doc, 'en', format, kwds) + args for doc in get_all_documents(self._source_dir())
460
if doc == 'reference/references'
461
]
462
build_many(build_ref_doc, references)
463
464
def _build_everything_except_bibliography(self, format, *args, **kwds):
465
"""
466
Build the entire reference manual except the bibliography
467
"""
468
non_references = [
469
(doc, 'en', format, kwds) + args for doc in get_all_documents(self._source_dir())
470
if doc != Path('reference/references')
471
]
472
build_many(build_ref_doc, non_references)
473
474
def _build_top_level(self, format, *args, **kwds):
475
"""
476
Build top-level document.
477
"""
478
getattr(ReferenceTopBuilder('reference', self.options), format)(*args, **kwds)
479
480
def _wrapper(self, format, *args, **kwds):
481
"""
482
Build reference manuals: build the top-level document and its components.
483
"""
484
logger.info('Building bibliography')
485
self._build_bibliography(format, *args, **kwds)
486
logger.info('Bibliography finished, building dependent manuals')
487
self._build_everything_except_bibliography(format, *args, **kwds)
488
# The html refman must be built at the end to ensure correct
489
# merging of indexes and inventories.
490
# Sphinx is run here in the current process (not in a
491
# subprocess) and the IntersphinxCache gets populated to be
492
# used for the second pass of the reference manual and for
493
# the other documents.
494
self._build_top_level(format, *args, **kwds)
495
496
class ReferenceTopBuilder(DocBuilder):
497
"""
498
This class builds the top-level page of the reference manual.
499
"""
500
def __init__(self, name: str, options: BuildOptions):
501
DocBuilder.__init__(self, 'en/reference', options)
502
503
def html(self):
504
"""
505
Build the top-level document.
506
"""
507
super().html()
508
509
# We want to build master index file which lists all of the PDF file.
510
# We modify the file index.html from the "reference_top" target, if it
511
# exists. Otherwise, we are done.
512
output_dir = self._output_dir('html')
513
514
# Install in output_dir a symlink to the directory containing static files.
515
# Prefer relative path for symlinks.
516
relpath = output_dir.relative_to(self._options.output_dir)
517
try:
518
(output_dir / '_static').symlink_to(relpath / '_static')
519
except FileExistsError:
520
pass
521
522
# Now modify top reference index.html page and write it to output_dir.
523
with open(output_dir / 'index.html') as f:
524
html = f.read()
525
# Fix links in navigation bar
526
html = re.sub(r'<a href="(.*)">Sage(.*)Documentation</a>',
527
r'<a href="../../../html/en/index.html">Sage\2Documentation</a>',
528
html)
529
html = re.sub(r'<li class="right"(.*)>', r'<li class="right" style="display: none" \1>',
530
html)
531
html = re.sub(r'<div class="sphinxsidebar"(.*)>', r'<div class="sphinxsidebar" style="display: none" \1>',
532
html)
533
534
# From index.html, we want the preamble and the tail.
535
html_end_preamble = html.find(r'<section')
536
html_bottom = html.rfind(r'</section>') + len(r'</section>')
537
538
# For the content, we modify doc/en/reference/index.rst, which
539
# has two parts: the body and the table of contents.
540
with open(self.dir / 'index.rst') as f:
541
rst = f.read()
542
# Get rid of todolist and miscellaneous rst markup.
543
rst = rst.replace('.. _reference-manual:\n\n', '')
544
rst = re.sub(r'\\\\', r'\\', rst)
545
# Replace rst links with html links. There are three forms:
546
#
547
# `blah`__ followed by __ LINK
548
#
549
# `blah <LINK>`_
550
#
551
# :doc:`blah <module/index>`
552
#
553
# Change the first and the second forms to
554
#
555
# <a href="LINK">blah</a>
556
#
557
# Change the third form to
558
#
559
# <a href="module/module.pdf"><img src="_static/pdf.png">blah</a>
560
#
561
rst = re.sub(r'`([^`\n]*)`__.*\n\n__ (.*)',
562
r'<a href="\2">\1</a>.', rst)
563
rst = re.sub(r'`([^<\n]*)\s+<(.*)>`_',
564
r'<a href="\2">\1</a>', rst)
565
rst = re.sub(r':doc:`([^<]*?)\s+<(.*)/index>`',
566
r'<a title="PDF" class="pdf" href="../../../pdf/en/reference/\2/\2.pdf"><img src="_static/pdf.png"></a><a href="\2/index.html">\1</a> ', rst)
567
# Body: add paragraph <p> markup.
568
start = rst.rfind('*\n') + 1
569
end = rst.find('\nUser Interfaces')
570
rst_body = rst[start:end]
571
rst_body = rst_body.replace('\n\n', '</p>\n<p>')
572
# TOC: don't include the indices
573
start = rst.find('\nUser Interfaces')
574
end = rst.find('Indices and Tables')
575
rst_toc = rst[start:end]
576
# change * to <li>; change rst headers to html headers
577
rst_toc = re.sub(r'\*(.*)\n',
578
r'<li>\1</li>\n', rst_toc)
579
rst_toc = re.sub(r'\n([A-Z][a-zA-Z, ]*)\n[=]*\n',
580
r'</ul>\n\n\n<h2>\1</h2>\n\n<ul>\n', rst_toc)
581
rst_toc = re.sub(r'\n([A-Z][a-zA-Z, ]*)\n[-]*\n',
582
r'</ul>\n\n\n<h3>\1</h3>\n\n<ul>\n', rst_toc)
583
# now write the file.
584
with open(output_dir / 'index-pdf.html', 'w') as new_index:
585
new_index.write(html[:html_end_preamble])
586
new_index.write('<h1>Sage Reference Manual</h1>')
587
new_index.write(rst_body)
588
new_index.write('<ul>')
589
new_index.write(rst_toc)
590
new_index.write('</ul>\n\n')
591
new_index.write(html[html_bottom:])
592
593
594
class ReferenceSubBuilder(DocBuilder):
595
"""
596
This class builds sub-components of the reference manual. It is
597
responsible for making sure that the auto generated reST files for the
598
Sage library are up to date.
599
600
When building any output, we must first go through and check
601
to see if we need to update any of the autogenerated reST
602
files. There are two cases where this would happen:
603
604
1. A new module gets added to one of the toctrees.
605
2. The actual module gets updated and possibly contains a new title.
606
"""
607
_cache = None
608
609
def __init__(self, name: str, options: BuildOptions):
610
DocBuilder.__init__(self, "en/" + name, options)
611
self._wrap_builder_helpers()
612
613
def _wrap_builder_helpers(self):
614
from functools import partial, update_wrapper
615
for attr in dir(self):
616
if hasattr(getattr(self, attr), 'is_output_format'):
617
f = partial(self._wrapper, attr)
618
f.is_output_format = True
619
update_wrapper(f, getattr(self, attr))
620
setattr(self, attr, f)
621
622
def _wrapper(self, build_type, *args, **kwds):
623
"""
624
This is the wrapper around the builder_helper methods that
625
goes through and makes sure things are up to date.
626
"""
627
# Force regeneration of all modules if the inherited
628
# and/or underscored members options have changed.
629
cache = self.get_cache()
630
force = False
631
try:
632
if (cache['option_inherited'] != self._options.inherited or
633
cache['option_underscore'] != self._options.underscore):
634
logger.info("Detected change(s) in inherited and/or underscored members option(s).")
635
force = True
636
except KeyError:
637
force = True
638
cache['option_inherited'] = self._options.inherited
639
cache['option_underscore'] = self._options.underscore
640
self.save_cache()
641
642
# Refresh the reST file mtimes in environment.pickle
643
if self._options.update_mtimes:
644
logger.info("Checking for reST file mtimes to update...")
645
self.update_mtimes()
646
647
if force:
648
# Write reST files for all modules from scratch.
649
self.clean_auto()
650
for module_name in self.get_all_included_modules():
651
self.write_auto_rest_file(module_name)
652
else:
653
# Write reST files for new and updated modules.
654
for module_name in self.get_new_and_updated_modules():
655
self.write_auto_rest_file(module_name)
656
657
# Copy over the custom reST files from _sage
658
_sage = self.dir / '_sage'
659
if _sage.exists():
660
logger.info(f"Copying over custom reST files from {_sage} ...")
661
shutil.copytree(_sage, self.dir / 'sage')
662
663
# Copy over some generated reST file in the build directory
664
# (Background: Meson puts them in the build directory, but Sphinx can also read
665
# files from the source directory, see https://github.com/sphinx-doc/sphinx/issues/3132)
666
generated_dir = self._options.output_dir / self.name
667
for file in generated_dir.rglob('*'):
668
shutil.copy2(file, self.dir / file.relative_to(generated_dir))
669
670
getattr(DocBuilder, build_type)(self, *args, **kwds)
671
672
def cache_file(self) -> Path:
673
"""
674
Return the filename where the pickle of the reference cache
675
is stored.
676
"""
677
return self._doctrees_dir() / 'reference.pickle'
678
679
def get_cache(self):
680
"""
681
Retrieve the reference cache which contains the options previously used
682
by the reference builder.
683
684
If it doesn't exist, then we just return an empty dictionary. If it
685
is corrupted, return an empty dictionary.
686
"""
687
if self._cache is not None:
688
return self._cache
689
690
cache_file = self.cache_file()
691
if not cache_file.exists():
692
return {}
693
try:
694
with cache_file.open('rb') as file:
695
cache = pickle.load(file)
696
except Exception:
697
logger.debug(f"Cache file '{cache_file}' is corrupted; ignoring it...")
698
cache = {}
699
else:
700
logger.debug(f"Loaded the reference cache: {cache_file}")
701
self._cache = cache
702
return cache
703
704
def save_cache(self):
705
"""
706
Pickle the current reference cache for later retrieval.
707
"""
708
cache = self.get_cache()
709
try:
710
with open(self.cache_file(), 'wb') as file:
711
pickle.dump(cache, file)
712
logger.debug("Saved the reference cache: %s", self.cache_file())
713
except PermissionError:
714
logger.debug("Permission denied for the reference cache: %s", self.cache_file())
715
716
def get_sphinx_environment(self):
717
"""
718
Return the Sphinx environment for this project.
719
"""
720
env_pickle = os.path.join(self._doctrees_dir(), 'environment.pickle')
721
try:
722
with open(env_pickle, 'rb') as f:
723
env = pickle.load(f)
724
logger.debug("Opened Sphinx environment: %s", env_pickle)
725
return env
726
except (OSError, EOFError):
727
logger.debug(
728
f"Failed to open Sphinx environment '{env_pickle}'", exc_info=True)
729
730
def update_mtimes(self):
731
"""
732
Update the modification times for reST files in the Sphinx
733
environment for this project.
734
"""
735
env = self.get_sphinx_environment()
736
if env is not None:
737
for doc in env.all_docs:
738
env.all_docs[doc] = time.time()
739
logger.info("Updated %d reST file mtimes", len(env.all_docs))
740
741
# This is the only place we need to save (as opposed to
742
# load) Sphinx's pickle, so we do it right here.
743
env_pickle = os.path.join(self._doctrees_dir(), 'environment.pickle')
744
745
# remove unpicklable attributes
746
env.set_warnfunc(None)
747
with open(env_pickle, 'wb') as picklefile:
748
pickle.dump(env, picklefile, pickle.HIGHEST_PROTOCOL)
749
750
logger.debug("Saved Sphinx environment: %s", env_pickle)
751
752
def get_modified_modules(self):
753
"""
754
Return an iterator for all the modules that have been modified
755
since the documentation was last built.
756
"""
757
env = self.get_sphinx_environment()
758
if env is None:
759
logger.debug("Stopped check for modified modules.")
760
return
761
try:
762
added, changed, removed = env.get_outdated_files(False)
763
logger.info("Sphinx found %d modified modules", len(changed))
764
except OSError as err:
765
logger.debug("Sphinx failed to determine modified modules: %s", err)
766
return
767
for name in changed:
768
# Only pay attention to files in a directory sage/... In
769
# particular, don't treat a file like 'sagetex.rst' in
770
# doc/en/reference/misc as an autogenerated file: see
771
# #14199.
772
if name.startswith('sage' + os.sep):
773
yield name
774
775
def print_modified_modules(self):
776
"""
777
Print a list of all the modules that have been modified since
778
the documentation was last built.
779
"""
780
for module_name in self.get_modified_modules():
781
print(module_name)
782
783
def get_all_rst_files(self) -> Generator[Path, None, None]:
784
"""
785
Return an iterator for all rst files which are not autogenerated.
786
"""
787
for file in self.dir.rglob('*.rst'):
788
if 'sage' in file.relative_to(self.dir).parts:
789
continue
790
yield file
791
792
def get_all_included_modules(self):
793
"""
794
Return an iterator for all modules which are included in the
795
reference manual.
796
"""
797
for file in self.get_all_rst_files():
798
for module in self.get_modules(file):
799
yield module
800
801
def get_new_and_updated_modules(self):
802
"""
803
Return an iterator for all new and updated modules that appear in
804
the toctrees, and remove obsolete old modules.
805
"""
806
env = self.get_sphinx_environment()
807
if env is None:
808
all_docs = {}
809
else:
810
all_docs = env.all_docs
811
812
new_modules = []
813
updated_modules = []
814
old_modules = []
815
for module_name in self.get_all_included_modules():
816
docname = module_name.replace('.', os.path.sep)
817
818
if docname not in all_docs:
819
new_modules.append(module_name)
820
yield module_name
821
continue
822
823
# get the modification timestamp of the reST doc for the module
824
mtime = all_docs[docname]
825
try:
826
with warnings.catch_warnings():
827
# primarily intended to ignore deprecation warnings
828
warnings.simplefilter("ignore")
829
__import__(module_name)
830
except ImportError as err:
831
logger.error("Warning: Could not import %s %s", module_name, err)
832
raise
833
834
module_filename = sys.modules[module_name].__file__
835
if module_filename is None:
836
# Namespace package
837
old_modules.append(module_name)
838
continue
839
if (module_filename.endswith('.pyc') or module_filename.endswith('.pyo')):
840
source_filename = module_filename[:-1]
841
if (os.path.exists(source_filename)):
842
module_filename = source_filename
843
newtime = os.path.getmtime(module_filename)
844
845
if newtime > mtime:
846
updated_modules.append(module_name)
847
yield module_name
848
else: # keep good old module
849
old_modules.append(module_name)
850
851
removed_modules = []
852
for docname in all_docs.keys():
853
if docname.startswith('sage' + os.path.sep):
854
module_name = docname.replace(os.path.sep, '.')
855
if not (module_name in old_modules or module_name in updated_modules):
856
try:
857
os.remove(os.path.join(self.dir, docname) + '.rst')
858
except OSError: # already removed
859
pass
860
logger.debug("Deleted auto-generated reST file {}".format(docname))
861
removed_modules.append(module_name)
862
863
logger.info("Found %d new modules", len(new_modules))
864
logger.info("Found %d updated modules", len(updated_modules))
865
logger.info("Removed %d obsolete modules", len(removed_modules))
866
867
def print_new_and_updated_modules(self):
868
"""
869
Print all the modules that appear in the toctrees that
870
are newly included or updated.
871
"""
872
for module_name in self.get_new_and_updated_modules():
873
print(module_name)
874
875
def get_modules(self, file: Path) -> Generator[str, None, None]:
876
"""
877
Given a reST file, return an iterator for
878
all of the autogenerated reST files that it includes.
879
"""
880
# Create the regular expression used to detect an autogenerated file
881
auto_re = re.compile(r'^\s*(..\/)*(sage(_docbuild)?\/[\w\/]*)\s*$')
882
883
# Read the lines
884
with file.open(encoding='utf-8') as f:
885
lines = f.readlines()
886
887
for line in lines:
888
match = auto_re.match(line)
889
if match:
890
yield match.group(2).replace('/', '.')
891
892
def get_module_docstring_title(self, module_name):
893
"""
894
Return the title of the module from its docstring.
895
"""
896
# Try to import the module
897
try:
898
__import__(module_name)
899
except ImportError as err:
900
logger.error("Warning: Could not import %s %s", module_name, err)
901
return "UNABLE TO IMPORT MODULE"
902
module = sys.modules[module_name]
903
904
# Get the docstring
905
doc = module.__doc__
906
if doc is None:
907
doc = module.doc if hasattr(module, 'doc') else ""
908
909
# Extract the title
910
i = doc.find('\n')
911
if i != -1:
912
return doc[i + 1:].lstrip().splitlines()[0]
913
else:
914
return doc
915
916
def auto_rest_filename(self, module_name: str) -> Path:
917
"""
918
Return the name of the file associated to a given module
919
920
EXAMPLES::
921
922
sage: from sage_docbuild.builders import ReferenceSubBuilder
923
sage: from sage_docbuild.build_options import BuildOptions
924
sage: options = BuildOptions(source_dir = Path('src/doc'))
925
sage: ReferenceSubBuilder("reference", options).auto_rest_filename("sage.combinat.partition")
926
...Path('src/doc/en/reference/sage/combinat/partition.rst')
927
"""
928
return self.dir / (module_name.replace('.', os.path.sep) + '.rst')
929
930
def write_auto_rest_file(self, module_name: str):
931
"""
932
Write the autogenerated reST file for module_name.
933
"""
934
if not module_name.startswith('sage'):
935
return
936
937
title = self.get_module_docstring_title(module_name)
938
if title == '':
939
logger.error("Warning: Missing title for %s", module_name)
940
title = "MISSING TITLE"
941
942
rst_file = self.auto_rest_filename(module_name)
943
rst_file.parent.mkdir(parents=True, exist_ok=True)
944
with rst_file.open('w') as outfile:
945
# Don't doctest the autogenerated file.
946
outfile.write(".. nodoctest\n\n")
947
# Now write the actual content.
948
outfile.write(".. _%s:\n\n" % (module_name.replace(".__init__", "")))
949
outfile.write(title + '\n')
950
outfile.write('=' * len(title) + "\n\n")
951
outfile.write('.. This file has been autogenerated.\n\n')
952
953
inherited = ':inherited-members:' if self._options.inherited else ''
954
955
automodule = '''
956
.. automodule:: %s
957
:members:
958
:undoc-members:
959
:show-inheritance:
960
%s
961
962
'''
963
outfile.write(automodule % (module_name, inherited))
964
965
def clean_auto(self):
966
"""
967
Remove all autogenerated reST files.
968
"""
969
try:
970
shutil.rmtree(os.path.join(self.dir, 'sage'))
971
logger.debug("Deleted auto-generated reST files in: %s",
972
os.path.join(self.dir, 'sage'))
973
except OSError:
974
pass
975
976
def get_unincluded_modules(self):
977
"""
978
Return an iterator for all the modules in the Sage library
979
which are not included in the reference manual.
980
"""
981
# Make a dictionary of the included modules
982
included_modules = {}
983
for module_name in self.get_all_included_modules():
984
included_modules[module_name] = True
985
986
base_path = os.path.join(SAGE_SRC, 'sage')
987
for directory, subdirs, files in os.walk(base_path):
988
for filename in files:
989
if not (filename.endswith('.py') or
990
filename.endswith('.pyx')):
991
continue
992
993
path = os.path.join(directory, filename)
994
995
# Create the module name
996
module_name = path[len(base_path):].replace(os.path.sep, '.')
997
module_name = 'sage' + module_name
998
module_name = module_name[:-4] if module_name.endswith('pyx') else module_name[:-3]
999
1000
# Exclude some ones -- we don't want init the manual
1001
if module_name.endswith('__init__') or module_name.endswith('all'):
1002
continue
1003
1004
if module_name not in included_modules:
1005
yield module_name
1006
1007
def print_unincluded_modules(self):
1008
"""
1009
Print all of the modules which are not included in the Sage
1010
reference manual.
1011
"""
1012
for module_name in self.get_unincluded_modules():
1013
print(module_name)
1014
1015
def print_included_modules(self):
1016
"""
1017
Print all of the modules that are included in the Sage reference
1018
manual.
1019
"""
1020
for module_name in self.get_all_included_modules():
1021
print(module_name)
1022
1023
1024
class SingleFileBuilder(DocBuilder):
1025
"""
1026
This is the class used to build the documentation for a single
1027
user-specified file. If the file is called 'foo.py', then the
1028
documentation is built in ``DIR/foo/`` if the user passes the
1029
command line option "-o DIR", or in ``DOT_SAGE/docbuild/foo/``
1030
otherwise.
1031
"""
1032
def __init__(self, path):
1033
"""
1034
INPUT:
1035
1036
- ``path`` -- the path to the file for which documentation
1037
should be built
1038
"""
1039
self.lang = 'en'
1040
self.name = 'single_file'
1041
path = os.path.abspath(path)
1042
1043
# Create docbuild and relevant subdirectories, e.g.,
1044
# the static and templates directories in the output directory.
1045
# By default, this is DOT_SAGE/docbuild/MODULE_NAME, but can
1046
# also be specified at the command line.
1047
module_name = os.path.splitext(os.path.basename(path))[0]
1048
latex_name = module_name.replace('_', r'\\_')
1049
1050
if self._options.output_dir:
1051
base_dir = os.path.join(self._options.output_dir, module_name)
1052
if os.path.exists(base_dir):
1053
logger.warning('Warning: Directory %s exists. It is safer to build in a new directory.' % base_dir)
1054
else:
1055
base_dir = os.path.join(DOT_SAGE, 'docbuild', module_name)
1056
try:
1057
shutil.rmtree(base_dir)
1058
except OSError:
1059
pass
1060
self.dir = os.path.join(base_dir, 'source')
1061
1062
os.makedirs(os.path.join(self.dir, "static"), exist_ok=True)
1063
os.makedirs(os.path.join(self.dir, "templates"), exist_ok=True)
1064
# Write self.dir/conf.py
1065
conf = r"""# This file is automatically generated by {}, do not edit!
1066
1067
import sys, os, contextlib
1068
sys.path.append({!r})
1069
1070
from sage.docs.conf import *
1071
html_static_path = [] + html_common_static_path
1072
1073
project = 'Documentation for {}'
1074
release = 'unknown'
1075
name = {!r}
1076
html_title = project
1077
html_short_title = project
1078
htmlhelp_basename = name
1079
1080
with contextlib.suppress(ValueError):
1081
extensions.remove('multidocs') # see #29651
1082
extensions.remove('inventory_builder')
1083
1084
latex_domain_indices = False
1085
latex_documents = [
1086
('index', name + '.tex', 'Documentation for {}',
1087
'unknown', 'manual'),
1088
]
1089
""".format(__file__, self.dir, module_name, module_name, latex_name)
1090
1091
if 'SAGE_DOC_UNDERSCORE' in os.environ:
1092
conf += r"""
1093
def setup(app):
1094
app.connect('autodoc-skip-member', skip_member)
1095
"""
1096
1097
with open(os.path.join(self.dir, 'conf.py'), 'w') as conffile:
1098
conffile.write(conf)
1099
1100
# Write self.dir/index.rst
1101
title = 'Docs for file %s' % path
1102
heading = title + "\n" + ("=" * len(title))
1103
index = r"""{}
1104
1105
.. This file is automatically generated by {}, do not edit!
1106
1107
.. automodule:: {}
1108
:members:
1109
:undoc-members:
1110
:show-inheritance:
1111
""".format(heading, __file__, module_name)
1112
with open(os.path.join(self.dir, 'index.rst'), 'w') as indexfile:
1113
indexfile.write(index)
1114
1115
# Create link from original file to self.dir. Note that we
1116
# append self.dir to sys.path in conf.py. This is reasonably
1117
# safe (but not perfect), since we just created self.dir.
1118
try:
1119
os.symlink(path, os.path.join(self.dir, os.path.basename(path)))
1120
except OSError:
1121
pass
1122
1123
def _output_dir(self, type):
1124
"""
1125
Return the directory where the output of type ``type`` is stored.
1126
1127
If the directory does not exist, then it will automatically be
1128
created.
1129
"""
1130
base_dir = os.path.split(self.dir)[0]
1131
d = os.path.join(base_dir, "output", type)
1132
os.makedirs(d, exist_ok=True)
1133
return d
1134
1135
def _doctrees_dir(self):
1136
"""
1137
Return the directory where the doctrees are stored.
1138
1139
If the directory does not exist, then it will automatically be
1140
created.
1141
"""
1142
return self._output_dir('doctrees')
1143
1144
1145
def get_builder(name: str, options: BuildOptions) -> DocBuilder | ReferenceBuilder:
1146
"""
1147
Return an appropriate *Builder* object for the document ``name``.
1148
1149
DocBuilder and its subclasses do all the real work in building the
1150
documentation.
1151
"""
1152
if name == 'reference_top':
1153
return ReferenceTopBuilder('reference', options)
1154
elif name.endswith('reference'):
1155
return ReferenceBuilder(name, options)
1156
elif 'reference' in name and (options.source_dir / 'en' / name).exists():
1157
return ReferenceSubBuilder(name, options)
1158
elif name.endswith('website'):
1159
return WebsiteBuilder(name, options)
1160
elif name.startswith('file='):
1161
path = name[5:]
1162
if path.endswith('.sage') or path.endswith('.pyx'):
1163
raise NotImplementedError('Building documentation for a single file only works for Python files.')
1164
return SingleFileBuilder(path)
1165
elif Path(name) in get_all_documents(options.source_dir):
1166
return DocBuilder(name, options)
1167
else:
1168
print("'%s' is not a recognized document. Type 'sage --docbuild -D' for a list" % name)
1169
print("of documents, or 'sage --docbuild --help' for more help.")
1170
sys.exit(1)
1171
1172
1173
def get_all_documents(source: Path) -> list[Path]:
1174
"""
1175
Return a list of all of the documents, relative to the source
1176
directory.
1177
1178
A document is a directory within one of the language
1179
subdirectories of ``doc``.
1180
1181
EXAMPLES::
1182
1183
sage: from sage_docbuild.builders import get_all_documents
1184
sage: documents = get_all_documents(Path('src/doc'))
1185
sage: Path('en/tutorial') in documents
1186
True
1187
"""
1188
documents = []
1189
for lang in [path for path in source.iterdir() if path.is_dir()]:
1190
if not re.match('^[a-z][a-z]$', lang.name):
1191
# Skip non-language directories
1192
continue
1193
for document in lang.iterdir():
1194
if (document.name not in build_options.OMIT
1195
and document.is_dir()):
1196
documents.append(document.relative_to(source))
1197
1198
# Top-level reference document is build seperately
1199
if Path('en/reference') in documents:
1200
documents.remove(Path('en/reference'))
1201
1202
return documents
1203
1204
def get_all_reference_documents(source: Path) -> list[Path]:
1205
"""
1206
Return a list of all reference manual documents to build, relative to the
1207
specified source directory.
1208
1209
We add a document if it's a subdirectory of the manual's
1210
directory and contains a file named 'index.rst'.
1211
1212
The order corresponds to the order in which the documents should be built.
1213
1214
EXAMPLES::
1215
1216
sage: from sage_docbuild.builders import get_all_reference_documents
1217
sage: documents = get_all_reference_documents(Path('src/doc/en'))
1218
sage: Path('reference/algebras') in documents
1219
True
1220
"""
1221
documents: list[tuple[int, Path]] = []
1222
1223
for directory in (source / 'reference').iterdir():
1224
if (directory / 'index.rst').exists():
1225
n = len(list(directory.iterdir()))
1226
documents.append((-n, directory.relative_to(source)))
1227
1228
# Sort largest component (most subdirectory entries) first since
1229
# they will take the longest to build
1230
docs = [doc[1] for doc in sorted(documents)]
1231
# Put the bibliography first, because it needs to be built first:
1232
docs.remove(Path('reference/references'))
1233
docs.insert(0, Path('reference/references'))
1234
1235
# Add the top-level reference document
1236
docs.append(Path('reference_top'))
1237
1238
return docs
1239
1240