Skip to content

Commit 3632417

Browse files
authored
sphinx ext: include autosummary templates and extensions from cookiecutter-scverse (#60)
1 parent c6ffdb1 commit 3632417

8 files changed

Lines changed: 290 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ and this project adheres to [Semantic Versioning][].
1010

1111
## [Unreleased]
1212

13-
## [0.1.0]
13+
### Added
14+
15+
- The sphinx extension ships templates for `sphinx.ext.autosummary` that were previously part of `cookiecutter-scverse`.
16+
17+
## [0.1.1]
1418

1519
### Fixed
1620

docs/sphinx_ext.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ For example, using the [deprecated decorator](#scverse_misc.deprecated) adds a d
99
To use the extension, add `"scverse_misc.sphinx_ext"` to your extensions array in `conf.py`.
1010
The extension requires `scverse_misc` to be installed with the `sphinx` extra.
1111

12+
The extension also ships and enables several templates for `sphinx.ext.autosummary`.
13+
These were previously part of the [scverse cookiecutter template](https://github.com/scverse/cookiecutter-scverse).
14+
This keeps the cookiecutter template lean and ensures that scverse packages have a consistent documentation design, even if some packages are not updating the cookiecutter template.
15+
If you have custom autosummary templates in your package, they will still be used.
16+
17+
Similarly, support for prose return sections in NumPy-style docstrings, previously part of cookiecutter-scverse, is now provided here.
18+
1219
# Examples
1320

1421
(example-deprecating-a-function)=

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ dependencies = [
2929
optional-dependencies.datasets = [ "anndata", "pooch", "pyyaml", "tqdm" ]
3030
optional-dependencies.settings = [ "pydantic-settings", "python-dotenv" ]
3131
optional-dependencies.spatialdata = [ "spatialdata" ]
32-
optional-dependencies.sphinx = [ "pydocstring-rs>=0.1.13", "sphinx>=9" ]
32+
optional-dependencies.sphinx = [ "jinja2", "pydocstring-rs>=0.1.13", "sphinx>=9" ]
3333
# https://docs.pypi.org/project_metadata/#project-urls
3434
urls.Documentation = "https://scverse-misc.readthedocs.io/"
3535
urls.Homepage = "https://github.com/scverse/scverse-misc"

src/scverse_misc/sphinx_ext/__init__.py

Lines changed: 98 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,38 @@
11
from __future__ import annotations
22

3+
import re
34
import sys
45
import textwrap
56
import warnings
67
from importlib.metadata import version
8+
from pathlib import Path
79
from textwrap import indent
810
from types import MethodType
9-
from typing import TYPE_CHECKING, cast
11+
from typing import TYPE_CHECKING, Any, Generic, Literal, cast, get_origin
1012

1113
if sys.version_info >= (3, 13):
1214
from warnings import deprecated as deprecated
1315
else:
1416
from typing_extensions import deprecated as deprecated
1517

16-
from pydocstring import Docstring, Parameter, Return, Section, SectionKind, Style, emit_google, emit_numpy, parse
18+
from jinja2.defaults import DEFAULT_FILTERS # type: ignore[attr-defined]
19+
from jinja2.utils import import_string
20+
from pydocstring import (
21+
Docstring,
22+
GoogleDocstring,
23+
NumPyDocstring,
24+
NumPySectionKind,
25+
Parameter,
26+
PlainDocstring,
27+
Return,
28+
Section,
29+
SectionKind,
30+
Style,
31+
emit_google,
32+
emit_numpy,
33+
parse,
34+
)
35+
from sphinx.ext.napoleon import NumpyDocstring # type: ignore[attr-defined]
1736

1837
from .._deprecated import Deprecation, deprecated_arg
1938
from .._extensions import _NSInfo
@@ -30,6 +49,8 @@
3049

3150

3251
if TYPE_CHECKING:
52+
from collections.abc import Generator, Iterable
53+
3354
from sphinx.application import Sphinx
3455
from sphinx.ext.autodoc import Options as AutodocOptions
3556
from sphinx.ext.autodoc import _AutodocObjType # type: ignore[attr-defined]
@@ -43,10 +64,58 @@ def setup(app: Sphinx) -> ExtensionMetadata: # noqa: D103
4364
app.setup_extension("sphinx.ext.autodoc")
4465
# To go first, we use a lower number than napoleon (which uses the default, 500)
4566
app.connect("autodoc-process-docstring", _process_docstring, priority=100)
67+
app.connect("autodoc-process-bases", _skip_private_bases)
68+
69+
DEFAULT_FILTERS["member_type"] = _member_type
70+
71+
app.config.templates_path = list(app.config.templates_path) + [str(Path(__file__).parent / "templates")]
72+
73+
NumpyDocstring._parse_returns_section = _parse_returns_section # type: ignore[assignment]
4674

4775
return {"version": version("scverse-misc"), "parallel_read_safe": True}
4876

4977

78+
def _process_return(lines: Iterable[str]) -> Generator[str, None, None]:
79+
for line in lines:
80+
if m := re.fullmatch(r"(?P<param>\w+)\s+:\s+(?P<type>[\w.]+)", line):
81+
yield f"-{m['param']} (:class:`~{m['type']}`)"
82+
else:
83+
yield line
84+
85+
86+
def _parse_returns_section(self: NumpyDocstring, section: str) -> list[str]:
87+
"""Add support for prose return sections in Numpy-style docstrings."""
88+
lines_raw = self._dedent(self._consume_to_next_section())
89+
if lines_raw[0] == ":":
90+
del lines_raw[0]
91+
lines = self._format_block(":returns: ", list(_process_return(lines_raw)))
92+
if lines and lines[-1]:
93+
lines.append("")
94+
return lines
95+
96+
97+
def _skip_private_bases(app: Sphinx, name: str, obj: type, _unused: Any, bases: list[type]) -> None: # noqa: ANN401
98+
bases[:] = [b for b in bases if b is not object and get_origin(b) is not Generic and not b.__name__.startswith("_")]
99+
100+
101+
def _member_type(obj_path: str) -> Literal["method", "property", "attribute"]:
102+
"""Determine object member type.
103+
104+
E.g.: `.. auto{{ fullname | member_type }}::`
105+
"""
106+
# https://jinja.palletsprojects.com/en/stable/api/#custom-filters
107+
cls_path, member_name = obj_path.rsplit(".", 1)
108+
cls = import_string(cls_path)
109+
member = getattr(cls, member_name, None)
110+
match member:
111+
case property():
112+
return "property"
113+
case _ if callable(member):
114+
return "method"
115+
case _:
116+
return "attribute"
117+
118+
50119
def _process_docstring(
51120
app: Sphinx, objtype: _AutodocObjType, name: str, obj: object, options: AutodocOptions, lines: list[str]
52121
) -> None:
@@ -62,13 +131,35 @@ def _process_docstring(
62131
if hasattr(obj, ATTR_DEPRECATED) and isinstance(msg := getattr(obj, ATTR_DEPRECATED, None), Deprecation):
63132
_process_deprecated_function(app, msg, lines)
64133
if (args := getattr(obj, ATTR_DEPRECATED_ARG, None)) is not None:
65-
_process_deprecated_args(args, lines)
134+
_process_deprecated_args(app, args, lines)
66135
case "data" if isinstance(obj, Settings):
67136
_process_settings_object(obj, name, lines)
68137

69138

70-
def _emit_docstring(app: Sphinx, model: Docstring, lines: list[str]) -> None:
139+
def _emit_docstring(
140+
app: Sphinx,
141+
model: Docstring,
142+
lines: list[str],
143+
parsed: NumPyDocstring | GoogleDocstring | PlainDocstring | None = None,
144+
) -> None:
71145
"""Emit a docstring compatible with the user settings (i.e. renderable with the chosen napoleon settings)."""
146+
# pydocstring doesn't support prose return sections and tries to parse them as NumPy style.
147+
# Copy the original return section verbatim.
148+
if parsed is not None and isinstance(parsed, NumPyDocstring):
149+
sections = model.sections
150+
return_section = None
151+
for i, section in enumerate(sections):
152+
if section.kind == SectionKind.RETURNS:
153+
return_section = i
154+
break
155+
156+
if return_section is not None:
157+
ast_section = parsed.sections[return_section]
158+
assert ast_section.section_kind == NumPySectionKind.RETURNS
159+
text = "\n".join(parsed.source[ast_section.range.start : ast_section.range.end].splitlines()[2:])
160+
sections[i] = Section(SectionKind.UNKNOWN, unknown_name=ast_section.header_name.text, body=text)
161+
model.sections = sections
162+
72163
if getattr(app.config, "napoleon_google_docstring", True):
73164
doc = emit_google(model)
74165
elif getattr(app.config, "napoleon_numpy_docstring", True):
@@ -91,10 +182,10 @@ def _process_deprecated_function(app: Sphinx, msg: Deprecation, lines: list[str]
91182
if model.extended_summary is not None:
92183
notice += f"\n\n{model.extended_summary}"
93184
model.extended_summary = notice
94-
_emit_docstring(app, model, lines)
185+
_emit_docstring(app, model, lines, parsed)
95186

96187

97-
def _process_deprecated_args(deprecations: list[deprecated_arg], lines: list[str]) -> None:
188+
def _process_deprecated_args(app: Sphinx, deprecations: list[deprecated_arg], lines: list[str]) -> None:
98189
parsed = parse("\n".join(lines))
99190
if parsed.style is Style.PLAIN:
100191
return
@@ -124,15 +215,8 @@ def _process_deprecated_args(deprecations: list[deprecated_arg], lines: list[str
124215
sections = model.sections
125216
sections[s] = Section(section.kind, parameters=params)
126217
model.sections = sections
127-
match parsed.style:
128-
case Style.GOOGLE:
129-
doc = emit_google(model)
130-
case Style.NUMPY:
131-
doc = emit_numpy(model)
132-
case _: # pragma: no cover
133-
raise AssertionError
134218

135-
lines[:] = doc.strip("\n").splitlines()
219+
_emit_docstring(app, model, lines, parsed)
136220

137221

138222
_settings_docstring_template = """Allows users to customize settings for the `{package}` package.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{{ fullname | escape | underline }}
2+
3+
.. currentmodule:: {{ module }}
4+
5+
.. add toctree option to make autodoc generate the pages
6+
7+
.. autoclass:: {{ fullname }}
8+
9+
{% set methods = methods | select("ne", "__init__") | list %}
10+
11+
{% block attributes %}
12+
{% for item in attributes %}
13+
{% if loop.first %}
14+
Attributes table
15+
~~~~~~~~~~~~~~~~
16+
17+
.. autosummary::
18+
{% endif %}
19+
~{{ name }}.{{ item }}
20+
{%- endfor %}
21+
{% endblock %}
22+
23+
{% block methods %}
24+
{% for item in methods %}
25+
{% if loop.first %}
26+
Methods table
27+
~~~~~~~~~~~~~
28+
29+
.. autosummary::
30+
{% endif %}
31+
~{{ name }}.{{ item }}
32+
{% endfor %}
33+
{% endblock %}
34+
35+
{% block attributes_documentation %}
36+
{% for item in attributes %}
37+
{% if loop.first %}
38+
Attributes
39+
~~~~~~~~~~
40+
41+
{% endif %}
42+
.. auto{{ [fullname, item] | join(".") | member_type }}:: {{ [fullname, item] | join(".") }}
43+
{%- endfor %}
44+
{% endblock %}
45+
46+
{% block methods_documentation %}
47+
{% for item in methods %}
48+
{% if loop.first %}
49+
Methods
50+
~~~~~~~
51+
{% endif %}
52+
.. automethod:: {{ [fullname, item] | join(".") }}
53+
{%- endfor %}
54+
{% endblock %}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
{{ fullname | escape | underline}}
2+
3+
.. automodule:: {{ fullname }}
4+
5+
{% block attributes %}
6+
{% if attributes %}
7+
.. rubric:: {{ _('Module Attributes') }}
8+
9+
.. autosummary::
10+
{% for item in attributes %}
11+
{{ item }}
12+
{%- endfor %}
13+
{% endif %}
14+
{% endblock %}
15+
16+
{% block functions %}
17+
{% if functions %}
18+
.. rubric:: {{ _('Functions') }}
19+
20+
.. autosummary::
21+
:toctree:
22+
{% for item in functions %}
23+
{{ item }}
24+
{%- endfor %}
25+
{% endif %}
26+
{% endblock %}
27+
28+
{% block classes %}
29+
{% if classes %}
30+
.. rubric:: {{ _('Classes') }}
31+
32+
.. autosummary::
33+
:toctree:
34+
{% for item in classes %}
35+
{{ item }}
36+
{%- endfor %}
37+
{% endif %}
38+
{% endblock %}
39+
40+
{% block exceptions %}
41+
{% if exceptions %}
42+
.. rubric:: {{ _('Exceptions') }}
43+
44+
.. autosummary::
45+
:toctree:
46+
{% for item in exceptions %}
47+
{{ item }}
48+
{%- endfor %}
49+
{% endif %}
50+
{% endblock %}
51+
52+
{% block modules %}
53+
{% if modules %}
54+
.. rubric:: Modules
55+
56+
.. autosummary::
57+
:toctree:
58+
{% for item in modules %}
59+
{{ item }}
60+
{%- endfor %}
61+
{% endif %}
62+
{% endblock %}

tests/deprecation_decorator/test_sphinx.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,15 @@ def docstring(request: pytest.FixtureRequest, docstring_style: Literal["google",
4040
baz
4141
keyword_only_default
4242
foobar
43+
44+
Returns
45+
-------
46+
This is a prose returns section.
47+
48+
:attr:`~module.ClassName.attr1`
49+
First attribute
50+
:attr:`~module.ClassName.attr2`
51+
Second attribute
4352
"""
4453
case "long_googlestyle":
4554
if docstring_style == "numpy":
@@ -67,10 +76,19 @@ def test_deprecation_decorator(
6776
offset = 0 if docstring is None else 2
6877

6978
if docstring is not None:
70-
lines_orig = docstring.expandtabs().splitlines()
79+
lines_orig = inspect.cleandoc(docstring).expandtabs().splitlines()
7180
assert lines[0] == lines_orig[0]
7281
assert len(lines[1].strip()) == 0, "expected empty line following summary"
7382

83+
try:
84+
orig_returns_offset = lines_orig.index("Returns")
85+
except ValueError:
86+
pass
87+
else:
88+
returns_offset = lines.index("Returns")
89+
for roffset in range(max(len(lines_orig) - orig_returns_offset, len(lines) - returns_offset)):
90+
assert lines[returns_offset + roffset] == lines_orig[orig_returns_offset + roffset]
91+
7492
assert lines[offset].startswith(".. version-deprecated")
7593
if msg is None:
7694
assert len(lines) == offset + 1 or not lines[offset + 1].startswith(" ")
@@ -85,7 +103,7 @@ def test_deprecation_decorator(
85103
("positional_only_no_default", "positional_only_default", "positional_or_keyword_default", "keyword_only_default"),
86104
)
87105
def test_deprecated_arg_decorator(
88-
parser: type[GoogleDocstring | NumpyDocstring], func: Callable[..., int], msg: str | None, arg: str
106+
app: Sphinx, parser: type[GoogleDocstring | NumpyDocstring], func: Callable[..., int], msg: str | None, arg: str
89107
) -> None:
90108
deprecated_func = deprecated_arg(arg, Deprecation("2.718", msg or ""))(func)
91109
with pytest.warns(FutureWarning, match=f"{arg} is deprecated"):
@@ -100,7 +118,7 @@ def test_deprecated_arg_decorator(
100118
return
101119

102120
lines = (inspect.getdoc(deprecated_func) or "").splitlines()
103-
sphinx_ext._process_deprecated_args(getattr(deprecated_func, ATTR_DEPRECATED_ARG), lines)
121+
sphinx_ext._process_deprecated_args(app, getattr(deprecated_func, ATTR_DEPRECATED_ARG), lines)
104122
lines = parser(lines).lines()
105123

106124
prefix = f":param {arg}:"

0 commit comments

Comments
 (0)