Skip to content

Commit 02dd58f

Browse files
authored
🐛 fix(docstring): survive a re-entrant docstring handler (#758)
Building the docs died with `ExtensionError: Handler <function process_docstring ...> threw an exception (exception: 'Config' object has no attribute '_annotation_globals')`, reported in #750 against 3.5-3.6. `process_docstring` hangs three values off the config for the formatting code to read and deletes them in a `finally`. That works until documenting one object starts documenting another before the first one finishes: the inner call deletes the attributes, and the outer call then raises on its own teardown, which is the error the reporter saw. On their version an autodoc directive sitting in a docstring was enough, because the throwaway parse that places `:rtype:` ran it. The reproducer below builds clean since #624 neutralized those directives. A `typehints_formatter` that documents a nested object still re-enters the handler on `main`, and still aborts the build the same way. Rather than block re-entry, the teardown now puts back whatever it found: an inner call restores the outer call's values, and the outermost call clears them. Nesting turns into an ordinary save and restore, whatever the caller did to reach it. 🔁 ```python # conf.py def setup(app): import demo from sphinx_autodoc_typehints import process_docstring def formatter(annotation, config): if annotation is int: process_docstring(app, "function", "demo.helper", demo.helper, None, ["Help."]) return None app.config.typehints_formatter = formatter ```
1 parent 3f6d334 commit 02dd58f

2 files changed

Lines changed: 57 additions & 8 deletions

File tree

src/sphinx_autodoc_typehints/__init__.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import inspect
66
import re
77
import types
8+
from contextlib import contextmanager
89
from typing import TYPE_CHECKING, Any, TypeVar
910

1011
from docutils import nodes
@@ -40,11 +41,12 @@
4041
from .version import __version__
4142

4243
if TYPE_CHECKING:
43-
from collections.abc import Callable
44+
from collections.abc import Callable, Iterator
4445

4546
from docutils.nodes import Node
4647
from docutils.parsers.rst import states
4748
from sphinx.application import Sphinx
49+
from sphinx.config import Config
4850
from sphinx.environment import BuildEnvironment
4951
from sphinx.ext.autodoc import Options
5052

@@ -196,17 +198,36 @@ def process_docstring( # ruff:ignore[too-many-arguments, too-many-positional-ar
196198
for param, hint in type_hints.items():
197199
if id(hint) in eager_aliases:
198200
type_hints[param] = eager_aliases[id(hint)]
199-
app.config._annotation_globals = getattr(obj, "__globals__", {}) # ruff:ignore[private-member-access]
200-
app.config._typehints_env = env # ruff:ignore[private-member-access]
201-
app.config._typehints_module_prefix = module_prefix # ruff:ignore[private-member-access]
202-
try:
201+
with _annotation_state(
202+
app.config,
203+
_annotation_globals=getattr(obj, "__globals__", {}),
204+
_typehints_env=env,
205+
_typehints_module_prefix=module_prefix,
206+
):
203207
has_overloads = _inject_overload_signatures(app, what, name, obj, lines)
204208
_inject_types_to_docstring(type_hints, signature, original_obj, app, what, name, lines, has_overloads)
205209
_inject_ivar_types(ivar_annotations, app, lines)
210+
211+
212+
@contextmanager
213+
def _annotation_state(config: Config, **values: Any) -> Iterator[None]:
214+
"""
215+
Publish the state format_annotation reads off the config, restoring what was there.
216+
217+
Documenting an object can re-enter this handler for another one, and deleting the attributes
218+
on the way out of the inner call then broke the outer call's teardown (#750).
219+
"""
220+
previous = {name: getattr(config, name) for name in values if hasattr(config, name)}
221+
for name, value in values.items():
222+
setattr(config, name, value)
223+
try:
224+
yield
206225
finally:
207-
del app.config._annotation_globals # ruff:ignore[private-member-access]
208-
del app.config._typehints_env # ruff:ignore[private-member-access]
209-
del app.config._typehints_module_prefix # ruff:ignore[private-member-access]
226+
for name in values:
227+
if name in previous:
228+
setattr(config, name, previous[name])
229+
else:
230+
delattr(config, name)
210231

211232

212233
def _maybe_inject_descriptor_type(app: Sphinx, what: str, obj: Any, lines: list[str]) -> None:

tests/test_init.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,3 +430,31 @@ def test_setup_returns_version() -> None:
430430
assert result["version"] == __version__
431431
assert result["parallel_read_safe"] is True
432432
assert result["parallel_write_safe"] is True
433+
434+
435+
def test_process_docstring_reentrant_call_keeps_outer_state() -> None:
436+
"""A formatter documenting a second object must not break the outer teardown (#750)."""
437+
438+
def inner(flag: str) -> str: ...
439+
440+
def outer(flag: bool) -> bool: ...
441+
442+
app = make_docstring_app()
443+
444+
def formatter(annotation: Any, config: Config) -> None: # ruff:ignore[unused-function-argument]
445+
if annotation is bool:
446+
process_docstring(app, "function", "test.inner", inner, None, ["Inner.", "", ":return: it"])
447+
448+
app.config.typehints_formatter = formatter
449+
lines = ["Outer.", "", ":param flag: the flag", ":return: it"]
450+
process_docstring(app, "function", "test.outer", outer, None, lines)
451+
bool_type = ":sphinx_autodoc_typehints_type:`\\:py\\:class\\:\\`bool\\``"
452+
assert lines == [
453+
"Outer.",
454+
"",
455+
f":type flag: {bool_type}",
456+
":param flag: the flag",
457+
"",
458+
f":rtype: {bool_type}",
459+
":return: it",
460+
]

0 commit comments

Comments
 (0)