Skip to content

Commit 8ea7336

Browse files
authored
More robust s-a-t patch (#123)
1 parent a0b27d6 commit 8ea7336

File tree

9 files changed

+131
-47
lines changed

9 files changed

+131
-47
lines changed

.pre-commit-config.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ repos:
44
hooks:
55
- id: trailing-whitespace
66
- repo: https://github.com/astral-sh/ruff-pre-commit
7-
rev: v0.1.11
7+
rev: v0.1.12
88
hooks:
99
- id: ruff
1010
args: [--fix, --exit-non-zero-on-fix]

.vscode/launch.json

+11-1
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,23 @@
55
"version": "0.2.0",
66
"configurations": [
77
{
8-
"name": "Python Debugger: Current File",
8+
"name": "Debug current File",
99
"type": "debugpy",
1010
"request": "launch",
1111
"program": "${file}",
1212
"pythonArgs": ["-Xfrozen_modules=off"],
1313
"console": "internalConsole",
1414
"justMyCode": false,
1515
},
16+
{
17+
"name": "Build Documentation",
18+
"type": "debugpy",
19+
"request": "launch",
20+
"module": "sphinx",
21+
"args": ["-M", "html", ".", "_build"],
22+
"cwd": "${workspaceFolder}/docs",
23+
"console": "internalConsole",
24+
"justMyCode": false,
25+
},
1626
],
1727
}

src/scanpydoc/elegant_typehints/__init__.py

+13-7
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,14 @@ def x() -> Tuple[int, float]:
5353
from dataclasses import dataclass
5454

5555
from sphinx.ext.autodoc import ClassDocumenter
56-
from sphinx.ext.napoleon import NumpyDocstring # type: ignore[attr-defined]
5756

5857
from scanpydoc import metadata, _setup_sig
5958

60-
from .example import example_func_prose, example_func_tuple
59+
from .example import (
60+
example_func_prose,
61+
example_func_tuple,
62+
example_func_anonymous_tuple,
63+
)
6164

6265

6366
if TYPE_CHECKING:
@@ -68,7 +71,12 @@ def x() -> Tuple[int, float]:
6871
from sphinx.application import Sphinx
6972

7073

71-
__all__ = ["example_func_prose", "example_func_tuple", "setup"]
74+
__all__ = [
75+
"example_func_prose",
76+
"example_func_tuple",
77+
"example_func_anonymous_tuple",
78+
"setup",
79+
]
7280

7381

7482
HERE = Path(__file__).parent.resolve()
@@ -124,10 +132,8 @@ def setup(app: Sphinx) -> dict[str, Any]:
124132
ClassDocumenter.add_directive_header,
125133
)
126134

127-
from ._return_tuple import process_docstring # , process_signature
128-
from ._return_patch_numpydoc import _parse_returns_section
135+
from . import _return_tuple
129136

130-
NumpyDocstring._parse_returns_section = _parse_returns_section # type: ignore[method-assign,assignment] # noqa: SLF001
131-
app.connect("autodoc-process-docstring", process_docstring)
137+
_return_tuple.setup(app)
132138

133139
return metadata

src/scanpydoc/elegant_typehints/_return_patch_numpydoc.py

-21
This file was deleted.

src/scanpydoc/elegant_typehints/_return_tuple.py

+43-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import Tuple as t_Tuple # noqa: UP035
88
from logging import getLogger
99

10+
from sphinx.ext.napoleon import NumpyDocstring # type: ignore[attr-defined]
1011
from sphinx_autodoc_typehints import format_annotation
1112

1213

@@ -26,6 +27,8 @@
2627
UNION_TYPES = {Union}
2728

2829

30+
__all__ = ["process_docstring", "_parse_returns_section", "setup"]
31+
2932
logger = getLogger(__name__)
3033
re_ret = re.compile("^:returns?: ")
3134

@@ -76,7 +79,11 @@ def process_docstring( # noqa: PLR0913
7679
if len(idxs_ret_names) == len(ret_types):
7780
for l, rt in zip(idxs_ret_names, ret_types):
7881
typ = format_annotation(rt, app.config)
79-
lines[l : l + 1] = [f"{lines[l]} : {typ}"]
82+
if (line := lines[l]).lstrip() in {":returns: :", ":return: :", ":"}:
83+
transformed = f"{line[:-1]}{typ}"
84+
else:
85+
transformed = f"{line} : {typ}"
86+
lines[l : l + 1] = [transformed]
8087

8188

8289
def _get_idxs_ret_names(lines: Sequence[str]) -> list[int]:
@@ -100,6 +107,40 @@ def _get_idxs_ret_names(lines: Sequence[str]) -> list[int]:
100107
# Meat
101108
idxs_ret_names = []
102109
for l, line in enumerate([l[i_prefix:] for l in lines[l_start : l_end + 1]]):
103-
if line.isidentifier() and lines[l + l_start + 1].startswith(" "):
110+
if (line == ":" or line.isidentifier()) and (
111+
lines[l + l_start + 1].startswith(" ")
112+
):
104113
idxs_ret_names.append(l + l_start)
105114
return idxs_ret_names
115+
116+
117+
def _parse_returns_section(self: NumpyDocstring, section: str) -> list[str]: # noqa: ARG001
118+
"""Parse return section as prose instead of tuple by default."""
119+
lines_raw = list(self._dedent(self._consume_to_next_section()))
120+
lines = self._format_block(":returns: ", lines_raw)
121+
if lines and lines[-1]:
122+
lines.append("")
123+
return lines
124+
125+
126+
def _delete_sphinx_autodoc_typehints_docstring_processor(app: Sphinx) -> None:
127+
for listener in app.events.listeners["autodoc-process-docstring"].copy():
128+
handler_name = getattr(listener.handler, "__name__", None)
129+
# https://github.com/tox-dev/sphinx-autodoc-typehints/blob/a5c091f725da8374347802d54c16c3d38833d41c/src/sphinx_autodoc_typehints/patches.py#L69
130+
if handler_name == "napoleon_numpy_docstring_return_type_processor":
131+
app.disconnect(listener.id)
132+
133+
134+
def setup(app: Sphinx) -> None:
135+
"""Patches the Sphinx app and :mod:`sphinx.ext.napoleon` in some ways.
136+
137+
1. Replaces the return section parser of napoleon’s NumpyDocstring
138+
with one that just adds a prose section.
139+
2. Removes sphinx-autodoc-typehints’s docstring processor that expects
140+
NumpyDocstring’s old behavior.
141+
2. Adds our own docstring processor that adds tuple return types
142+
If the docstring contains a definition list of appropriate length.
143+
"""
144+
NumpyDocstring._parse_returns_section = _parse_returns_section # type: ignore[method-assign,assignment] # noqa: SLF001
145+
_delete_sphinx_autodoc_typehints_docstring_processor(app)
146+
app.connect("autodoc-process-docstring", process_docstring, 1000)

src/scanpydoc/elegant_typehints/example.py

+13
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,16 @@ def example_func_tuple() -> tuple[int, str]: # pragma: no cover
3131
An example str
3232
"""
3333
return (1, "foo")
34+
35+
36+
def example_func_anonymous_tuple() -> tuple[int, str]: # pragma: no cover
37+
"""Example function with anonymous return tuple.
38+
39+
Returns
40+
-------
41+
:
42+
An example int
43+
:
44+
An example str
45+
"""
46+
return (1, "foo")

src/scanpydoc/rtd_github_links/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ def _get_annotations(obj: _SourceObjectType) -> dict[str, Any]:
122122
from get_annotations import get_annotations
123123

124124
try:
125-
return get_annotations(obj) # type: ignore[arg-type]
125+
return get_annotations(obj) # type: ignore[no-any-return,arg-type,unused-ignore]
126126
except TypeError: # pragma: no cover
127127
return {}
128128

tests/test_base.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@ def test_all_get_installed(
2929
scanpydoc.__path__, f"{scanpydoc.__name__}."
3030
):
3131
mod = import_module(mod_name)
32-
if mod_name in DEPRECATED or not hasattr(mod, "setup"):
32+
if (
33+
mod_name in DEPRECATED
34+
or any(m.startswith("_") for m in mod_name.split("."))
35+
or not hasattr(mod, "setup")
36+
):
3337
continue
3438
setups_seen.add(mod_name)
3539
monkeypatch.setattr(mod, "setup", partial(setups_called.__setitem__, mod_name))

tests/test_elegant_typehints.py

+44-13
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,13 @@
1717
get_origin,
1818
)
1919
from pathlib import Path
20+
from operator import attrgetter
2021
from collections.abc import Mapping, Callable
2122

2223
import pytest
23-
import sphinx_autodoc_typehints as sat
24-
from sphinx.ext import napoleon
2524
from sphinx.errors import ExtensionError
26-
from sphinx_autodoc_typehints.patches import (
27-
napoleon_numpy_docstring_return_type_processor,
28-
)
2925

3026
from scanpydoc.elegant_typehints._formatting import typehints_formatter
31-
from scanpydoc.elegant_typehints._return_tuple import process_docstring
3227

3328

3429
if TYPE_CHECKING:
@@ -81,6 +76,16 @@ def app(make_app_setup: Callable[..., Sphinx]) -> Sphinx:
8176

8277
@pytest.fixture()
8378
def process_doc(app: Sphinx) -> ProcessDoc:
79+
listeners = sorted(
80+
(l for l in app.events.listeners["autodoc-process-docstring"]),
81+
key=attrgetter("priority"),
82+
)
83+
assert [f"{l.handler.__module__}.{l.handler.__qualname__}" for l in listeners] == [
84+
"sphinx.ext.napoleon._process_docstring",
85+
"sphinx_autodoc_typehints.process_docstring",
86+
"scanpydoc.elegant_typehints._return_tuple.process_docstring",
87+
]
88+
8489
def process(fn: Callable[..., Any], *, run_napoleon: bool = False) -> list[str]:
8590
lines = (inspect.getdoc(fn) or "").split("\n")
8691
if isinstance(fn, property):
@@ -89,13 +94,13 @@ def process(fn: Callable[..., Any], *, run_napoleon: bool = False) -> list[str]:
8994
name = fn.__name__
9095
else:
9196
name = "???"
92-
napoleon_numpy_docstring_return_type_processor(
93-
app, "function", name, fn, None, lines
94-
)
95-
if run_napoleon:
96-
napoleon._process_docstring(app, "function", name, fn, None, lines) # noqa: SLF001
97-
sat.process_docstring(app, "function", name, fn, None, lines)
98-
process_docstring(app, "function", name, fn, None, lines)
97+
for listener in listeners:
98+
if (
99+
not run_napoleon
100+
and listener.handler.__module__ == "sphinx.ext.napoleon"
101+
):
102+
continue
103+
listener.handler(app, "function", name, fn, None, lines)
99104
return lines
100105

101106
return process
@@ -402,6 +407,32 @@ def fn_test() -> None: # pragma: no cover
402407
]
403408

404409

410+
def test_return_tuple_anonymous(process_doc: ProcessDoc) -> None:
411+
def fn_test() -> tuple[int, str]: # pragma: no cover
412+
"""
413+
Returns
414+
-------
415+
:
416+
An int!
417+
:
418+
A str!
419+
""" # noqa: D401, D205
420+
return (1, "foo")
421+
422+
lines = [
423+
l
424+
for l in process_doc(fn_test, run_napoleon=True)
425+
if l
426+
if not re.match(r"^:(rtype|param)( \w+)?:", l)
427+
]
428+
assert lines == [
429+
":returns: :py:class:`int`",
430+
" An int!",
431+
" :py:class:`str`",
432+
" A str!",
433+
]
434+
435+
405436
def test_return_nodoc(process_doc: ProcessDoc) -> None:
406437
def fn() -> tuple[int, str]: # pragma: no cover
407438
"""No return section."""

0 commit comments

Comments
 (0)