From 27c8f7b35f04c8a772da224e88a10e072dbe0519 Mon Sep 17 00:00:00 2001 From: okara chidera <45210306+okarachidera@users.noreply.github.com> Date: Thu, 23 Oct 2025 09:43:01 +0100 Subject: [PATCH] apidoc: allow configuring template directories --- CHANGES.rst | 3 + doc/usage/extensions/apidoc.rst | 12 ++++ sphinx/ext/apidoc/__init__.py | 5 ++ sphinx/ext/apidoc/_cli.py | 3 +- sphinx/ext/apidoc/_extension.py | 36 +++++++++++- sphinx/ext/apidoc/_shared.py | 4 +- sphinx/transforms/i18n.py | 2 +- .../_templates/custom/module.rst.jinja | 7 +++ .../_templates/default/module.rst.jinja | 7 +++ .../test-ext-apidoc-template-dir/conf.py | 15 +++++ .../test-ext-apidoc-template-dir/index.rst | 6 ++ .../src/pkg_custom/__init__.py | 1 + .../src/pkg_custom/module_custom.py | 5 ++ .../src/pkg_default/__init__.py | 1 + .../src/pkg_default/module_default.py | 5 ++ tests/test_extensions/test_ext_apidoc.py | 57 ++++++++++++++++++- 16 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 tests/roots/test-ext-apidoc-template-dir/_templates/custom/module.rst.jinja create mode 100644 tests/roots/test-ext-apidoc-template-dir/_templates/default/module.rst.jinja create mode 100644 tests/roots/test-ext-apidoc-template-dir/conf.py create mode 100644 tests/roots/test-ext-apidoc-template-dir/index.rst create mode 100644 tests/roots/test-ext-apidoc-template-dir/src/pkg_custom/__init__.py create mode 100644 tests/roots/test-ext-apidoc-template-dir/src/pkg_custom/module_custom.py create mode 100644 tests/roots/test-ext-apidoc-template-dir/src/pkg_default/__init__.py create mode 100644 tests/roots/test-ext-apidoc-template-dir/src/pkg_default/module_default.py diff --git a/CHANGES.rst b/CHANGES.rst index 792f6ce2201..1c422556d0c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -68,6 +68,9 @@ Features added Patch by Jean-François B. * #13508: Initial support for :pep:`695` type aliases. Patch by Martin Matouš, Jeremy Maitin-Shepard, and Adam Turner. +* apidoc: Allow configuring template directories via + :confval:`apidoc_template_dir` and the per-module ``template_dir`` option. + Patch by Chidera Okara. Bugs fixed ---------- diff --git a/doc/usage/extensions/apidoc.rst b/doc/usage/extensions/apidoc.rst index 4dc8ca941a2..f00c397dbb3 100644 --- a/doc/usage/extensions/apidoc.rst +++ b/doc/usage/extensions/apidoc.rst @@ -110,6 +110,11 @@ The apidoc extension uses the following configuration values: :code-py:`'automodule_options'` See :confval:`apidoc_automodule_options`. + :code-py:`'template_dir'` + A directory containing templates used when generating documentation files. + Paths are interpreted relative to the configuration directory when not + absolute. See :confval:`apidoc_template_dir`. + .. confval:: apidoc_exclude_patterns :type: :code-py:`Sequence[str]` :default: :code-py:`()` @@ -170,3 +175,10 @@ The apidoc extension uses the following configuration values: :default: :code-py:`{'members', 'show-inheritance', 'undoc-members'}` Options to pass to generated :rst:dir:`automodule` directives. + +.. confval:: apidoc_template_dir + :type: :code-py:`str` + :default: :code-py:`None` + + A directory containing templates that override the built-in apidoc templates. + Paths are interpreted relative to the configuration directory. diff --git a/sphinx/ext/apidoc/__init__.py b/sphinx/ext/apidoc/__init__.py index be52485771c..06c5bfbaf0a 100644 --- a/sphinx/ext/apidoc/__init__.py +++ b/sphinx/ext/apidoc/__init__.py @@ -11,6 +11,8 @@ from __future__ import annotations +from pathlib import Path +from types import NoneType from typing import TYPE_CHECKING import sphinx @@ -54,6 +56,9 @@ def setup(app: Sphinx) -> ExtensionMetadata: 'env', types=frozenset({frozenset, list, set, tuple}), ) + app.add_config_value( + 'apidoc_template_dir', None, 'env', types=frozenset({NoneType, str, Path}) + ) app.add_config_value('apidoc_modules', (), 'env', types=frozenset({list, tuple})) # Entry point to run apidoc diff --git a/sphinx/ext/apidoc/_cli.py b/sphinx/ext/apidoc/_cli.py index 618b29ce6bb..91d7bcf743d 100644 --- a/sphinx/ext/apidoc/_cli.py +++ b/sphinx/ext/apidoc/_cli.py @@ -353,4 +353,5 @@ def _full_quickstart(opts: ApidocOptions, /, *, modules: list[str]) -> None: d['extensions'].extend(ext.split(',')) if not opts.dry_run: - qs.generate(d, silent=True, overwrite=opts.force, templatedir=opts.template_dir) + templatedir = str(opts.template_dir) if opts.template_dir is not None else None + qs.generate(d, silent=True, overwrite=opts.force, templatedir=templatedir) diff --git a/sphinx/ext/apidoc/_extension.py b/sphinx/ext/apidoc/_extension.py index 7199fbba6dd..4d70e86e041 100644 --- a/sphinx/ext/apidoc/_extension.py +++ b/sphinx/ext/apidoc/_extension.py @@ -37,6 +37,7 @@ 'exclude_patterns', 'automodule_options', 'max_depth', + 'template_dir', }) @@ -169,7 +170,24 @@ def _parse_module_options( ) ] - # TODO template_dir + template_dir = _normalize_template_dir(defaults.template_dir, confdir=confdir) + if 'template_dir' in options: + if options['template_dir'] is None: + template_dir = None + elif isinstance(options['template_dir'], str): + template_dir = _normalize_template_dir( + options['template_dir'], confdir=confdir + ) + else: + LOGGER.warning( + __("apidoc_modules item %i '%s' must be a string"), + i, + 'template_dir', + type='apidoc', + ) + template_dir = _normalize_template_dir( + defaults.template_dir, confdir=confdir + ) max_depth = defaults.max_depth if 'max_depth' in options: @@ -227,6 +245,7 @@ def _parse_module_options( implicit_namespaces=bool_options['implicit_namespaces'], automodule_options=automodule_options, header=module_path.name, + template_dir=template_dir, ) @@ -261,3 +280,18 @@ def _check_collection_of_strings( ) return default return options[key] + + +def _normalize_template_dir( + value: str | Path | None, + *, + confdir: Path, +) -> Path | None: + """Return a template directory path resolved relative to *confdir*.""" + if value is None: + return None + + path = Path(value) + if not path.is_absolute(): + path = confdir / path + return path diff --git a/sphinx/ext/apidoc/_shared.py b/sphinx/ext/apidoc/_shared.py index 527f240f9df..8b0b18b2802 100644 --- a/sphinx/ext/apidoc/_shared.py +++ b/sphinx/ext/apidoc/_shared.py @@ -66,7 +66,7 @@ class ApidocOptions: version: str | None = None release: str | None = None extensions: Sequence[str] | None = None - template_dir: str | None = None + template_dir: str | Path | None = None @dataclasses.dataclass(frozen=True, kw_only=True, slots=True) @@ -82,6 +82,7 @@ class ApidocDefaults: no_headings: bool module_first: bool implicit_namespaces: bool + template_dir: str | Path | None @classmethod def from_config(cls, config: Config, /) -> Self: @@ -96,4 +97,5 @@ def from_config(cls, config: Config, /) -> Self: no_headings=config.apidoc_no_headings, module_first=config.apidoc_module_first, implicit_namespaces=config.apidoc_implicit_namespaces, + template_dir=config.apidoc_template_dir, ) diff --git a/sphinx/transforms/i18n.py b/sphinx/transforms/i18n.py index 570154185e9..a4d2d6ee056 100644 --- a/sphinx/transforms/i18n.py +++ b/sphinx/transforms/i18n.py @@ -415,7 +415,7 @@ def apply(self, **kwargs: Any) -> None: # There is no point in having noqa on literal blocks because # they cannot contain references. Recognizing it would just # completely prevent escaping the noqa. Outside of literal - # blocks, one can always write \#noqa. + # blocks, one can always write ``\#`` followed by ``noqa``. if not isinstance(node, LITERAL_TYPE_NODES): msgstr, _ = parse_noqa(msgstr) diff --git a/tests/roots/test-ext-apidoc-template-dir/_templates/custom/module.rst.jinja b/tests/roots/test-ext-apidoc-template-dir/_templates/custom/module.rst.jinja new file mode 100644 index 00000000000..098a2e35a39 --- /dev/null +++ b/tests/roots/test-ext-apidoc-template-dir/_templates/custom/module.rst.jinja @@ -0,0 +1,7 @@ +.. custom-template-marker + +{{ qualname }} documented with the custom template. + +.. automodule:: {{ qualname }} + :members: + diff --git a/tests/roots/test-ext-apidoc-template-dir/_templates/default/module.rst.jinja b/tests/roots/test-ext-apidoc-template-dir/_templates/default/module.rst.jinja new file mode 100644 index 00000000000..07cd30788c8 --- /dev/null +++ b/tests/roots/test-ext-apidoc-template-dir/_templates/default/module.rst.jinja @@ -0,0 +1,7 @@ +.. default-template-marker + +{{ qualname }} documented with the default template. + +.. automodule:: {{ qualname }} + :members: + diff --git a/tests/roots/test-ext-apidoc-template-dir/conf.py b/tests/roots/test-ext-apidoc-template-dir/conf.py new file mode 100644 index 00000000000..1d0b6a08a5f --- /dev/null +++ b/tests/roots/test-ext-apidoc-template-dir/conf.py @@ -0,0 +1,15 @@ +extensions = ['sphinx.ext.apidoc'] + +apidoc_separate_modules = True +apidoc_template_dir = '_templates/default' +apidoc_modules = [ + { + 'path': 'src/pkg_default', + 'destination': 'generated/default', + }, + { + 'path': 'src/pkg_custom', + 'destination': 'generated/custom', + 'template_dir': '_templates/custom', + }, +] diff --git a/tests/roots/test-ext-apidoc-template-dir/index.rst b/tests/roots/test-ext-apidoc-template-dir/index.rst new file mode 100644 index 00000000000..2677f1f5266 --- /dev/null +++ b/tests/roots/test-ext-apidoc-template-dir/index.rst @@ -0,0 +1,6 @@ +Test apidoc template directory +============================== + +This project exercises the per-module ``template_dir`` support added to the +``sphinx.ext.apidoc`` extension. + diff --git a/tests/roots/test-ext-apidoc-template-dir/src/pkg_custom/__init__.py b/tests/roots/test-ext-apidoc-template-dir/src/pkg_custom/__init__.py new file mode 100644 index 00000000000..9534d408473 --- /dev/null +++ b/tests/roots/test-ext-apidoc-template-dir/src/pkg_custom/__init__.py @@ -0,0 +1 @@ +"""Custom template package.""" diff --git a/tests/roots/test-ext-apidoc-template-dir/src/pkg_custom/module_custom.py b/tests/roots/test-ext-apidoc-template-dir/src/pkg_custom/module_custom.py new file mode 100644 index 00000000000..b501759de48 --- /dev/null +++ b/tests/roots/test-ext-apidoc-template-dir/src/pkg_custom/module_custom.py @@ -0,0 +1,5 @@ +"""Example module that should use the custom template.""" + + +def ping() -> str: + return 'custom template pong' diff --git a/tests/roots/test-ext-apidoc-template-dir/src/pkg_default/__init__.py b/tests/roots/test-ext-apidoc-template-dir/src/pkg_default/__init__.py new file mode 100644 index 00000000000..1b81e2b1db1 --- /dev/null +++ b/tests/roots/test-ext-apidoc-template-dir/src/pkg_default/__init__.py @@ -0,0 +1 @@ +"""Default template package.""" diff --git a/tests/roots/test-ext-apidoc-template-dir/src/pkg_default/module_default.py b/tests/roots/test-ext-apidoc-template-dir/src/pkg_default/module_default.py new file mode 100644 index 00000000000..01c596c5fcb --- /dev/null +++ b/tests/roots/test-ext-apidoc-template-dir/src/pkg_default/module_default.py @@ -0,0 +1,5 @@ +"""Example module that should use the default template.""" + + +def ping() -> str: + return 'default template pong' diff --git a/tests/test_extensions/test_ext_apidoc.py b/tests/test_extensions/test_ext_apidoc.py index 0052a4740bb..c35c236ad3c 100644 --- a/tests/test_extensions/test_ext_apidoc.py +++ b/tests/test_extensions/test_ext_apidoc.py @@ -3,16 +3,18 @@ from __future__ import annotations from collections import namedtuple +from pathlib import Path +from shutil import copytree +from types import SimpleNamespace from typing import TYPE_CHECKING import pytest import sphinx.ext.apidoc._generate from sphinx.ext.apidoc._cli import main as apidoc_main +from sphinx.ext.apidoc._extension import run_apidoc if TYPE_CHECKING: - from pathlib import Path - from sphinx.testing.util import SphinxTestApp _apidoc = namedtuple('_apidoc', 'coderoot,outdir') # NoQA: PYI024 @@ -96,6 +98,57 @@ def test_custom_templates(make_app, apidoc): assert 'The Jinja module template was found!' in txt +def test_extension_template_dir_option(rootdir, tmp_path): + srcdir = tmp_path / 'project' + copytree(rootdir / 'test-ext-apidoc-template-dir', srcdir) + config = SimpleNamespace( + apidoc_exclude_patterns=[], + apidoc_automodule_options=frozenset({ + 'members', + 'undoc-members', + 'show-inheritance', + }), + apidoc_max_depth=4, + apidoc_follow_links=False, + apidoc_separate_modules=True, + apidoc_include_private=False, + apidoc_no_headings=False, + apidoc_module_first=False, + apidoc_implicit_namespaces=False, + apidoc_template_dir='_templates/default', + apidoc_modules=[ + { + 'path': 'src/pkg_default', + 'destination': 'generated/default', + }, + { + 'path': 'src/pkg_custom', + 'destination': 'generated/custom', + 'template_dir': '_templates/custom', + }, + ], + ) + app = SimpleNamespace(config=config, srcdir=srcdir, confdir=srcdir) + run_apidoc(app) + + generated_default = ( + Path(srcdir) / 'generated' / 'default' / 'pkg_default.module_default.rst' + ) + generated_custom = ( + Path(srcdir) / 'generated' / 'custom' / 'pkg_custom.module_custom.rst' + ) + + assert generated_default.is_file() + assert generated_custom.is_file() + + default_text = generated_default.read_text(encoding='utf-8') + custom_text = generated_custom.read_text(encoding='utf-8') + + assert '.. default-template-marker' in default_text + assert '.. custom-template-marker' in custom_text + assert '.. custom-template-marker' not in default_text + + @pytest.mark.apidoc( coderoot='test-ext-apidoc-pep420/a', options=['--implicit-namespaces'],