"""Sphinx extension for auto-generating API documentation."""
from __future__ import annotations
import fnmatch
import re
from pathlib import Path
from typing import TYPE_CHECKING
from sphinx._cli.util.colour import bold
from sphinx.ext.apidoc._generate import create_modules_toc_file, recurse_tree
from sphinx.ext.apidoc._shared import (
LOGGER,
ApidocDefaults,
ApidocOptions,
_remove_old_files,
)
from sphinx.locale import __
if TYPE_CHECKING:
from collections.abc import Collection, Sequence
from typing import Any
from sphinx.application import Sphinx
_BOOL_KEYS = frozenset({
'follow_links',
'separate_modules',
'include_private',
'no_headings',
'module_first',
'implicit_namespaces',
})
_ALLOWED_KEYS = _BOOL_KEYS | frozenset({
'path',
'destination',
'exclude_patterns',
'automodule_options',
'max_depth',
})
def run_apidoc(app: Sphinx) -> None:
"""Run the apidoc extension."""
defaults = ApidocDefaults.from_config(app.config)
apidoc_modules: Sequence[dict[str, Any]] = app.config.apidoc_modules
srcdir: Path = app.srcdir
confdir: Path = app.confdir
LOGGER.info(bold(__('Running apidoc')))
module_options: dict[str, Any]
for i, module_options in enumerate(apidoc_modules):
_run_apidoc_module(
i,
options=module_options,
defaults=defaults,
srcdir=srcdir,
confdir=confdir,
)
def _run_apidoc_module(
i: int,
*,
options: dict[str, Any],
defaults: ApidocDefaults,
srcdir: Path,
confdir: Path,
) -> None:
"""Run apidoc for a single module."""
args = _parse_module_options(
i, options=options, defaults=defaults, srcdir=srcdir, confdir=confdir
)
if args is None:
return
exclude_patterns_compiled: list[re.Pattern[str]] = [
re.compile(fnmatch.translate(exclude)) for exclude in args.exclude_pattern
]
written_files, modules = recurse_tree(
args.module_path, exclude_patterns_compiled, args, args.template_dir
)
if args.toc_file:
written_files.append(
create_modules_toc_file(modules, args, args.toc_file, args.template_dir)
)
if args.remove_old:
_remove_old_files(written_files, args.dest_dir, args.suffix)
def _parse_module_options(
i: int,
*,
options: dict[str, Any],
defaults: ApidocDefaults,
srcdir: Path,
confdir: Path,
) -> ApidocOptions | None:
if not isinstance(options, dict):
LOGGER.warning(__('apidoc_modules item %i must be a dict'), i, type='apidoc')
return None
# module path should be absolute or relative to the conf directory
try:
path = Path(options['path'])
except KeyError:
LOGGER.warning(
__("apidoc_modules item %i must have a 'path' key"), i, type='apidoc'
)
return None
except TypeError:
LOGGER.warning(
__("apidoc_modules item %i 'path' must be a string"), i, type='apidoc'
)
return None
module_path = confdir / path
if not module_path.is_dir():
LOGGER.warning(
__("apidoc_modules item %i 'path' is not an existing folder: %s"),
i,
module_path,
type='apidoc',
)
return None
# destination path should be relative to the source directory
try:
destination = Path(options['destination'])
except KeyError:
LOGGER.warning(
__("apidoc_modules item %i must have a 'destination' key"),
i,
type='apidoc',
)
return None
except TypeError:
LOGGER.warning(
__("apidoc_modules item %i 'destination' must be a string"),
i,
type='apidoc',
)
return None
if destination.is_absolute():
LOGGER.warning(
__("apidoc_modules item %i 'destination' should be a relative path"),
i,
type='apidoc',
)
return None
dest_path = srcdir / destination
try:
dest_path.mkdir(parents=True, exist_ok=True)
except OSError as exc:
LOGGER.warning(
__('apidoc_modules item %i cannot create destination directory: %s'),
i,
exc.strerror,
type='apidoc',
)
return None
# exclude patterns should be absolute or relative to the conf directory
exclude_patterns: list[str] = [
str(confdir / pattern)
for pattern in _check_collection_of_strings(
i, options, key='exclude_patterns', default=defaults.exclude_patterns
)
]
# TODO template_dir
max_depth = defaults.max_depth
if 'max_depth' in options:
if not isinstance(options['max_depth'], int):
LOGGER.warning(
__("apidoc_modules item %i '%s' must be an int"),
i,
'max_depth',
type='apidoc',
)
else:
max_depth = options['max_depth']
bool_options: dict[str, bool] = {}
for key in sorted(_BOOL_KEYS):
if key not in options:
bool_options[key] = getattr(defaults, key)
elif not isinstance(options[key], bool):
LOGGER.warning(
__("apidoc_modules item %i '%s' must be a boolean"),
i,
key,
type='apidoc',
)
bool_options[key] = getattr(defaults, key)
else:
bool_options[key] = options[key]
# TODO per-module automodule_options
automodule_options = frozenset(
_check_collection_of_strings(
i, options, key='automodule_options', default=defaults.automodule_options
)
)
if diff := options.keys() - _ALLOWED_KEYS:
LOGGER.warning(
__('apidoc_modules item %i has unexpected keys: %s'),
i,
', '.join(sorted(diff)),
type='apidoc',
)
return ApidocOptions(
dest_dir=dest_path,
module_path=module_path,
exclude_pattern=exclude_patterns,
max_depth=max_depth,
quiet=True,
follow_links=bool_options['follow_links'],
separate_modules=bool_options['separate_modules'],
include_private=bool_options['include_private'],
no_headings=bool_options['no_headings'],
module_first=bool_options['module_first'],
implicit_namespaces=bool_options['implicit_namespaces'],
automodule_options=automodule_options,
header=module_path.name,
)
def _check_collection_of_strings(
index: int,
options: dict[str, Any],
*,
key: str,
default: Collection[str],
) -> Collection[str]:
"""Check that a key's value is a collection of strings in the options.
:returns: The value of the key, or None if invalid.
"""
if key not in options:
return default
if not isinstance(options[key], (list, tuple, set, frozenset)):
LOGGER.warning(
__("apidoc_modules item %i '%s' must be a sequence"),
index,
key,
type='apidoc',
)
return default
for item in options[key]:
if not isinstance(item, str):
LOGGER.warning(
__("apidoc_modules item %i '%s' must contain strings"),
index,
key,
type='apidoc',
)
return default
return options[key]