Skip to content

Commit d5e5bf7

Browse files
DOC: Doc build for a single doc made much faster, and clean up (#24428)
1 parent 1ebfd8a commit d5e5bf7

File tree

3 files changed

+159
-240
lines changed

3 files changed

+159
-240
lines changed

doc/make.py

+92-200
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,9 @@
1515
import sys
1616
import os
1717
import shutil
18-
# import subprocess
18+
import subprocess
1919
import argparse
20-
from contextlib import contextmanager
2120
import webbrowser
22-
import jinja2
2321

2422

2523
DOC_PATH = os.path.dirname(os.path.abspath(__file__))
@@ -28,179 +26,68 @@
2826
BUILD_DIRS = ['doctrees', 'html', 'latex', 'plots', '_static', '_templates']
2927

3028

31-
@contextmanager
32-
def _maybe_exclude_notebooks():
33-
"""Skip building the notebooks if pandoc is not installed.
34-
35-
This assumes that nbsphinx is installed.
36-
37-
Skip notebook conversion if:
38-
1. nbconvert isn't installed, or
39-
2. nbconvert is installed, but pandoc isn't
40-
"""
41-
# TODO move to exclude_pattern
42-
base = os.path.dirname(__file__)
43-
notebooks = [os.path.join(base, 'source', nb)
44-
for nb in ['style.ipynb']]
45-
contents = {}
46-
47-
def _remove_notebooks():
48-
for nb in notebooks:
49-
with open(nb, 'rt') as f:
50-
contents[nb] = f.read()
51-
os.remove(nb)
52-
53-
try:
54-
import nbconvert
55-
except ImportError:
56-
sys.stderr.write('Warning: nbconvert not installed. '
57-
'Skipping notebooks.\n')
58-
_remove_notebooks()
59-
else:
60-
try:
61-
nbconvert.utils.pandoc.get_pandoc_version()
62-
except nbconvert.utils.pandoc.PandocMissing:
63-
sys.stderr.write('Warning: Pandoc is not installed. '
64-
'Skipping notebooks.\n')
65-
_remove_notebooks()
66-
67-
yield
68-
69-
for nb, content in contents.items():
70-
with open(nb, 'wt') as f:
71-
f.write(content)
72-
73-
7429
class DocBuilder:
75-
"""Class to wrap the different commands of this script.
30+
"""
31+
Class to wrap the different commands of this script.
7632
7733
All public methods of this class can be called as parameters of the
7834
script.
7935
"""
80-
def __init__(self, num_jobs=1, include_api=True, single_doc=None,
81-
verbosity=0):
36+
def __init__(self, num_jobs=0, include_api=True, single_doc=None,
37+
verbosity=0, warnings_are_errors=False):
8238
self.num_jobs = num_jobs
83-
self.include_api = include_api
8439
self.verbosity = verbosity
85-
self.single_doc = None
86-
self.single_doc_type = None
87-
if single_doc is not None:
88-
self._process_single_doc(single_doc)
89-
self.exclude_patterns = self._exclude_patterns
90-
91-
self._generate_index()
92-
if self.single_doc_type == 'docstring':
93-
self._run_os('sphinx-autogen', '-o',
94-
'source/generated_single', 'source/index.rst')
95-
96-
@property
97-
def _exclude_patterns(self):
98-
"""Docs source files that will be excluded from building."""
99-
# TODO move maybe_exclude_notebooks here
100-
if self.single_doc is not None:
101-
rst_files = [f for f in os.listdir(SOURCE_PATH)
102-
if ((f.endswith('.rst') or f.endswith('.ipynb'))
103-
and (f != 'index.rst')
104-
and (f != '{0}.rst'.format(self.single_doc)))]
105-
if self.single_doc_type != 'api':
106-
rst_files += ['generated/*.rst']
107-
elif not self.include_api:
108-
rst_files = ['api.rst', 'generated/*.rst']
109-
else:
110-
rst_files = ['generated_single/*.rst']
111-
112-
exclude_patterns = ','.join(
113-
'{!r}'.format(i) for i in ['**.ipynb_checkpoints'] + rst_files)
114-
115-
return exclude_patterns
40+
self.warnings_are_errors = warnings_are_errors
41+
42+
if single_doc:
43+
single_doc = self._process_single_doc(single_doc)
44+
include_api = False
45+
os.environ['SPHINX_PATTERN'] = single_doc
46+
elif not include_api:
47+
os.environ['SPHINX_PATTERN'] = '-api'
48+
49+
self.single_doc_html = None
50+
if single_doc and single_doc.endswith('.rst'):
51+
self.single_doc_html = os.path.splitext(single_doc)[0] + '.html'
52+
elif single_doc:
53+
self.single_doc_html = 'generated/pandas.{}.html'.format(
54+
single_doc)
11655

11756
def _process_single_doc(self, single_doc):
118-
"""Extract self.single_doc (base name) and self.single_doc_type from
119-
passed single_doc kwarg.
120-
12157
"""
122-
self.include_api = False
123-
124-
if single_doc == 'api.rst' or single_doc == 'api':
125-
self.single_doc_type = 'api'
126-
self.single_doc = 'api'
127-
elif os.path.exists(os.path.join(SOURCE_PATH, single_doc)):
128-
self.single_doc_type = 'rst'
58+
Make sure the provided value for --single is a path to an existing
59+
.rst/.ipynb file, or a pandas object that can be imported.
12960
130-
if 'whatsnew' in single_doc:
131-
basename = single_doc
61+
For example, categorial.rst or pandas.DataFrame.head. For the latter,
62+
return the corresponding file path
63+
(e.g. generated/pandas.DataFrame.head.rst).
64+
"""
65+
base_name, extension = os.path.splitext(single_doc)
66+
if extension in ('.rst', '.ipynb'):
67+
if os.path.exists(os.path.join(SOURCE_PATH, single_doc)):
68+
return single_doc
13269
else:
133-
basename = os.path.basename(single_doc)
134-
self.single_doc = os.path.splitext(basename)[0]
135-
elif os.path.exists(
136-
os.path.join(SOURCE_PATH, '{}.rst'.format(single_doc))):
137-
self.single_doc_type = 'rst'
138-
self.single_doc = single_doc
139-
elif single_doc is not None:
70+
raise FileNotFoundError('File {} not found'.format(single_doc))
71+
72+
elif single_doc.startswith('pandas.'):
14073
try:
14174
obj = pandas # noqa: F821
14275
for name in single_doc.split('.'):
14376
obj = getattr(obj, name)
14477
except AttributeError:
145-
raise ValueError('Single document not understood, it should '
146-
'be a file in doc/source/*.rst (e.g. '
147-
'"contributing.rst" or a pandas function or '
148-
'method (e.g. "pandas.DataFrame.head")')
78+
raise ImportError('Could not import {}'.format(single_doc))
14979
else:
150-
self.single_doc_type = 'docstring'
151-
if single_doc.startswith('pandas.'):
152-
self.single_doc = single_doc[len('pandas.'):]
153-
else:
154-
self.single_doc = single_doc
155-
156-
def _copy_generated_docstring(self):
157-
"""Copy existing generated (from api.rst) docstring page because
158-
this is more correct in certain cases (where a custom autodoc
159-
template is used).
160-
161-
"""
162-
fname = os.path.join(SOURCE_PATH, 'generated',
163-
'pandas.{}.rst'.format(self.single_doc))
164-
temp_dir = os.path.join(SOURCE_PATH, 'generated_single')
165-
166-
try:
167-
os.makedirs(temp_dir)
168-
except OSError:
169-
pass
170-
171-
if os.path.exists(fname):
172-
try:
173-
# copying to make sure sphinx always thinks it is new
174-
# and needs to be re-generated (to pick source code changes)
175-
shutil.copy(fname, temp_dir)
176-
except: # noqa
177-
pass
178-
179-
def _generate_index(self):
180-
"""Create index.rst file with the specified sections."""
181-
if self.single_doc_type == 'docstring':
182-
self._copy_generated_docstring()
183-
184-
with open(os.path.join(SOURCE_PATH, 'index.rst.template')) as f:
185-
t = jinja2.Template(f.read())
186-
187-
with open(os.path.join(SOURCE_PATH, 'index.rst'), 'w') as f:
188-
f.write(t.render(include_api=self.include_api,
189-
single_doc=self.single_doc,
190-
single_doc_type=self.single_doc_type))
191-
192-
@staticmethod
193-
def _create_build_structure():
194-
"""Create directories required to build documentation."""
195-
for dirname in BUILD_DIRS:
196-
try:
197-
os.makedirs(os.path.join(BUILD_PATH, dirname))
198-
except OSError:
199-
pass
80+
return single_doc[len('pandas.'):]
81+
else:
82+
raise ValueError(('--single={} not understood. Value should be a '
83+
'valid path to a .rst or .ipynb file, or a '
84+
'valid pandas object (e.g. categorical.rst or '
85+
'pandas.DataFrame.head)').format(single_doc))
20086

20187
@staticmethod
20288
def _run_os(*args):
203-
"""Execute a command as a OS terminal.
89+
"""
90+
Execute a command as a OS terminal.
20491
20592
Parameters
20693
----------
@@ -211,16 +98,11 @@ def _run_os(*args):
21198
--------
21299
>>> DocBuilder()._run_os('python', '--version')
213100
"""
214-
# TODO check_call should be more safe, but it fails with
215-
# exclude patterns, needs investigation
216-
# subprocess.check_call(args, stderr=subprocess.STDOUT)
217-
exit_status = os.system(' '.join(args))
218-
if exit_status:
219-
msg = 'Command "{}" finished with exit code {}'
220-
raise RuntimeError(msg.format(' '.join(args), exit_status))
101+
subprocess.check_call(args, stdout=sys.stdout, stderr=sys.stderr)
221102

222103
def _sphinx_build(self, kind):
223-
"""Call sphinx to build documentation.
104+
"""
105+
Call sphinx to build documentation.
224106
225107
Attribute `num_jobs` from the class is used.
226108
@@ -236,43 +118,44 @@ def _sphinx_build(self, kind):
236118
raise ValueError('kind must be html or latex, '
237119
'not {}'.format(kind))
238120

239-
self._run_os('sphinx-build',
240-
'-j{}'.format(self.num_jobs),
241-
'-b{}'.format(kind),
242-
'-{}'.format(
243-
'v' * self.verbosity) if self.verbosity else '',
244-
'-d"{}"'.format(os.path.join(BUILD_PATH, 'doctrees')),
245-
'-Dexclude_patterns={}'.format(self.exclude_patterns),
246-
'"{}"'.format(SOURCE_PATH),
247-
'"{}"'.format(os.path.join(BUILD_PATH, kind)))
248-
249-
def _open_browser(self):
250-
base_url = os.path.join('file://', DOC_PATH, 'build', 'html')
251-
if self.single_doc_type == 'docstring':
252-
url = os.path.join(
253-
base_url,
254-
'generated_single', 'pandas.{}.html'.format(self.single_doc))
255-
else:
256-
url = os.path.join(base_url, '{}.html'.format(self.single_doc))
121+
self.clean()
122+
123+
cmd = ['sphinx-build', '-b', kind]
124+
if self.num_jobs:
125+
cmd += ['-j', str(self.num_jobs)]
126+
if self.warnings_are_errors:
127+
cmd.append('-W')
128+
if self.verbosity:
129+
cmd.append('-{}'.format('v' * self.verbosity))
130+
cmd += ['-d', os.path.join(BUILD_PATH, 'doctrees'),
131+
SOURCE_PATH, os.path.join(BUILD_PATH, kind)]
132+
cmd = ['sphinx-build', SOURCE_PATH, os.path.join(BUILD_PATH, kind)]
133+
self._run_os(*cmd)
134+
135+
def _open_browser(self, single_doc_html):
136+
"""
137+
Open a browser tab showing single
138+
"""
139+
url = os.path.join('file://', DOC_PATH, 'build', 'html',
140+
single_doc_html)
257141
webbrowser.open(url, new=2)
258142

259143
def html(self):
260-
"""Build HTML documentation."""
261-
self._create_build_structure()
262-
with _maybe_exclude_notebooks():
263-
self._sphinx_build('html')
264-
zip_fname = os.path.join(BUILD_PATH, 'html', 'pandas.zip')
265-
if os.path.exists(zip_fname):
266-
os.remove(zip_fname)
267-
268-
if self.single_doc is not None:
269-
self._open_browser()
270-
shutil.rmtree(os.path.join(SOURCE_PATH, 'generated_single'),
271-
ignore_errors=True)
144+
"""
145+
Build HTML documentation.
146+
"""
147+
self._sphinx_build('html')
148+
zip_fname = os.path.join(BUILD_PATH, 'html', 'pandas.zip')
149+
if os.path.exists(zip_fname):
150+
os.remove(zip_fname)
151+
152+
if self.single_doc_html is not None:
153+
self._open_browser(self.single_doc_html)
272154

273155
def latex(self, force=False):
274-
"""Build PDF documentation."""
275-
self._create_build_structure()
156+
"""
157+
Build PDF documentation.
158+
"""
276159
if sys.platform == 'win32':
277160
sys.stderr.write('latex build has not been tested on windows\n')
278161
else:
@@ -289,18 +172,24 @@ def latex(self, force=False):
289172
self._run_os('make')
290173

291174
def latex_forced(self):
292-
"""Build PDF documentation with retries to find missing references."""
175+
"""
176+
Build PDF documentation with retries to find missing references.
177+
"""
293178
self.latex(force=True)
294179

295180
@staticmethod
296181
def clean():
297-
"""Clean documentation generated files."""
182+
"""
183+
Clean documentation generated files.
184+
"""
298185
shutil.rmtree(BUILD_PATH, ignore_errors=True)
299186
shutil.rmtree(os.path.join(SOURCE_PATH, 'generated'),
300187
ignore_errors=True)
301188

302189
def zip_html(self):
303-
"""Compress HTML documentation into a zip file."""
190+
"""
191+
Compress HTML documentation into a zip file.
192+
"""
304193
zip_fname = os.path.join(BUILD_PATH, 'html', 'pandas.zip')
305194
if os.path.exists(zip_fname):
306195
os.remove(zip_fname)
@@ -326,7 +215,7 @@ def main():
326215
help='command to run: {}'.format(', '.join(cmds)))
327216
argparser.add_argument('--num-jobs',
328217
type=int,
329-
default=1,
218+
default=0,
330219
help='number of jobs used by sphinx-build')
331220
argparser.add_argument('--no-api',
332221
default=False,
@@ -345,6 +234,9 @@ def main():
345234
argparser.add_argument('-v', action='count', dest='verbosity', default=0,
346235
help=('increase verbosity (can be repeated), '
347236
'passed to the sphinx build command'))
237+
argparser.add_argument('--warnings-are-errors', '-W',
238+
action='store_true',
239+
help='fail if warnings are raised')
348240
args = argparser.parse_args()
349241

350242
if args.command not in cmds:
@@ -364,7 +256,7 @@ def main():
364256
os.environ['MPLBACKEND'] = 'module://matplotlib.backends.backend_agg'
365257

366258
builder = DocBuilder(args.num_jobs, not args.no_api, args.single,
367-
args.verbosity)
259+
args.verbosity, args.warnings_are_errors)
368260
getattr(builder, args.command)()
369261

370262

0 commit comments

Comments
 (0)