From f1672f75dcc0f9b77e956ef019e4423c1968340d Mon Sep 17 00:00:00 2001 From: Christopher Barber Date: Mon, 1 Sep 2025 12:39:21 -0400 Subject: [PATCH 1/3] Fix for issue #47 --- CHANGELOG.md | 4 ++++ pixi.lock | 2 +- pyproject.toml | 4 ++-- .../python_xref/crossref.py | 9 +++++++- tests/project/src/myproj/bar.py | 5 +++++ tests/project/src/myproj/pkg/__init__.py | 7 ++++++ tests/project/src/myproj/pkg/dataclass.py | 22 +++++++++++++++++++ tests/test_crossref.py | 11 ++++++++++ tests/test_integration.py | 15 ++++++++++++- 9 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 tests/project/src/myproj/pkg/dataclass.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a7ddb80..cd500d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ *Note that versions roughly correspond to the version of mkdocstrings-python that they are compatible with.* +## 1.16.4 + +* Fix handling of aliases (see bug #47) + ## 1.16.3 * Added `check_crossrefs_exclude` config option diff --git a/pixi.lock b/pixi.lock index 6f533fb..290a6ef 100644 --- a/pixi.lock +++ b/pixi.lock @@ -1360,7 +1360,7 @@ packages: - pypi: ./ name: mkdocstrings-python-xref version: 1.16.3 - sha256: 07b578f8cb9e04ab7cbc2ec7e851cbcc4bb458056456d19c654b0dc4c87fdc4a + sha256: 9a8f66b0f5685e868a3ae1308a2f9a2c25eaf4649130730bea2be017e8ef7082 requires_dist: - griffe>=1.0 - mkdocstrings-python>=1.16.6,<2.0 diff --git a/pyproject.toml b/pyproject.toml index c85d7ed..569f8d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dynamic = ["version"] requires-python = ">=3.9" dependencies = [ "mkdocstrings-python >=1.16.6,<2.0", - "griffe >=1.0" + "griffe >=1.0", ] [project.urls] @@ -47,7 +47,7 @@ dev = [ "mike >=1.1", "mkdocs >=1.5.3,<2.0", "mkdocs-material >=9.5.4", - "linkchecker >=10.4" + "linkchecker >=10.4", ] [tool.pixi.workspace] diff --git a/src/mkdocstrings_handlers/python_xref/crossref.py b/src/mkdocstrings_handlers/python_xref/crossref.py index 2fb3084..4f10d78 100644 --- a/src/mkdocstrings_handlers/python_xref/crossref.py +++ b/src/mkdocstrings_handlers/python_xref/crossref.py @@ -20,7 +20,7 @@ import sys from typing import Any, Callable, List, Optional, cast -from griffe import Docstring, Object +from griffe import Alias, Docstring, GriffeError, Object from mkdocstrings import get_logger __all__ = [ @@ -332,6 +332,13 @@ def substitute_relative_crossrefs(obj: Object, checkref: Optional[Callable[[str] doc.value = _RE_CROSSREF.sub(_RelativeCrossrefProcessor(doc, checkref=checkref), doc.value) for member in obj.members.values(): + if isinstance(member, Alias): + try: + member = member.target + except GriffeError as ex: + # If alias could not be resolved, it probably refers + # to an external package, not be documented. + pass if isinstance(member, Object): # pragma: no branch substitute_relative_crossrefs(member, checkref=checkref) diff --git a/tests/project/src/myproj/bar.py b/tests/project/src/myproj/bar.py index 9bb80b7..8f85fc2 100644 --- a/tests/project/src/myproj/bar.py +++ b/tests/project/src/myproj/bar.py @@ -21,6 +21,11 @@ class Bar(Foo): """See [bar][.] method.""" + attribute: str = "attribute" + """ + See [`foo`][(c).] + """ + def bar(self) -> None: """This is in the [Bar][(c)] class. Also see the [foo][^.] method and the [func][(m).] function. diff --git a/tests/project/src/myproj/pkg/__init__.py b/tests/project/src/myproj/pkg/__init__.py index 9e30385..e18a461 100644 --- a/tests/project/src/myproj/pkg/__init__.py +++ b/tests/project/src/myproj/pkg/__init__.py @@ -15,6 +15,13 @@ A module """ +from .dataclass import Dataclass + +__all__ = [ + "Dataclass", + "func", +] + def func() -> None: """ A function diff --git a/tests/project/src/myproj/pkg/dataclass.py b/tests/project/src/myproj/pkg/dataclass.py new file mode 100644 index 0000000..fa60ed4 --- /dev/null +++ b/tests/project/src/myproj/pkg/dataclass.py @@ -0,0 +1,22 @@ + +from dataclasses import dataclass, field + +@dataclass +class Dataclass: + """ + Test dataclasses + + See [content][(c).] for an example attribute. + + See [method][(c).] + """ + content: str = "hi" + """some content""" + + duration: float = field(default_factory=lambda: 0.0) + """ + example: [`content`][(c).] + """ + + def method(self) -> str: + """Example method.""" diff --git a/tests/test_crossref.py b/tests/test_crossref.py index 501ef0e..1ccbd08 100644 --- a/tests/test_crossref.py +++ b/tests/test_crossref.py @@ -23,6 +23,7 @@ from textwrap import dedent from typing import Callable, Optional +import griffe import pytest from griffe import Class, Docstring, Function, Module, Object, LinesCollection @@ -264,3 +265,13 @@ def test_doc_value_offset_to_location() -> None: assert doc_value_offset_to_location(doc3, 0) == (2, 5) assert doc_value_offset_to_location(doc3, 6) == (3, 3) + +def test_griffe() -> None: + this_dir = Path(__file__).parent + test_src_dir = this_dir / "project" / "src" + myproj = griffe.load( + "myproj", + search_paths = [ test_src_dir ], + ) + substitute_relative_crossrefs(myproj) + print(myproj) diff --git a/tests/test_integration.py b/tests/test_integration.py index 42661ac..db04c42 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -36,7 +36,7 @@ def check_autorefs(autorefs: List[Any], cases: Dict[Tuple[str,str],str] ) -> Non Arguments: autorefs: list of autoref tags parsed from HTML cases: mapping from (,) to generated reference tag - where <location? is the qualified name of the object whose doc string + where <location> is the qualified name of the object whose doc string contains the cross-reference, and <title> is the text in the cross-reference. """ cases = cases.copy() @@ -123,4 +123,17 @@ def test_integration(tmpdir: PathLike) -> None: } ) + pkg_html = site_dir.joinpath('pkg', 'index.html').read_text() + pkg_bs = bs4.BeautifulSoup(pkg_html, 'html.parser') + autorefs = pkg_bs.find_all('a', attrs={'class':'autorefs'}) + assert len(autorefs) >= 3 + + check_autorefs( + autorefs, + { + ('myproj.pkg.Dataclass', 'content') : '#myproj.pkg.Dataclass.content', + ('myproj.pkg.Dataclass', 'method') : '#myproj.pkg.Dataclass.method', + ('myproj.pkg.Dataclass.duration', 'content') : '#myproj.pkg.Dataclass.content', + } + ) From 8ee98355e1e8a990535ab43da17d35ba488dcd03 Mon Sep 17 00:00:00 2001 From: Christopher Barber <christopher.barber@analog.com> Date: Mon, 1 Sep 2025 12:40:15 -0400 Subject: [PATCH 2/3] Update version to 1.16.4 --- src/mkdocstrings_handlers/python_xref/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mkdocstrings_handlers/python_xref/VERSION b/src/mkdocstrings_handlers/python_xref/VERSION index c807441..a232073 100644 --- a/src/mkdocstrings_handlers/python_xref/VERSION +++ b/src/mkdocstrings_handlers/python_xref/VERSION @@ -1 +1 @@ -1.16.3 +1.16.4 From b51b45a5df16db6076b954c9c69e4df94bcd1532 Mon Sep 17 00:00:00 2001 From: Christopher Barber <christopher.barber@analog.com> Date: Mon, 1 Sep 2025 13:03:13 -0400 Subject: [PATCH 3/3] lint fixes --- .../python_xref/crossref.py | 22 +++++++++++-------- tests/project/src/myproj/pkg/dataclass.py | 5 ++++- tests/test_crossref.py | 7 +++++- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/mkdocstrings_handlers/python_xref/crossref.py b/src/mkdocstrings_handlers/python_xref/crossref.py index 4f10d78..57ed60e 100644 --- a/src/mkdocstrings_handlers/python_xref/crossref.py +++ b/src/mkdocstrings_handlers/python_xref/crossref.py @@ -318,7 +318,10 @@ def _error(self, msg: str, just_warn: bool = False) -> None: self._ok = just_warn -def substitute_relative_crossrefs(obj: Object, checkref: Optional[Callable[[str], bool]] = None) -> None: +def substitute_relative_crossrefs( + obj: Alias|Object, + checkref: Optional[Callable[[str], bool]] = None, +) -> None: """Recursively expand relative cross-references in all docstrings in tree. Arguments: @@ -326,20 +329,21 @@ def substitute_relative_crossrefs(obj: Object, checkref: Optional[Callable[[str] checkref: optional function to check whether computed cross-reference is valid. Should return True if valid, False if not valid. """ + if isinstance(obj, Alias): + try: + obj = obj.target + except GriffeError: + # If alias could not be resolved, it probably refers + # to an external package, not be documented. + return + doc = obj.docstring if doc is not None: doc.value = _RE_CROSSREF.sub(_RelativeCrossrefProcessor(doc, checkref=checkref), doc.value) for member in obj.members.values(): - if isinstance(member, Alias): - try: - member = member.target - except GriffeError as ex: - # If alias could not be resolved, it probably refers - # to an external package, not be documented. - pass - if isinstance(member, Object): # pragma: no branch + if isinstance(member, (Alias,Object)): # pragma: no branch substitute_relative_crossrefs(member, checkref=checkref) def doc_value_offset_to_location(doc: Docstring, offset: int) -> tuple[int,int]: diff --git a/tests/project/src/myproj/pkg/dataclass.py b/tests/project/src/myproj/pkg/dataclass.py index fa60ed4..2ef7dce 100644 --- a/tests/project/src/myproj/pkg/dataclass.py +++ b/tests/project/src/myproj/pkg/dataclass.py @@ -1,3 +1,6 @@ +""" +Dataclass example +""" from dataclasses import dataclass, field @@ -18,5 +21,5 @@ class Dataclass: example: [`content`][(c).] """ - def method(self) -> str: + def method(self) -> None: """Example method.""" diff --git a/tests/test_crossref.py b/tests/test_crossref.py index 1ccbd08..fa3a768 100644 --- a/tests/test_crossref.py +++ b/tests/test_crossref.py @@ -267,6 +267,11 @@ def test_doc_value_offset_to_location() -> None: assert doc_value_offset_to_location(doc3, 6) == (3, 3) def test_griffe() -> None: + """ + Test substitution on griffe rep of local project + Returns: + + """ this_dir = Path(__file__).parent test_src_dir = this_dir / "project" / "src" myproj = griffe.load( @@ -274,4 +279,4 @@ def test_griffe() -> None: search_paths = [ test_src_dir ], ) substitute_relative_crossrefs(myproj) - print(myproj) + # TODO - grovel output