Fix #8727: apidoc: namespace module file is not generated if no submodules

sphinx-apidoc should generate a namespace module file when
`--implicit-namespace` option given.  This fixes the case the namespace
module has subpackages, but no submodules.
This commit is contained in:
Takeshi KOMIYA 2021-01-23 00:35:23 +09:00
parent a71028bf9e
commit 650f8ea237
3 changed files with 53 additions and 24 deletions

View File

@ -51,6 +51,7 @@ Features added
Bugs fixed Bugs fixed
---------- ----------
* #8727: apidoc: namespace module file is not generated if no submodules there
* #741: autodoc: inherited-members doesn't work for instance attributes on super * #741: autodoc: inherited-members doesn't work for instance attributes on super
class class
* #8592: autodoc: ``:meta public:`` does not effect to variables * #8592: autodoc: ``:meta public:`` does not effect to variables

View File

@ -24,7 +24,7 @@ from copy import copy
from fnmatch import fnmatch from fnmatch import fnmatch
from importlib.machinery import EXTENSION_SUFFIXES from importlib.machinery import EXTENSION_SUFFIXES
from os import path from os import path
from typing import Any, List, Tuple from typing import Any, Generator, List, Tuple
import sphinx.locale import sphinx.locale
from sphinx import __display_version__, package_dir from sphinx import __display_version__, package_dir
@ -264,14 +264,46 @@ def is_skipped_module(filename: str, opts: Any, excludes: List[str]) -> bool:
return False return False
def walk(rootpath: str, excludes: List[str], opts: Any
) -> Generator[Tuple[str, List[str], List[str]], None, None]:
"""Walk through the directory and list files and subdirectories up."""
followlinks = getattr(opts, 'followlinks', False)
includeprivate = getattr(opts, 'includeprivate', False)
for root, subs, files in os.walk(rootpath, followlinks=followlinks):
# document only Python module files (that aren't excluded)
files = sorted(f for f in files
if f.endswith(PY_SUFFIXES) and
not is_excluded(path.join(root, f), excludes))
# remove hidden ('.') and private ('_') directories, as well as
# excluded dirs
if includeprivate:
exclude_prefixes = ('.',) # type: Tuple[str, ...]
else:
exclude_prefixes = ('.', '_')
subs[:] = sorted(sub for sub in subs if not sub.startswith(exclude_prefixes) and
not is_excluded(path.join(root, sub), excludes))
yield root, subs, files
def has_child_module(rootpath: str, excludes: List[str], opts: Any) -> bool:
"""Check the given directory contains child modules at least one."""
for root, subs, files in walk(rootpath, excludes, opts):
if files:
return True
return False
def recurse_tree(rootpath: str, excludes: List[str], opts: Any, def recurse_tree(rootpath: str, excludes: List[str], opts: Any,
user_template_dir: str = None) -> List[str]: user_template_dir: str = None) -> List[str]:
""" """
Look for every file in the directory tree and create the corresponding Look for every file in the directory tree and create the corresponding
ReST files. ReST files.
""" """
followlinks = getattr(opts, 'followlinks', False)
includeprivate = getattr(opts, 'includeprivate', False)
implicit_namespaces = getattr(opts, 'implicit_namespaces', False) implicit_namespaces = getattr(opts, 'implicit_namespaces', False)
# check if the base directory is a package and get its name # check if the base directory is a package and get its name
@ -282,48 +314,36 @@ def recurse_tree(rootpath: str, excludes: List[str], opts: Any,
root_package = None root_package = None
toplevels = [] toplevels = []
for root, subs, files in os.walk(rootpath, followlinks=followlinks): for root, subs, files in walk(rootpath, excludes, opts):
# document only Python module files (that aren't excluded) is_pkg = is_packagedir(None, files)
py_files = sorted(f for f in files
if f.endswith(PY_SUFFIXES) and
not is_excluded(path.join(root, f), excludes))
is_pkg = is_packagedir(None, py_files)
is_namespace = not is_pkg and implicit_namespaces is_namespace = not is_pkg and implicit_namespaces
if is_pkg: if is_pkg:
for f in py_files[:]: for f in files[:]:
if is_initpy(f): if is_initpy(f):
py_files.remove(f) files.remove(f)
py_files.insert(0, f) files.insert(0, f)
elif root != rootpath: elif root != rootpath:
# only accept non-package at toplevel unless using implicit namespaces # only accept non-package at toplevel unless using implicit namespaces
if not implicit_namespaces: if not implicit_namespaces:
del subs[:] del subs[:]
continue continue
# remove hidden ('.') and private ('_') directories, as well as
# excluded dirs
if includeprivate:
exclude_prefixes = ('.',) # type: Tuple[str, ...]
else:
exclude_prefixes = ('.', '_')
subs[:] = sorted(sub for sub in subs if not sub.startswith(exclude_prefixes) and
not is_excluded(path.join(root, sub), excludes))
if is_pkg or is_namespace: if is_pkg or is_namespace:
# we are in a package with something to document # we are in a package with something to document
if subs or len(py_files) > 1 or not is_skipped_package(root, opts): if subs or len(files) > 1 or not is_skipped_package(root, opts):
subpackage = root[len(rootpath):].lstrip(path.sep).\ subpackage = root[len(rootpath):].lstrip(path.sep).\
replace(path.sep, '.') replace(path.sep, '.')
# if this is not a namespace or # if this is not a namespace or
# a namespace and there is something there to document # a namespace and there is something there to document
if not is_namespace or len(py_files) > 0: if not is_namespace or has_child_module(root, excludes, opts):
create_package_file(root, root_package, subpackage, create_package_file(root, root_package, subpackage,
py_files, opts, subs, is_namespace, excludes, files, opts, subs, is_namespace, excludes,
user_template_dir) user_template_dir)
toplevels.append(module_join(root_package, subpackage)) toplevels.append(module_join(root_package, subpackage))
else: else:
# if we are at the root level, we don't require it to be a package # if we are at the root level, we don't require it to be a package
assert root == rootpath and root_package is None assert root == rootpath and root_package is None
for py_file in py_files: for py_file in files:
if not is_skipped_module(path.join(rootpath, py_file), opts, excludes): if not is_skipped_module(path.join(rootpath, py_file), opts, excludes):
module = py_file.split('.')[0] module = py_file.split('.')[0]
create_module_file(root_package, module, opts, user_template_dir) create_module_file(root_package, module, opts, user_template_dir)

View File

@ -216,6 +216,8 @@ def test_trailing_underscore(make_app, apidoc):
def test_excludes(apidoc): def test_excludes(apidoc):
outdir = apidoc.outdir outdir = apidoc.outdir
assert (outdir / 'conf.py').isfile() assert (outdir / 'conf.py').isfile()
assert (outdir / 'a.rst').isfile()
assert (outdir / 'a.b.rst').isfile()
assert (outdir / 'a.b.c.rst').isfile() # generated because not empty assert (outdir / 'a.b.c.rst').isfile() # generated because not empty
assert not (outdir / 'a.b.e.rst').isfile() # skipped because of empty after excludes assert not (outdir / 'a.b.e.rst').isfile() # skipped because of empty after excludes
assert (outdir / 'a.b.x.rst').isfile() assert (outdir / 'a.b.x.rst').isfile()
@ -231,6 +233,8 @@ def test_excludes_subpackage_should_be_skipped(apidoc):
"""Subpackage exclusion should work.""" """Subpackage exclusion should work."""
outdir = apidoc.outdir outdir = apidoc.outdir
assert (outdir / 'conf.py').isfile() assert (outdir / 'conf.py').isfile()
assert (outdir / 'a.rst').isfile()
assert (outdir / 'a.b.rst').isfile()
assert (outdir / 'a.b.c.rst').isfile() # generated because not empty assert (outdir / 'a.b.c.rst').isfile() # generated because not empty
assert not (outdir / 'a.b.e.f.rst').isfile() # skipped because 'b/e' subpackage is skipped assert not (outdir / 'a.b.e.f.rst').isfile() # skipped because 'b/e' subpackage is skipped
@ -244,6 +248,8 @@ def test_excludes_module_should_be_skipped(apidoc):
"""Module exclusion should work.""" """Module exclusion should work."""
outdir = apidoc.outdir outdir = apidoc.outdir
assert (outdir / 'conf.py').isfile() assert (outdir / 'conf.py').isfile()
assert (outdir / 'a.rst').isfile()
assert (outdir / 'a.b.rst').isfile()
assert (outdir / 'a.b.c.rst').isfile() # generated because not empty assert (outdir / 'a.b.c.rst').isfile() # generated because not empty
assert not (outdir / 'a.b.e.f.rst').isfile() # skipped because of empty after excludes assert not (outdir / 'a.b.e.f.rst').isfile() # skipped because of empty after excludes
@ -257,6 +263,8 @@ def test_excludes_module_should_not_be_skipped(apidoc):
"""Module should be included if no excludes are used.""" """Module should be included if no excludes are used."""
outdir = apidoc.outdir outdir = apidoc.outdir
assert (outdir / 'conf.py').isfile() assert (outdir / 'conf.py').isfile()
assert (outdir / 'a.rst').isfile()
assert (outdir / 'a.b.rst').isfile()
assert (outdir / 'a.b.c.rst').isfile() # generated because not empty assert (outdir / 'a.b.c.rst').isfile() # generated because not empty
assert (outdir / 'a.b.e.f.rst').isfile() # skipped because of empty after excludes assert (outdir / 'a.b.e.f.rst').isfile() # skipped because of empty after excludes