diff --git a/CHANGES b/CHANGES index f4d28a243..8856450ea 100644 --- a/CHANGES +++ b/CHANGES @@ -204,6 +204,8 @@ Bugs fixed * #4438: math: math with labels with whitespace cause html error * #2437: make full reference for classes, aliased with "alias of" * #4434: pure numbers as link targets produce warning +* #4477: Build fails after building specific files +* #4449: apidoc: include "empty" packages that contain modules Testing -------- diff --git a/sphinx/builders/html.py b/sphinx/builders/html.py index dcbc59280..c53a01406 100644 --- a/sphinx/builders/html.py +++ b/sphinx/builders/html.py @@ -54,9 +54,11 @@ from sphinx.environment.adapters.indexentries import IndexEntries if False: # For type annotation - from typing import Any, Dict, Iterable, Iterator, List, Type, Tuple, Union # NOQA - from sphinx.domains import Domain, Index # NOQA + from typing import Any, Dict, IO, Iterable, Iterator, List, Type, Tuple, Union # NOQA from sphinx.application import Sphinx # NOQA + from sphinx.config import Config # NOQA + from sphinx.domains import Domain, Index # NOQA + from sphinx.util.tags import Tags # NOQA # Experimental HTML5 Writer if is_html5_writer_available(): @@ -147,6 +149,56 @@ class Stylesheet(text_type): return self +class BuildInfo(object): + """buildinfo file manipulator. + + HTMLBuilder and its family are storing their own envdata to ``.buildinfo``. + This class is a manipulator for the file. + """ + + @classmethod + def load(cls, f): + # type: (IO) -> BuildInfo + try: + lines = f.readlines() + assert lines[0].rstrip() == '# Sphinx build info version 1' + assert lines[2].startswith('config: ') + assert lines[3].startswith('tags: ') + + build_info = BuildInfo() + build_info.config_hash = lines[2].split()[1].strip() + build_info.tags_hash = lines[3].split()[1].strip() + return build_info + except Exception as exc: + raise ValueError('build info file is broken: %r' % exc) + + def __init__(self, config=None, tags=None): + # type: (Config, Tags) -> None + self.config_hash = u'' + self.tags_hash = u'' + + if config: + values = dict((c.name, c.value) for c in config.filter('html')) + self.config_hash = get_stable_hash(values) + + if tags: + self.tags_hash = get_stable_hash(sorted(tags)) + + def __eq__(self, other): # type: ignore + # type: (BuildInfo) -> bool + return (self.config_hash == other.config_hash and + self.tags_hash == other.tags_hash) + + def dump(self, f): + # type: (IO) -> None + f.write('# Sphinx build info version 1\n' + '# This file hashes the configuration used when building these files.' + ' When it is not found, a full rebuild will be done.\n' + 'config: %s\n' + 'tags: %s\n' % + (self.config_hash, self.tags_hash)) + + class StandaloneHTMLBuilder(Builder): """ Builds standalone HTML docs. @@ -191,9 +243,7 @@ class StandaloneHTMLBuilder(Builder): def init(self): # type: () -> None - # a hash of all config values that, if changed, cause a full rebuild - self.config_hash = '' # type: unicode - self.tags_hash = '' # type: unicode + self.build_info = BuildInfo(self.config, self.tags) # basename of images directory self.imagedir = '_images' # section numbers for headings in the currently visited document @@ -274,32 +324,19 @@ class StandaloneHTMLBuilder(Builder): def get_outdated_docs(self): # type: () -> Iterator[unicode] - cfgdict = dict((confval.name, confval.value) for confval in self.config.filter('html')) - self.config_hash = get_stable_hash(cfgdict) - self.tags_hash = get_stable_hash(sorted(self.tags)) - old_config_hash = old_tags_hash = '' try: with open(path.join(self.outdir, '.buildinfo')) as fp: - version = fp.readline() - if version.rstrip() != '# Sphinx build info version 1': - raise ValueError - fp.readline() # skip commentary - cfg, old_config_hash = fp.readline().strip().split(': ') - if cfg != 'config': - raise ValueError - tag, old_tags_hash = fp.readline().strip().split(': ') - if tag != 'tags': - raise ValueError - except ValueError: - logger.warning('unsupported build info format in %r, building all', - path.join(self.outdir, '.buildinfo')) - except Exception: + buildinfo = BuildInfo.load(fp) + + if self.build_info != buildinfo: + for docname in self.env.found_docs: + yield docname + return + except ValueError as exc: + logger.warning('Failed to read build info file: %r', exc) + except IOError: + # ignore errors on reading pass - if old_config_hash != self.config_hash or \ - old_tags_hash != self.tags_hash: - for docname in self.env.found_docs: - yield docname - return if self.templates: template_mtime = self.templates.newest_template_mtime() @@ -777,14 +814,9 @@ class StandaloneHTMLBuilder(Builder): def write_buildinfo(self): # type: () -> None - # write build info file try: with open(path.join(self.outdir, '.buildinfo'), 'w') as fp: - fp.write('# Sphinx build info version 1\n' - '# This file hashes the configuration used when building' - ' these files. When it is not found, a full rebuild will' - ' be done.\nconfig: %s\ntags: %s\n' % - (self.config_hash, self.tags_hash)) + self.build_info.dump(fp) except IOError as exc: logger.warning('Failed to write build info file: %r', exc) @@ -1257,8 +1289,7 @@ class SerializingHTMLBuilder(StandaloneHTMLBuilder): def init(self): # type: () -> None - self.config_hash = '' - self.tags_hash = '' + self.build_info = BuildInfo(self.config, self.tags) self.imagedir = '_images' self.current_docname = None self.theme = None # no theme necessary diff --git a/sphinx/ext/apidoc.py b/sphinx/ext/apidoc.py index cec9d8138..efe8b780a 100644 --- a/sphinx/ext/apidoc.py +++ b/sphinx/ext/apidoc.py @@ -18,6 +18,7 @@ from __future__ import print_function import argparse +import glob import os import sys from os import path @@ -194,7 +195,17 @@ def shall_skip(module, opts): # skip it if there is nothing (or just \n or \r\n) in the file if path.exists(module) and path.getsize(module) <= 2: - return True + skip = True + if os.path.basename(module) == '__init__.py': + pattern = path.join(path.dirname(module), '*.py') + # We only want to skip packages if they do not contain any + # .py files other than __init__.py. + other_modules = list(glob.glob(pattern)) + other_modules.remove(module) + skip = not other_modules + + if skip: + return True # skip if it has a "private" name and this is selected filename = path.basename(module) diff --git a/tests/roots/test-apidoc-pep420/a/b/e/__init__.py b/tests/roots/test-apidoc-pep420/a/b/e/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/roots/test-apidoc-pep420/a/b/e/f.py b/tests/roots/test-apidoc-pep420/a/b/e/f.py new file mode 100644 index 000000000..a09affe86 --- /dev/null +++ b/tests/roots/test-apidoc-pep420/a/b/e/f.py @@ -0,0 +1 @@ +"Module f" diff --git a/tests/test_ext_apidoc.py b/tests/test_ext_apidoc.py index 2bfc8016e..8a60816bd 100644 --- a/tests/test_ext_apidoc.py +++ b/tests/test_ext_apidoc.py @@ -67,6 +67,7 @@ def test_pep_0420_enabled(make_app, apidoc): outdir = apidoc.outdir assert (outdir / 'conf.py').isfile() assert (outdir / 'a.b.c.rst').isfile() + assert (outdir / 'a.b.e.rst').isfile() assert (outdir / 'a.b.x.rst').isfile() with open(outdir / 'a.b.c.rst') as f: @@ -74,6 +75,10 @@ def test_pep_0420_enabled(make_app, apidoc): assert "automodule:: a.b.c.d\n" in rst assert "automodule:: a.b.c\n" in rst + with open(outdir / 'a.b.e.rst') as f: + rst = f.read() + assert "automodule:: a.b.e.f\n" in rst + with open(outdir / 'a.b.x.rst') as f: rst = f.read() assert "automodule:: a.b.x.y\n" in rst @@ -86,12 +91,67 @@ def test_pep_0420_enabled(make_app, apidoc): builddir = outdir / '_build' / 'text' assert (builddir / 'a.b.c.txt').isfile() + assert (builddir / 'a.b.e.txt').isfile() assert (builddir / 'a.b.x.txt').isfile() with open(builddir / 'a.b.c.txt') as f: txt = f.read() assert "a.b.c package\n" in txt + with open(builddir / 'a.b.e.txt') as f: + txt = f.read() + assert "a.b.e.f module\n" in txt + + with open(builddir / 'a.b.x.txt') as f: + txt = f.read() + assert "a.b.x namespace\n" in txt + + +@pytest.mark.apidoc( + coderoot='test-apidoc-pep420/a', + options=["--implicit-namespaces", "--separate"], +) +def test_pep_0420_enabled_separate(make_app, apidoc): + outdir = apidoc.outdir + assert (outdir / 'conf.py').isfile() + assert (outdir / 'a.b.c.rst').isfile() + assert (outdir / 'a.b.e.rst').isfile() + assert (outdir / 'a.b.e.f.rst').isfile() + assert (outdir / 'a.b.x.rst').isfile() + assert (outdir / 'a.b.x.y.rst').isfile() + + with open(outdir / 'a.b.c.rst') as f: + rst = f.read() + assert ".. toctree::\n\n a.b.c.d\n" in rst + + with open(outdir / 'a.b.e.rst') as f: + rst = f.read() + assert ".. toctree::\n\n a.b.e.f\n" in rst + + with open(outdir / 'a.b.x.rst') as f: + rst = f.read() + assert ".. toctree::\n\n a.b.x.y\n" in rst + + app = make_app('text', srcdir=outdir) + app.build() + print(app._status.getvalue()) + print(app._warning.getvalue()) + + builddir = outdir / '_build' / 'text' + assert (builddir / 'a.b.c.txt').isfile() + assert (builddir / 'a.b.e.txt').isfile() + assert (builddir / 'a.b.e.f.txt').isfile() + assert (builddir / 'a.b.x.txt').isfile() + assert (builddir / 'a.b.x.y.txt').isfile() + + with open(builddir / 'a.b.c.txt') as f: + txt = f.read() + assert "a.b.c package\n" in txt + + with open(builddir / 'a.b.e.f.txt') as f: + txt = f.read() + assert "a.b.e.f module\n" in txt + with open(builddir / 'a.b.x.txt') as f: txt = f.read() assert "a.b.x namespace\n" in txt