1
2import os
3import pkgutil
4import re
5import shutil
6import subprocess
7import sys
8from distutils.version import LooseVersion
9
10from distutils import log
11
12from sphinx.setup_command import BuildDoc as SphinxBuildDoc
13
14SUBPROCESS_TEMPLATE = """
15import os
16import sys
17
18{build_main}
19
20os.chdir({srcdir!r})
21
22{sys_path_inserts}
23
24for builder in {builders!r}:
25    retcode = build_main(argv={argv!r} + ['-b', builder, '.', os.path.join({output_dir!r}, builder)])
26    if retcode != 0:
27        sys.exit(retcode)
28"""
29
30
31def ensure_sphinx_astropy_installed():
32    """
33    Make sure that sphinx-astropy is available.
34    """
35
36    try:
37        from sphinx_astropy import __version__ as sphinx_astropy_version  # noqa
38    except ImportError:
39        sphinx_astropy_version = None
40
41    if (sphinx_astropy_version is None
42            or LooseVersion(sphinx_astropy_version) < LooseVersion('1.2')):
43        raise ImportError("sphinx-astropy 1.2 or later needs to be installed to build "
44                            "the documentation.")
45
46
47class AstropyBuildDocs(SphinxBuildDoc):
48    """
49    A version of the ``build_docs`` command that uses the version of Astropy
50    that is built by the setup ``build`` command, rather than whatever is
51    installed on the system.  To build docs against the installed version, run
52    ``make html`` in the ``astropy/docs`` directory.
53    """
54
55    description = 'Build Sphinx documentation for Astropy environment'
56    user_options = SphinxBuildDoc.user_options[:]
57    user_options.append(
58        ('warnings-returncode', 'w',
59         'Parses the sphinx output and sets the return code to 1 if there '
60         'are any warnings. Note that this will cause the sphinx log to '
61         'only update when it completes, rather than continuously as is '
62         'normally the case.'))
63    user_options.append(
64        ('clean-docs', 'l',
65         'Completely clean previous builds, including '
66         'automodapi-generated files before building new ones'))
67    user_options.append(
68        ('no-intersphinx', 'n',
69         'Skip intersphinx, even if conf.py says to use it'))
70    user_options.append(
71        ('open-docs-in-browser', 'o',
72         'Open the docs in a browser (using the webbrowser module) if the '
73         'build finishes successfully.'))
74    user_options.append(
75        ('parallel=', 'j',
76         'Build the docs in parallel on the specified number of '
77         'processes. If "auto", all the cores on the machine will be '
78         'used.'))
79
80    boolean_options = SphinxBuildDoc.boolean_options[:]
81    boolean_options.append('warnings-returncode')
82    boolean_options.append('clean-docs')
83    boolean_options.append('no-intersphinx')
84    boolean_options.append('open-docs-in-browser')
85
86    _self_iden_rex = re.compile(r"self\.([^\d\W][\w]+)", re.UNICODE)
87
88    def initialize_options(self):
89        SphinxBuildDoc.initialize_options(self)
90        self.clean_docs = False
91        self.no_intersphinx = False
92        self.open_docs_in_browser = False
93        self.warnings_returncode = False
94        self.traceback = False
95        self.parallel = None
96
97    def finalize_options(self):
98
99        # This has to happen before we call the parent class's finalize_options
100        if self.build_dir is None:
101            self.build_dir = 'docs/_build'
102
103        SphinxBuildDoc.finalize_options(self)
104
105        # Clear out previous sphinx builds, if requested
106        if self.clean_docs:
107
108            dirstorm = [os.path.join(self.source_dir, 'api'),
109                        os.path.join(self.source_dir, 'generated')]
110
111            dirstorm.append(self.build_dir)
112
113            for d in dirstorm:
114                if os.path.isdir(d):
115                    log.info('Cleaning directory ' + d)
116                    shutil.rmtree(d)
117                else:
118                    log.info('Not cleaning directory ' + d + ' because '
119                             'not present or not a directory')
120
121    def run(self):
122
123        # TODO: Break this method up into a few more subroutines and
124        # document them better
125        import webbrowser
126
127        from urllib.request import pathname2url
128
129        # This is used at the very end of `run` to decide if sys.exit should
130        # be called. If it's None, it won't be.
131        retcode = None
132
133        # Now make sure Astropy is built and determine where it was built
134        build_cmd = self.reinitialize_command('build')
135        build_cmd.inplace = 0
136        self.run_command('build')
137        build_cmd = self.get_finalized_command('build')
138        build_cmd_path = os.path.abspath(build_cmd.build_lib)
139
140        ah_importer = pkgutil.get_importer('astropy_helpers')
141        if ah_importer is None:
142            ah_path = '.'
143        else:
144            ah_path = os.path.abspath(ah_importer.path)
145
146        build_main = 'from sphinx.cmd.build import build_main'
147
148        # We need to make sure sphinx-astropy is installed
149        ensure_sphinx_astropy_installed()
150
151        sys_path_inserts = [build_cmd_path, ah_path]
152        sys_path_inserts = os.linesep.join(['sys.path.insert(0, {0!r})'.format(path) for path in sys_path_inserts])
153
154        argv = []
155
156        if self.warnings_returncode:
157            argv.append('-W')
158
159        if self.no_intersphinx:
160            argv.extend(['-D', 'disable_intersphinx=1'])
161
162        # We now need to adjust the flags based on the parent class's options
163
164        if self.fresh_env:
165            argv.append('-E')
166
167        if self.all_files:
168            argv.append('-a')
169
170        if getattr(self, 'pdb', False):
171            argv.append('-P')
172
173        if getattr(self, 'nitpicky', False):
174            argv.append('-n')
175
176        if self.traceback:
177            argv.append('-T')
178
179        # The default verbosity level is 1, so in that case we just don't add a flag
180        if self.verbose == 0:
181            argv.append('-q')
182        elif self.verbose > 1:
183            argv.append('-v')
184
185        if self.parallel is not None:
186            argv.append(f'-j={self.parallel}')
187
188        if isinstance(self.builder, str):
189            builders = [self.builder]
190        else:
191            builders = self.builder
192
193        subproccode = SUBPROCESS_TEMPLATE.format(build_main=build_main,
194                                                 srcdir=self.source_dir,
195                                                 sys_path_inserts=sys_path_inserts,
196                                                 builders=builders,
197                                                 argv=argv,
198                                                 output_dir=os.path.abspath(self.build_dir))
199
200        log.debug('Starting subprocess of {0} with python code:\n{1}\n'
201                  '[CODE END])'.format(sys.executable, subproccode))
202
203        proc = subprocess.Popen([sys.executable], stdin=subprocess.PIPE)
204        proc.communicate(subproccode.encode('utf-8'))
205        if proc.returncode != 0:
206            retcode = proc.returncode
207
208        if retcode is None:
209            if self.open_docs_in_browser:
210                if self.builder == 'html':
211                    absdir = os.path.abspath(self.builder_target_dir)
212                    index_path = os.path.join(absdir, 'index.html')
213                    fileurl = 'file://' + pathname2url(index_path)
214                    webbrowser.open(fileurl)
215                else:
216                    log.warn('open-docs-in-browser option was given, but '
217                             'the builder is not html! Ignoring.')
218
219        # Here we explicitly check proc.returncode since we only want to output
220        # this for cases where the return code really wasn't 0.
221        if proc.returncode:
222            log.warn('Sphinx Documentation subprocess failed with return '
223                     'code ' + str(proc.returncode))
224
225        if retcode is not None:
226            # this is potentially dangerous in that there might be something
227            # after the call to `setup` in `setup.py`, and exiting here will
228            # prevent that from running.  But there's no other apparent way
229            # to signal what the return code should be.
230            sys.exit(retcode)
231
232
233class AstropyBuildSphinx(AstropyBuildDocs):  # pragma: no cover
234    def run(self):
235        AstropyBuildDocs.run(self)
236