Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/sphinx_autodoc_typehints/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,13 @@
get_annotation_args,
get_annotation_class_name,
get_annotation_module,
unescape,
)
from ._deferred import ALIAS_CHOICE_ROLE, DeferAliasChoice, alias_choice_role, merge_alias_choices
from ._formats import detect_format
from ._formats._numpydoc import _convert_numpydoc_to_sphinx_fields # ruff:ignore[unused-import]
from ._formats._sphinx import _has_yields_section, _is_generator_type, _strip_inline_param_type
from ._intersphinx import build_type_mapping
from ._parser import parse
from ._parser import parse, unescape
from ._resolver import (
backfill_attrs_annotations,
backfill_type_hints,
Expand Down Expand Up @@ -579,6 +579,9 @@ def setup(app: Sphinx) -> dict[str, bool | str]:
app.add_config_value("typehints_use_signature_return", False, "env") # ruff:ignore[boolean-positional-value-in-call]
app.add_config_value("typehints_fixup_module_name", None, "env")
app.add_role("sphinx_autodoc_typehints_type", sphinx_autodoc_typehints_type_role)
app.add_role(ALIAS_CHOICE_ROLE, alias_choice_role)
app.add_post_transform(DeferAliasChoice)
app.connect("env-merge-info", merge_alias_choices)
app.connect("env-before-read-docs", validate_config)
app.connect("autodoc-process-signature", process_signature)
app.connect("autodoc-process-docstring", process_docstring)
Expand Down
40 changes: 18 additions & 22 deletions src/sphinx_autodoc_typehints/_annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@

import enum
import inspect
import re
import sys
import types
from typing import TYPE_CHECKING, Any, AnyStr, ForwardRef, NewType, TypeVar, Union, get_args, get_origin
Expand All @@ -27,6 +26,8 @@

from typing import TypeAliasType

from ._deferred import defer_alias_choice

_PYDATA_ANNOTS_TYPING = {
"Any",
"AnyStr",
Expand Down Expand Up @@ -60,14 +61,6 @@
_TYPES_DICT = {getattr(types, name): name for name in types.__all__}
_TYPES_DICT[types.FunctionType] = "FunctionType"

_UNESCAPE_RE = re.compile(
r"""
\\ # literal backslash
([^ ]) # followed by any non-space character (captured)
""",
re.VERBOSE,
)


class MyTypeAliasForwardRef(TypeAliasForwardRef):
crossref: bool = False
Expand Down Expand Up @@ -171,27 +164,34 @@ def _format_node( # ruff:ignore[complex-structure, too-many-return-statements,
if isinstance(annotation, TypeAliasType):
if (crossref := _type_alias_crossref(annotation, config)) is not None:
return crossref
reference = _alias_name_reference(annotation, config)
if not _can_expand_alias(annotation, config):
return _alias_name_reference(annotation, config)
return (yield annotation.__value__)
return reference
expanded = yield annotation.__value__
return defer_alias_choice(config, reference, expanded) or expanded

if isinstance(alias := get_origin(annotation), TypeAliasType | TypeAliasForwardRef):
# A subscripted generic alias, e.g. ``NDArray[np.void]`` for ``type NDArray[ScalarT] = ...``.
# It is a plain ``types.GenericAlias``, so without unwrapping it here the code below would
# render it as its wrapper class' name instead of as the alias.
args = get_args(annotation)
expanded: str | None = None
if isinstance(alias, TypeAliasForwardRef): # a documented alias, resolved from a string annotation
rendered_alias = yield alias
elif (crossref := _type_alias_crossref(alias, config)) is not None:
rendered_alias = crossref
elif _matches_type_params(alias, args) and _can_expand_alias(alias, config): # undocumented: expand its value
return (yield _substitute_type_params(alias, args))
else: # cannot expand, so name the alias instead of leaking its type params
else:
if _matches_type_params(alias, args) and _can_expand_alias(alias, config):
expanded = yield _substitute_type_params(alias, args)
# cannot expand, so name the alias instead of leaking its type params
rendered_alias = _alias_name_reference(alias, config)
parts = []
for arg in args:
parts.append((yield arg)) # yield is illegal inside a comprehension
return f"{rendered_alias}\\ \\[{', '.join(parts)}]"
subscripted = f"{rendered_alias}\\ \\[{', '.join(parts)}]"
if expanded is None:
return subscripted
return defer_alias_choice(config, subscripted, expanded) or expanded

try:
module = get_annotation_module(annotation)
Expand Down Expand Up @@ -453,8 +453,9 @@ def _type_alias_crossref(annotation: TypeAliasType, config: Config) -> str | Non

Look the alias up in the py domain under the names it could be documented as: walk up the current module
prefix (so a ``T`` documented in the module being built wins), then its canonical module (so an alias
imported from elsewhere still resolves), then its bare name. An alias owned by a different top-level package
falls back to an intersphinx-style reference. Returning ``None`` tells the caller to expand the alias value.
imported from elsewhere still resolves), then its bare name. An alias owned by a different top-level
package falls back to an intersphinx-style reference. Only a document already read can answer, so
``None`` means "not known yet", and the caller defers the choice to the resolve phase.
"""
env = getattr(config, "_typehints_env", None)
if env is None:
Expand Down Expand Up @@ -507,10 +508,5 @@ def _get_canonical_type_alias_name(annotation: TypeAliasType) -> str:
return f"{module}.{name}"


def unescape(escaped: str) -> str:
escaped = escaped.replace("\x00", "")
return _UNESCAPE_RE.sub(r"\1", escaped)


def add_type_css_class(type_rst: str) -> str:
return f":sphinx_autodoc_typehints_type:`{rst.escape(type_rst)}`"
149 changes: 149 additions & 0 deletions src/sphinx_autodoc_typehints/_deferred.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"""Picking between a reference to a type alias and its expanded value, once every document is read."""

from __future__ import annotations

from hashlib import blake2s
from typing import TYPE_CHECKING, Any, Final

from docutils import nodes
from sphinx import addnodes
from sphinx.errors import NoUri
from sphinx.transforms.post_transforms import SphinxPostTransform
from sphinx.util.docutils import sphinx_domains

from ._parser import parse, unescape

if TYPE_CHECKING:
from docutils.nodes import Node
from docutils.parsers.rst import states
from sphinx.application import Sphinx
from sphinx.config import Config
from sphinx.environment import BuildEnvironment


#: Role marking a spot where the resolve phase picks between a reference and an expanded alias value.
ALIAS_CHOICE_ROLE: Final = "sphinx_autodoc_typehints_alias"
#: Node attribute and build environment attribute the two renderings are looked up through.
ALIAS_CHOICE_KEY: Final = "sphinx_autodoc_typehints_alias_key"
ALIAS_CHOICE_ENV_ATTR: Final = "_typehints_alias_choices"


def alias_choice_role(
_role: str,
_rawtext: str,
text: str,
_lineno: int,
_inliner: states.Inliner,
_options: dict[str, Any] | None = None,
_content: list[str] | None = None,
) -> tuple[list[Node], list[Node]]:
"""Mark a spot :class:`DeferAliasChoice` fills in, naming the choice made for it while reading."""
# No classes: `replace_self` would copy them onto whichever rendering wins
node = nodes.inline("", "")
node[ALIAS_CHOICE_KEY] = unescape(text)
return [node], []


def defer_alias_choice(config: Config, linked: str, expanded: str) -> str | None:
"""
Emit a marker the resolve phase replaces with ``linked`` or ``expanded``, or ``None`` if it cannot.

Whether an alias is documented has no answer while documents are still being read: the py domain
only learns a target once its own document is read, so deciding here would tie the rendering of a
signature to where its document sorts (and to how ``-j`` hands documents out). Hand both renderings
to the post-transform instead, which runs once every document is in.
"""
env = getattr(config, "_typehints_env", None)
if env is None or linked == expanded:
return None
choices = getattr(env, ALIAS_CHOICE_ENV_ATTR, None)
if not isinstance(choices, dict):
choices = {}
setattr(env, ALIAS_CHOICE_ENV_ATTR, choices)
# Content-addressed, so a doctree read in an earlier build still finds its choice
key = blake2s(f"{linked}\n{expanded}".encode(), digest_size=8).hexdigest()
choices[key] = (linked, expanded)
return f":{ALIAS_CHOICE_ROLE}:`{key}`"


def merge_alias_choices(app: Sphinx, env: BuildEnvironment, docnames: list[str], other: BuildEnvironment) -> None:
"""Carry the choices a parallel read recorded in a worker over into the main environment."""
del app, docnames
if not (worker_choices := getattr(other, ALIAS_CHOICE_ENV_ATTR, None)):
return
choices = getattr(env, ALIAS_CHOICE_ENV_ATTR, None)
if not isinstance(choices, dict):
choices = {}
setattr(env, ALIAS_CHOICE_ENV_ATTR, choices)
choices.update(worker_choices)


class DeferAliasChoice(SphinxPostTransform):
"""
Replace every alias marker with a reference to the alias, or with its expanded value.

Runs ahead of ``ReferencesResolver`` (priority 10), so the domain and the inventories can answer
for every document, and the references this leaves behind are still resolved normally.
"""

default_priority = 5

def run(self, **kwargs: Any) -> None:
del kwargs
choices = getattr(self.env, ALIAS_CHOICE_ENV_ATTR, None) or {}
pending = self._markers(self.document)
while pending:
node = pending.pop()
if (choice := choices.get(node[ALIAS_CHOICE_KEY])) is None:
node.replace_self([]) # nothing recorded this choice, e.g. a doctree from an older version
continue
linked, expanded = choice
replacement = self._reference(linked)
if replacement is None:
replacement = self._parse(expanded).children[0].children
# An expanded value can name further aliases, whose choices are still to be made
pending.extend(marker for child in replacement for marker in self._markers(child))
node.replace_self(list(replacement))

@staticmethod
def _markers(node: Node) -> list[nodes.Element]:
return [n for n in node.findall(nodes.inline) if ALIAS_CHOICE_KEY in n]

def _parse(self, rst: str) -> nodes.document:
with sphinx_domains(self.env):
doc = parse(rst, self.document.settings)
for xref in doc.findall(addnodes.pending_xref):
xref.setdefault("refdoc", self.env.docname)
return doc

def _reference(self, linked: str) -> list[Node] | None:
"""Render the reference ``linked`` describes, or ``None`` if nothing documents its target."""
doc = self._parse(linked)
for xref in list(doc.findall(addnodes.pending_xref)):
if (resolved := self._resolve(xref)) is None:
return None
xref.replace_self(resolved)
return list(doc.children[0].children) if doc.children else []

def _resolve(self, xref: addnodes.pending_xref) -> Node | None:
contnode = xref.children[0].deepcopy() if xref.children else nodes.literal(text=xref["reftarget"])
try:
domain = self.env.domains[xref["refdomain"]]
node = domain.resolve_xref(
self.env, xref["refdoc"], self.app.builder, xref["reftype"], xref["reftarget"], xref, contnode
)
if node is None: # give `missing-reference` handlers (e.g. qualname overrides) their say
node = self.app.emit_firstresult(
"missing-reference", self.env, xref, contnode, allowed_exceptions=(NoUri,)
)
except NoUri:
return None
return node


__all__ = [
"DeferAliasChoice",
"alias_choice_role",
"defer_alias_choice",
"merge_alias_choices",
]
19 changes: 18 additions & 1 deletion src/sphinx_autodoc_typehints/_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

from __future__ import annotations

from typing import TYPE_CHECKING
import re
from typing import TYPE_CHECKING, Final

from docutils.utils import new_document
from sphinx.parsers import RSTParser
Expand All @@ -14,6 +15,14 @@
from docutils.frontend import Values
from docutils.statemachine import StringList

_UNESCAPE_RE: Final = re.compile(
r"""
\\ # literal backslash
([^ ]) # followed by any non-space character (captured)
""",
re.VERBOSE,
)


class _RstSnippetParser(RSTParser):
@staticmethod
Expand All @@ -28,3 +37,11 @@ def parse(inputstr: str, settings: Values | optparse.Values) -> nodes.document:
# losing the external+ roles the read phase resolves (#753)
_RstSnippetParser().parse(inputstr, doc)
return doc


def unescape(escaped: str) -> str:
escaped = escaped.replace("\x00", "")
return _UNESCAPE_RE.sub(r"\1", escaped)
Comment on lines +42 to +44

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved this over here to prevent circular imports, and I think it fits here pretty well anyway.



__all__ = ["parse", "unescape"]
74 changes: 73 additions & 1 deletion tests/test_pep695.py
Original file line number Diff line number Diff line change
Expand Up @@ -571,7 +571,8 @@ def some_func(some_param: RecType) -> None:
assert "reference target not found" not in warning.getvalue()

result = normalize_sphinx_text((Path(app.srcdir) / "_build/text/index.txt").read_text())
assert '"int" | "list"["RecType"]' in result
# documented further down the same document, which only the resolve phase can know
assert '**some_param** ("RecType")' in result
Comment on lines -574 to +575

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this test changed because we鈥檙e now better at not expanding things that are documented.



@pytest.mark.parametrize(
Expand Down Expand Up @@ -683,6 +684,77 @@ def test_eager_annotations(
assert '"UserId"' in result


@pytest.mark.sphinx("text", testroot="integration")
def test_alias_documented_in_a_later_document(
app: SphinxTestApp,
status: StringIO,
warning: StringIO,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An alias documented in a document read after the one using it cross-references, not expands."""
(Path(app.srcdir) / "index.rst").write_text(
".. autofunction:: mod.type_alias_func\n\n.. toctree::\n\n zz_aliases\n"
)
(Path(app.srcdir) / "zz_aliases.rst").write_text(".. py:type:: mod.IntList\n\n List of integers.\n")
monkeypatch.setitem(sys.modules, "mod", _mod_pep695)
app.build()
assert "build succeeded" in status.getvalue()

value = warning.getvalue().strip()
assert not value or "Inline strong start-string without end-string" in value

result = normalize_sphinx_text((Path(app.srcdir) / "_build/text/index.txt").read_text())
assert '"IntList"' in result
assert '"list"["int"]' not in result


_mod_nested = types.ModuleType("mod_nested")
_mod_nested.__file__ = __file__
exec( # ruff:ignore[exec-builtin]
dedent("""\
from __future__ import annotations

type Inner = int | str
type Outer = Inner | bytes
type Documented = float
type Mixed = Documented | Inner

def nested_func(x: Mixed) -> Outer:
\"\"\"Describe.

:param x: the value
:return: the result
\"\"\"
...
"""),
_mod_nested.__dict__,
)


@pytest.mark.sphinx("text", testroot="integration")
def test_alias_expansion_recurses_into_further_aliases(
app: SphinxTestApp,
status: StringIO,
warning: StringIO,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An expanded value naming further aliases links the documented ones and expands the rest."""
(Path(app.srcdir) / "index.rst").write_text(
".. autofunction:: mod_nested.nested_func\n\n.. toctree::\n\n zz_aliases\n"
)
(Path(app.srcdir) / "zz_aliases.rst").write_text(".. py:type:: mod_nested.Documented\n\n A documented alias.\n")
monkeypatch.setitem(sys.modules, "mod_nested", _mod_nested)
app.build()
assert "build succeeded" in status.getvalue()

value = warning.getvalue().strip()
assert not value or "Inline strong start-string without end-string" in value

result = normalize_sphinx_text((Path(app.srcdir) / "_build/text/index.txt").read_text())
assert '**x** ("Documented" | "int" | "str")' in result # Mixed = Documented | Inner
assert '"int" | "str" | "bytes"' in result # Outer = Inner | bytes


@pytest.mark.skipif(sys.version_info < (3, 14), reason="annotationlib requires Python 3.14+")
@pytest.mark.sphinx("text", testroot="integration")
def test_forward_ref_builds_without_errors( # pragma: >=3.14 cover
Expand Down