Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemath
GitHub Repository: sagemath/sage
Path: blob/develop/src/sage_docbuild/builders.py
7363 views
1
# sage.doctest: needs sphinx
2
"""
3
Documentation builders
4
5
.. NOTE::
6
7
If you are a developer and want to build the SageMath documentation from source,
8
refer to `developer's guide <../../../developer/sage_manuals.html>`_.
9
10
This module is the starting point for building documentation, and is
11
responsible to figure out what to build and with which options. The actual
12
documentation build for each individual document is then done in a subprocess
13
call to Sphinx, see :func:`builder_helper`. Note that
14
15
* The builders are configured with ``build_options.py``;
16
* The Sphinx subprocesses are configured in ``conf.py``.
17
18
:class:`DocBuilder` is the base class of all Builders. It has builder helpers
19
:meth:`html()`, :meth:`latex`, :meth:`pdf`, :meth:`inventory`, etc, which are
20
invoked depending on the output type. Each type corresponds with the Sphinx
21
builder format, except that ``pdf`` is Sphinx latex builder plus compiling
22
latex to pdf. Note that Sphinx inventory builder is not native to Sphinx
23
but provided by Sage. See ``sage_docbuild/ext/inventory_builder.py``. The
24
Sphinx inventory builder is a dummy builder with no actual output but produces
25
doctree files in ``local/share/doctree`` and ``inventory.inv`` inventory files
26
in ``local/share/inventory``.
27
28
The reference manual is built in two passes, first by :class:`ReferenceBuilder`
29
with ``inventory`` output type and secondly with ``html`` output type. The
30
:class:`ReferenceBuilder` itself uses :class:`ReferenceTopBuilder` and
31
:class:`ReferenceSubBuilder` to build subcomponents of the reference manual.
32
The :class:`ReferenceSubBuilder` examines the modules included in the
33
subcomponent by comparing the modification times of the module files with the
34
times saved in ``local/share/doctree/reference.pickle`` from the previous
35
build. Then new rst files are generated for new and updated modules. See
36
:meth:`get_new_and_updated_modules()`.
37
38
After :issue:`31948`, when Sage is built, :class:`ReferenceBuilder` is not used
39
and its responsibility is now taken by the ``Makefile`` in ``$SAGE_ROOT/src/doc``.
40
"""
41
42
# ****************************************************************************
43
# Copyright (C) 2008-2009 Mike Hansen <[email protected]>
44
# 2009-2010 Mitesh Patel <[email protected]>
45
# 2009-2015 J. H. Palmieri <[email protected]>
46
# 2009 Carl Witty <[email protected]>
47
# 2010-2017 Jeroen Demeyer <[email protected]>
48
# 2012 William Stein <[email protected]>
49
# 2012-2014 Nicolas M. Thiery <[email protected]>
50
# 2012-2015 André Apitzsch <[email protected]>
51
# 2012 Florent Hivert <[email protected]>
52
# 2013-2014 Volker Braun <[email protected]>
53
# 2013 R. Andrew Ohana <[email protected]>
54
# 2015 Thierry Monteil <[email protected]>
55
# 2015 Marc Mezzarobba <[email protected]>
56
# 2015 Travis Scrimshaw <tscrim at ucdavis.edu>
57
# 2016-2017 Frédéric Chapoton <[email protected]>
58
# 2016 Erik M. Bray <[email protected]>
59
# 2017 Kwankyu Lee <[email protected]>
60
# 2017 François Bissey <[email protected]>
61
# 2018 Julian Rüth <[email protected]>
62
#
63
# This program is free software: you can redistribute it and/or modify
64
# it under the terms of the GNU General Public License as published by
65
# the Free Software Foundation, either version 2 of the License, or
66
# (at your option) any later version.
67
# https://www.gnu.org/licenses/
68
# ****************************************************************************
69
70
import logging
71
import os
72
import pickle
73
import re
74
import shlex
75
import shutil
76
import subprocess
77
import sys
78
import time
79
import warnings
80
from collections.abc import Generator
81
from pathlib import Path
82
from typing import Literal
83
84
from . import build_options
85
from .build_options import BuildOptions
86
from .utils import build_many as _build_many
87
88
logger = logging.getLogger(__name__)
89
90
91
##########################################
92
# Parallel Building Ref Manual #
93
##########################################
94
95
def build_ref_doc(args):
96
doc = args[0]
97
lang = args[1]
98
format = args[2]
99
kwds = args[3]
100
args = args[4:]
101
if format == 'inventory': # you must not use the inventory to build the inventory
102
kwds['use_multidoc_inventory'] = False
103
getattr(ReferenceSubBuilder(doc, lang), format)(*args, **kwds)
104
105
106
##########################################
107
# Builders #
108
##########################################
109
110
def builder_helper(type):
111
"""
112
Return a function which builds the documentation for
113
output type ``type``.
114
"""
115
def f(self, *args, **kwds):
116
output_dir = self._output_dir(type)
117
118
options = build_options.ALLSPHINXOPTS
119
120
if self.name == 'website':
121
# WEBSITESPHINXOPTS is either empty or " -A hide_pdf_links=1 "
122
options += build_options.WEBSITESPHINXOPTS
123
124
if kwds.get('use_multidoc_inventory', True) and type != 'inventory':
125
options += ' -D multidoc_first_pass=0'
126
else:
127
options += ' -D multidoc_first_pass=1'
128
129
build_command = '-b %s -d %s %s %s %s' % (type, self._doctrees_dir(),
130
options, self.dir,
131
output_dir)
132
133
# Provide "pdf" tag to be used with "only" directive as an alias of "latex"
134
if type == 'latex':
135
build_command = '-t pdf ' + build_command
136
137
logger.debug(build_command)
138
139
# Run Sphinx with Sage's special logger
140
sys.argv = ["sphinx-build"] + build_command.split()
141
from .sphinxbuild import runsphinx
142
try:
143
runsphinx()
144
except Exception:
145
if build_options.ABORT_ON_ERROR:
146
raise
147
except BaseException as e:
148
# We need to wrap a BaseException that is not an Exception in a
149
# regular Exception. Otherwise multiprocessing.Pool.get hangs, see
150
# #25161
151
if build_options.ABORT_ON_ERROR:
152
raise Exception("Non-exception during docbuild: %s" % (e,), e)
153
154
if type == 'latex':
155
logger.warning(f"LaTeX files can be found in {output_dir}.")
156
elif type != 'inventory':
157
logger.warning(f"Build finished. The built documents can be found in {output_dir}.")
158
159
f.is_output_format = True
160
return f
161
162
163
class DocBuilder():
164
def __init__(self, name: str, options: BuildOptions):
165
"""
166
INPUT:
167
168
- ``name`` -- the name of a subdirectory in ``doc/<lang>``, such as
169
'tutorial' or 'installation'
170
"""
171
self.name = name
172
self.dir = options.source_dir / self.name
173
self._options = options
174
175
def _output_dir(self, type):
176
"""
177
Return the directory where the output of type ``type`` is stored.
178
179
If the directory does not exist, then it will automatically be
180
created.
181
182
EXAMPLES::
183
184
sage: from sage_docbuild.builders import DocBuilder
185
sage: from sage_docbuild.build_options import BuildOptions
186
sage: import tempfile
187
sage: with tempfile.TemporaryDirectory() as directory:
188
....: options = BuildOptions(output_dir=Path(directory), source_dir=Path('src/doc'))
189
....: builder = DocBuilder('en/tutorial', options)
190
....: builder._output_dir('html')
191
...Path('.../html/en/tutorial')
192
"""
193
dir = self._options.output_dir / type / self.name
194
dir.mkdir(parents=True, exist_ok=True)
195
return dir
196
197
def _doctrees_dir(self) -> Path:
198
"""
199
Return the directory where the doctrees are stored.
200
201
If the directory does not exist, then it will automatically be
202
created.
203
204
EXAMPLES::
205
206
sage: from sage_docbuild.builders import DocBuilder
207
sage: from sage_docbuild.build_options import BuildOptions
208
sage: import tempfile
209
sage: with tempfile.TemporaryDirectory() as directory:
210
....: options = BuildOptions(output_dir=Path(directory), source_dir=Path('src/doc'))
211
....: builder = DocBuilder('en/tutorial', options)
212
....: builder._doctrees_dir()
213
...Path('.../doctrees/en/tutorial')
214
"""
215
dir = self._options.output_dir / 'doctrees' / self.name
216
dir.mkdir(parents=True, exist_ok=True)
217
return dir
218
219
def _output_formats(self):
220
"""
221
Return a list of the possible output formats.
222
223
EXAMPLES::
224
225
sage: from sage_docbuild.builders import DocBuilder
226
sage: from sage_docbuild.build_options import BuildOptions
227
sage: options = BuildOptions(source_dir=Path('src/doc'))
228
sage: builder = DocBuilder('tutorial', options)
229
sage: builder._output_formats()
230
['changes', 'html', 'htmlhelp', 'inventory', 'json', 'latex', 'linkcheck', 'pickle', 'web']
231
"""
232
# Go through all the attributes of self and check to
233
# see which ones have an 'is_output_format' attribute. These
234
# are the ones created with builder_helper.
235
output_formats = []
236
for attr in dir(self):
237
if hasattr(getattr(self, attr), 'is_output_format'):
238
output_formats.append(attr)
239
output_formats.sort()
240
return output_formats
241
242
def pdf(self):
243
"""
244
Build the PDF files for this document.
245
246
This is done by first (re)-building the LaTeX output, going
247
into that LaTeX directory, and running 'make all-pdf' there.
248
249
EXAMPLES::
250
251
sage: from sage_docbuild.builders import DocBuilder
252
sage: from sage_docbuild.build_options import BuildOptions
253
sage: options = BuildOptions(source_dir = Path('src/doc'))
254
sage: builder = DocBuilder('tutorial', options)
255
sage: builder.pdf() #not tested
256
"""
257
self.latex()
258
tex_dir = self._output_dir('latex')
259
pdf_dir = self._output_dir('pdf')
260
261
if self.name == 'reference':
262
# recover maths in tex, undoing what Sphinx did (trac #29993)
263
tex_file = tex_dir / 'reference.tex'
264
with open(tex_file) as f:
265
ref = f.read()
266
ref = re.sub(r'\\textbackslash{}', r'\\', ref)
267
ref = re.sub(r'\\textbackslash{}', r'\\', ref)
268
ref = re.sub(r'\\{', r'{', ref)
269
ref = re.sub(r'\\}', r'}', ref)
270
ref = re.sub(r'\\_', r'_', ref)
271
ref = re.sub(r'\\textasciicircum{}', r'^', ref)
272
with open(tex_file, 'w') as f:
273
f.write(ref)
274
275
make_cmd = os.environ.get('MAKE', 'make')
276
command = shlex.split(make_cmd) + ['all-pdf']
277
logger.debug(f"Running {' '.join(command)} in {tex_dir}")
278
279
proc = subprocess.run(
280
command,
281
check=False, cwd=tex_dir,
282
capture_output=True,
283
text=True,
284
)
285
286
if proc.returncode != 0:
287
logger.error(f"stdout from {make_cmd}:\n{proc.stdout}")
288
logger.error(f"stderr from {make_cmd}:\n{proc.stderr}")
289
raise RuntimeError(f"failed to run {' '.join(command)} in {tex_dir}")
290
291
if proc.stdout:
292
logger.debug(f"make stdout:\n{proc.stdout}")
293
if proc.stderr:
294
# Still surface stderr even on success, but at debug level
295
logger.debug(f"make stderr:\n{proc.stderr}")
296
297
# Move generated PDFs
298
for pdf in tex_dir.glob("*.pdf"):
299
try:
300
dst_pdf = os.path.join(pdf_dir, os.path.basename(pdf))
301
shutil.move(str(pdf), dst_pdf)
302
except Exception as e:
303
logger.error(f"Failed moving {pdf} to {dst_pdf}: {e}")
304
raise
305
306
logger.info(f"Build finished. The built documents can be found in {pdf_dir}.")
307
308
def clean(self, *args):
309
shutil.rmtree(self._doctrees_dir())
310
output_formats = list(args) if args else self._output_formats()
311
for format in output_formats:
312
shutil.rmtree(self._output_dir(format), ignore_errors=True)
313
314
html = builder_helper('html')
315
pickle = builder_helper('pickle')
316
web = pickle
317
json = builder_helper('json')
318
htmlhelp = builder_helper('htmlhelp')
319
latex = builder_helper('latex')
320
changes = builder_helper('changes')
321
linkcheck = builder_helper('linkcheck')
322
# import the customized builder for object.inv files
323
inventory = builder_helper('inventory')
324
325
326
def build_many(target, args, processes=None):
327
"""
328
Thin wrapper around :func:`sage_docbuild.utils.build_many` which uses the
329
docbuild settings ``NUM_THREADS`` and ``ABORT_ON_ERROR``.
330
"""
331
if processes is None:
332
processes = build_options.NUM_THREADS
333
try:
334
_build_many(target, args, processes=processes)
335
except BaseException:
336
if build_options.ABORT_ON_ERROR:
337
raise
338
339
340
##########################################
341
# Parallel Building Ref Manual #
342
##########################################
343
class WebsiteBuilder(DocBuilder):
344
def html(self):
345
"""
346
After we have finished building the website index page, we copy
347
everything one directory up, that is, to the base diectory ``html/en``.
348
349
In addition, an index file is installed into the root doc directory.
350
351
Thus we have three index.html files:
352
353
html/en/website/index.html (not used)
354
html/en/index.html (base directory)
355
index.html (root doc directory)
356
"""
357
super().html()
358
html_output_dir = self._output_dir('html')
359
360
# This file is used by src/doc/common/static/jupyter-sphinx-furo.js
361
# for doc version selector
362
shutil.copy2(os.path.join(self.dir, 'versions.txt'), html_output_dir)
363
364
for f in os.listdir(html_output_dir):
365
src = os.path.join(html_output_dir, f)
366
dst = os.path.join(html_output_dir, '..', f)
367
if os.path.isdir(src):
368
shutil.rmtree(dst, ignore_errors=True)
369
shutil.copytree(src, dst)
370
else:
371
shutil.copy2(src, dst)
372
373
shutil.copy2(os.path.join(self.dir, 'root_index.html'),
374
os.path.join(html_output_dir, '../../../index.html'))
375
376
def pdf(self):
377
"""
378
Build the website hosting pdf docs.
379
"""
380
super().pdf()
381
382
# If the website exists, update it.
383
384
from sage.env import SAGE_DOC
385
website_dir = os.path.join(SAGE_DOC, 'html', 'en', 'website')
386
387
if os.path.exists(os.path.join(website_dir, 'index.html')):
388
# Rebuild WITHOUT --no-pdf-links, which is translated to
389
# "-A hide_pdf_links=1" Sphinx argument. Thus effectively
390
# the index page SHOWS links to pdf docs.
391
self.html()
392
393
def clean(self):
394
"""
395
When we clean the output for the website index, we need to
396
remove all of the HTML that were placed in the parent
397
directory.
398
399
In addition, remove the index file installed into the root doc directory.
400
"""
401
html_output_dir = self._output_dir('html')
402
parent_dir = os.path.realpath(os.path.join(html_output_dir, '..'))
403
for filename in os.listdir(html_output_dir):
404
parent_filename = os.path.join(parent_dir, filename)
405
if not os.path.exists(parent_filename):
406
continue
407
if os.path.isdir(parent_filename):
408
shutil.rmtree(parent_filename, ignore_errors=True)
409
else:
410
os.unlink(parent_filename)
411
412
root_index_file = os.path.join(html_output_dir, '../../../index.html')
413
if os.path.exists(root_index_file):
414
os.remove(root_index_file)
415
416
DocBuilder.clean(self)
417
418
419
class ReferenceBuilder():
420
"""
421
This class builds the reference manual. It uses DocBuilder to
422
build the top-level page and ReferenceSubBuilder for each
423
sub-component.
424
"""
425
def __init__(self, name:str, options: BuildOptions):
426
"""
427
Record the reference manual's name, in case it's not
428
identical to 'reference'.
429
"""
430
self.name = name
431
self.options = options
432
433
def _output_dir(self, type: Literal['html', 'latex', 'pdf']) -> Path:
434
"""
435
Return the directory where the output of type ``type`` is stored.
436
437
If the directory does not exist, then it will automatically be
438
created.
439
440
EXAMPLES::
441
442
sage: from sage_docbuild.builders import ReferenceBuilder
443
sage: from sage_docbuild.build_options import BuildOptions
444
sage: import tempfile
445
sage: with tempfile.TemporaryDirectory() as directory:
446
....: options = BuildOptions(output_dir = Path(directory))
447
....: builder = ReferenceBuilder('reference', options)
448
....: builder._output_dir('html')
449
...Path('.../html/reference')
450
"""
451
dir = self.options.output_dir / type / self.name
452
dir.mkdir(parents=True, exist_ok=True)
453
return dir
454
455
def _source_dir(self) -> Path:
456
return self.options.source_dir / self.name
457
458
def _build_bibliography(self, format, *args, **kwds):
459
"""
460
Build the bibliography only
461
462
The bibliography references.aux is referenced by the other
463
manuals and needs to be built first.
464
"""
465
references = [
466
(doc, 'en', format, kwds) + args for doc in get_all_documents(self._source_dir())
467
if doc == 'reference/references'
468
]
469
build_many(build_ref_doc, references)
470
471
def _build_everything_except_bibliography(self, format, *args, **kwds):
472
"""
473
Build the entire reference manual except the bibliography
474
"""
475
non_references = [
476
(doc, 'en', format, kwds) + args for doc in get_all_documents(self._source_dir())
477
if doc != Path('reference/references')
478
]
479
build_many(build_ref_doc, non_references)
480
481
def _build_top_level(self, format, *args, **kwds):
482
"""
483
Build top-level document.
484
"""
485
getattr(ReferenceTopBuilder('reference', self.options), format)(*args, **kwds)
486
487
def _wrapper(self, format, *args, **kwds):
488
"""
489
Build reference manuals: build the top-level document and its components.
490
"""
491
logger.info('Building bibliography')
492
self._build_bibliography(format, *args, **kwds)
493
logger.info('Bibliography finished, building dependent manuals')
494
self._build_everything_except_bibliography(format, *args, **kwds)
495
# The html refman must be built at the end to ensure correct
496
# merging of indexes and inventories.
497
# Sphinx is run here in the current process (not in a
498
# subprocess) and the IntersphinxCache gets populated to be
499
# used for the second pass of the reference manual and for
500
# the other documents.
501
self._build_top_level(format, *args, **kwds)
502
503
class ReferenceTopBuilder(DocBuilder):
504
"""
505
This class builds the top-level page of the reference manual.
506
"""
507
def __init__(self, name: str, options: BuildOptions):
508
DocBuilder.__init__(self, 'en/reference', options)
509
510
def html(self):
511
"""
512
Build the top-level document.
513
"""
514
super().html()
515
516
# We want to build master index file which lists all of the PDF file.
517
# We modify the file index.html from the "reference_top" target, if it
518
# exists. Otherwise, we are done.
519
output_dir = self._output_dir('html')
520
521
# Install in output_dir a symlink to the directory containing static files.
522
# Prefer relative path for symlinks.
523
relpath = output_dir.relative_to(self._options.output_dir)
524
try:
525
(output_dir / '_static').symlink_to(relpath / '_static')
526
except FileExistsError:
527
pass
528
529
# Now modify top reference index.html page and write it to output_dir.
530
with open(output_dir / 'index.html') as f:
531
html = f.read()
532
# Fix links in navigation bar
533
html = re.sub(r'<a href="(.*)">Sage(.*)Documentation</a>',
534
r'<a href="../../../html/en/index.html">Sage\2Documentation</a>',
535
html)
536
html = re.sub(r'<li class="right"(.*)>', r'<li class="right" style="display: none" \1>',
537
html)
538
html = re.sub(r'<div class="sphinxsidebar"(.*)>', r'<div class="sphinxsidebar" style="display: none" \1>',
539
html)
540
541
# From index.html, we want the preamble and the tail.
542
html_end_preamble = html.find(r'<section')
543
html_bottom = html.rfind(r'</section>') + len(r'</section>')
544
545
# For the content, we modify doc/en/reference/index.rst, which
546
# has two parts: the body and the table of contents.
547
with open(self.dir / 'index.rst') as f:
548
rst = f.read()
549
# Get rid of todolist and miscellaneous rst markup.
550
rst = rst.replace('.. _reference-manual:\n\n', '')
551
rst = re.sub(r'\\\\', r'\\', rst)
552
# Replace rst links with html links. There are three forms:
553
#
554
# `blah`__ followed by __ LINK
555
#
556
# `blah <LINK>`_
557
#
558
# :doc:`blah <module/index>`
559
#
560
# Change the first and the second forms to
561
#
562
# <a href="LINK">blah</a>
563
#
564
# Change the third form to
565
#
566
# <a href="module/module.pdf"><img src="_static/pdf.png">blah</a>
567
#
568
rst = re.sub(r'`([^`\n]*)`__.*\n\n__ (.*)',
569
r'<a href="\2">\1</a>.', rst)
570
rst = re.sub(r'`([^<\n]*)\s+<(.*)>`_',
571
r'<a href="\2">\1</a>', rst)
572
rst = re.sub(r':doc:`([^<]*?)\s+<(.*)/index>`',
573
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)
574
# Body: add paragraph <p> markup.
575
start = rst.rfind('*\n') + 1
576
end = rst.find('\nUser Interfaces')
577
rst_body = rst[start:end]
578
rst_body = rst_body.replace('\n\n', '</p>\n<p>')
579
# TOC: don't include the indices
580
start = rst.find('\nUser Interfaces')
581
end = rst.find('Indices and Tables')
582
rst_toc = rst[start:end]
583
# change * to <li>; change rst headers to html headers
584
rst_toc = re.sub(r'\*(.*)\n',
585
r'<li>\1</li>\n', rst_toc)
586
rst_toc = re.sub(r'\n([A-Z][a-zA-Z, ]*)\n[=]*\n',
587
r'</ul>\n\n\n<h2>\1</h2>\n\n<ul>\n', rst_toc)
588
rst_toc = re.sub(r'\n([A-Z][a-zA-Z, ]*)\n[-]*\n',
589
r'</ul>\n\n\n<h3>\1</h3>\n\n<ul>\n', rst_toc)
590
# now write the file.
591
with open(output_dir / 'index-pdf.html', 'w') as new_index:
592
new_index.write(html[:html_end_preamble])
593
new_index.write('<h1>Sage Reference Manual</h1>')
594
new_index.write(rst_body)
595
new_index.write('<ul>')
596
new_index.write(rst_toc)
597
new_index.write('</ul>\n\n')
598
new_index.write(html[html_bottom:])
599
600
601
class ReferenceSubBuilder(DocBuilder):
602
"""
603
This class builds sub-components of the reference manual. It is
604
responsible for making sure that the auto generated reST files for the
605
Sage library are up to date.
606
607
When building any output, we must first go through and check
608
to see if we need to update any of the autogenerated reST
609
files. There are two cases where this would happen:
610
611
1. A new module gets added to one of the toctrees.
612
2. The actual module gets updated and possibly contains a new title.
613
"""
614
_cache = None
615
616
def __init__(self, name: str, options: BuildOptions):
617
DocBuilder.__init__(self, "en/" + name, options)
618
self._wrap_builder_helpers()
619
620
def _wrap_builder_helpers(self):
621
from functools import partial, update_wrapper
622
for attr in dir(self):
623
if hasattr(getattr(self, attr), 'is_output_format'):
624
f = partial(self._wrapper, attr)
625
f.is_output_format = True
626
update_wrapper(f, getattr(self, attr))
627
setattr(self, attr, f)
628
629
def _wrapper(self, build_type, *args, **kwds):
630
"""
631
This is the wrapper around the builder_helper methods that
632
goes through and makes sure things are up to date.
633
"""
634
# Force regeneration of all modules if the inherited
635
# and/or underscored members options have changed.
636
cache = self.get_cache()
637
force = False
638
try:
639
if (cache['option_inherited'] != self._options.inherited or
640
cache['option_underscore'] != self._options.underscore):
641
logger.info("Detected change(s) in inherited and/or underscored members option(s).")
642
force = True
643
except KeyError:
644
force = True
645
cache['option_inherited'] = self._options.inherited
646
cache['option_underscore'] = self._options.underscore
647
self.save_cache()
648
649
# Refresh the reST file mtimes in environment.pickle
650
if self._options.update_mtimes:
651
logger.info("Checking for reST file mtimes to update...")
652
self.update_mtimes()
653
654
if force:
655
# Write reST files for all modules from scratch.
656
self.clean_auto()
657
for module_name in self.get_all_included_modules():
658
self.write_auto_rest_file(module_name)
659
else:
660
# Write reST files for new and updated modules.
661
for module_name in self.get_new_and_updated_modules():
662
self.write_auto_rest_file(module_name)
663
664
# Copy over the custom reST files from _sage
665
_sage = self.dir / '_sage'
666
if _sage.exists():
667
logger.info(f"Copying over custom reST files from {_sage} ...")
668
shutil.copytree(_sage, self.dir / 'sage')
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: from sage.env import SAGE_DOC_SRC
1185
sage: documents = get_all_documents(Path(SAGE_DOC_SRC))
1186
sage: Path('en/tutorial') in documents
1187
True
1188
"""
1189
documents = []
1190
for lang in [path for path in source.iterdir() if path.is_dir()]:
1191
if not re.match('^[a-z][a-z]$', lang.name):
1192
# Skip non-language directories
1193
continue
1194
for document in lang.iterdir():
1195
if (document.name not in build_options.OMIT
1196
and document.is_dir()):
1197
documents.append(document.relative_to(source))
1198
1199
# Top-level reference document is build seperately
1200
if Path('en/reference') in documents:
1201
documents.remove(Path('en/reference'))
1202
1203
return documents
1204
1205
def get_all_reference_documents(source: Path) -> list[Path]:
1206
"""
1207
Return a list of all reference manual documents to build, relative to the
1208
specified source directory.
1209
1210
We add a document if it's a subdirectory of the manual's
1211
directory and contains a file named 'index.rst'.
1212
1213
The order corresponds to the order in which the documents should be built.
1214
1215
EXAMPLES::
1216
1217
sage: from sage_docbuild.builders import get_all_reference_documents
1218
sage: from sage.env import SAGE_DOC_SRC
1219
sage: documents = get_all_reference_documents(Path(SAGE_DOC_SRC) / 'en')
1220
sage: Path('reference/algebras') in documents
1221
True
1222
"""
1223
documents: list[tuple[int, Path]] = []
1224
1225
for directory in (source / 'reference').iterdir():
1226
if (directory / 'index.rst').exists():
1227
n = len(list(directory.iterdir()))
1228
documents.append((-n, directory.relative_to(source)))
1229
1230
# Sort largest component (most subdirectory entries) first since
1231
# they will take the longest to build
1232
docs = [doc[1] for doc in sorted(documents)]
1233
# Put the bibliography first, because it needs to be built first:
1234
docs.remove(Path('reference/references'))
1235
docs.insert(0, Path('reference/references'))
1236
1237
# Add the top-level reference document
1238
docs.append(Path('reference_top'))
1239
1240
return docs
1241
1242