-
-
Notifications
You must be signed in to change notification settings - Fork 113
馃悰 fix(annotations): defer alias expansion #766
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||
|
|
@@ -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 | ||
|
|
||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.