Skip to content

Commit f6ca3fd

Browse files
authored
🐛 fix(parser): stop shadowing intersphinx roles in the type role (#761)
1 parent 02dd58f commit f6ca3fd

8 files changed

Lines changed: 60 additions & 10 deletions

File tree

src/sphinx_autodoc_typehints/_formats/_sphinx.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from docutils.parsers.rst import Directive, directives
1616
from docutils.utils import new_document
1717
from sphinx.parsers import RSTParser
18+
from sphinx.util import logging
1819

1920
from sphinx_autodoc_typehints._parser import _RstSnippetParser
2021

@@ -112,7 +113,10 @@ def _safe_directive_lookup(
112113
# dispatcher layered on top of it, losing the external+ roles (#753)
113114
directives.directive = _safe_directive_lookup # type: ignore[assignment]
114115
try:
115-
_RstSnippetParser().parse(inputstr, doc)
116+
# Whatever this parse has to say about the docstring, the real parse says again with the
117+
# right line number, so its warnings are only duplicates
118+
with logging.suppress_logging():
119+
_RstSnippetParser().parse(inputstr, doc)
116120
finally:
117121
directives.directive = original_lookup
118122
return doc

src/sphinx_autodoc_typehints/_parser.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
from docutils.utils import new_document
88
from sphinx.parsers import RSTParser
9-
from sphinx.util.docutils import sphinx_domains
109

1110
if TYPE_CHECKING:
1211
import optparse
@@ -23,9 +22,9 @@ def decorate(_content: StringList) -> None: # ty: ignore[invalid-method-overrid
2322

2423

2524
def parse(inputstr: str, settings: Values | optparse.Values) -> nodes.document:
26-
"""Parse inputstr and return a docutils document."""
25+
"""Parse inputstr and return a docutils document. Callers must already be inside ``sphinx_domains``."""
2726
doc = new_document("", settings=settings) # ty: ignore[invalid-argument-type]
28-
with sphinx_domains(settings.env):
29-
parser = _RstSnippetParser()
30-
parser.parse(inputstr, doc)
27+
# Entering sphinx_domains again shadows the intersphinx dispatcher layered on top of it,
28+
# losing the external+ roles the read phase resolves (#753)
29+
_RstSnippetParser().parse(inputstr, doc)
3130
return doc

tests/roots/test-intersphinx-external-role/conf.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@
33
import pathlib
44
import sys
55
import zlib
6+
from typing import TYPE_CHECKING, Any
67

78
sys.path.insert(0, str(pathlib.Path(__file__).parent))
89

10+
if TYPE_CHECKING:
11+
from sphinx.config import Config
12+
913
master_doc = "index"
1014

1115
extensions = [
@@ -23,3 +27,8 @@
2327
b"# The remainder of this file is compressed using zlib.\n" + zlib.compress(b"index std:doc -1 index.html Demo\n")
2428
)
2529
intersphinx_mapping = {"demo": ("https://example.org/demo/", str(_INVENTORY))}
30+
31+
32+
def typehints_formatter(annotation: Any, config: Config) -> str | None: # ruff:ignore[unused-function-argument]
33+
"""Render one annotation as an intersphinx role, which the type role parses on its own."""
34+
return ":external+demo:doc:`the demo docs <index>`" if annotation is bool else None
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
from __future__ import annotations
22

33

4-
def probe(x: int) -> int:
4+
def probe(x: int, flag: bool) -> int:
55
"""
66
Summarize.
77
88
:param x: see :external+demo:doc:`the demo docs <index>`.
9+
:param flag: a flag whose type renders as a role.
910
"""
10-
return x
11+
return x if flag else -x
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from __future__ import annotations
2+
3+
import pathlib
4+
import sys
5+
6+
sys.path.insert(0, str(pathlib.Path(__file__).parent))
7+
8+
master_doc = "index"
9+
10+
extensions = [
11+
"sphinx.ext.autodoc",
12+
"sphinx.ext.intersphinx",
13+
"sphinx_autodoc_typehints",
14+
]
15+
intersphinx_mapping: dict[str, tuple[str, str]] = {}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from __future__ import annotations
2+
3+
4+
def probe(x: int) -> int:
5+
"""
6+
Summarize.
7+
8+
:param x: see :external+nope:doc:`the missing docs <index>`.
9+
"""
10+
return x
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Demo
2+
====
3+
4+
.. autofunction:: demo_missing.probe

tests/test_safe_parse.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,15 @@ def run(self) -> list:
7474
def test_intersphinx_role_resolves_during_snippet_parse(
7575
app: SphinxTestApp, status: StringIO, warning: StringIO
7676
) -> None:
77-
"""The snippet parse must not shadow the intersphinx role dispatcher (#753)."""
77+
"""Neither the snippet parse nor the type role may shadow the intersphinx dispatcher (#753)."""
7878
app.build()
7979
assert "build succeeded" in status.getvalue()
80-
assert "unknown role name" not in warning.getvalue()
80+
assert not warning.getvalue()
81+
82+
83+
@pytest.mark.sphinx("text", testroot="intersphinx-missing-inventory")
84+
def test_snippet_parse_stays_quiet(app: SphinxTestApp, status: StringIO, warning: StringIO) -> None:
85+
"""A reference the role cannot resolve is reported by the real parse alone (#753)."""
86+
app.build()
87+
assert "build succeeded" in status.getvalue()
88+
assert warning.getvalue().count("inventory for external cross-reference not found") == 1

0 commit comments

Comments
 (0)