diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ea1cfb..2ba4d95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [2.0.6](https://github.com/mkdocstrings/python/releases/tag/2.0.6) - 2026-08-16 + +[Compare with 2.0.5](https://github.com/mkdocstrings/python/compare/2.0.5...2.0.6) + +### Bug Fixes + +- Don't pass unsupported options to Griffe parsers ([4b38b6b](https://github.com/mkdocstrings/python/commit/4b38b6ba6c3d1e09339cd5eb2c43145be84bda66) by Timothée Mazzucotelli). [Issue-337](https://github.com/mkdocstrings/python/issues/337) +- Increase stash key length from 1 to 2 ([6ff2749](https://github.com/mkdocstrings/python/commit/6ff274911441ffaace47dd822fd92a3cdac84e65) by Jonas Haag). [PR-336](https://github.com/mkdocstrings/python/pull/336) + ## [2.0.5](https://github.com/mkdocstrings/python/releases/tag/2.0.5) - 2026-06-19 [Compare with 2.0.4](https://github.com/mkdocstrings/python/compare/2.0.4...2.0.5) diff --git a/README.md b/README.md index 2d55958..fb895ae 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ dependencies = [ BenHammersley trevorWieland MarcoGorelli -analog-cbarber +analog-cbarber OdinManiac rstudio-sponsorship schlich @@ -120,17 +120,15 @@ dependencies = [ activeloopai roboflow cmclaughlin -blaisep RapidataAI rodolphebarbanneau theSymbolSyndicate blakeNaccarato ChargeStorm -Alphadelta14 Cusp-AI

-*And 7 more private sponsor(s).* +*And 4 more private sponsor(s).* diff --git a/pyproject.toml b/pyproject.toml index 96d3c71..dac83b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,18 +85,19 @@ maintain = [ "yore>=0.3.3", ] ci = [ + "beautifulsoup4>=4.12.3", "black>=25.1", "duty>=1.6", - "ruff>=0.4", + "griffe>=2.1", + "inline-snapshot>=0.25", + "mypy>=1.10", "pytest>=8.2", "pytest-cov>=5.0", "pytest-randomly>=3.15", "pytest-xdist>=3.6", - "beautifulsoup4>=4.12.3", - "inline-snapshot>=0.25", - "mypy>=1.10", "types-markdown>=3.6", "types-pyyaml>=6.0", + "ruff>=0.4", ] docs = [ "markdown-callouts>=0.4", diff --git a/scripts/griffe_extensions.py b/scripts/griffe_extensions.py index 5a4447c..87106d4 100644 --- a/scripts/griffe_extensions.py +++ b/scripts/griffe_extensions.py @@ -23,9 +23,14 @@ def on_attribute_instance( """Fetch descriptions from `Field` annotations.""" if attr.docstring: return + annotation = attr.annotation + if not isinstance(annotation, griffe.ExprSubscript) or not isinstance(annotation.slice, griffe.ExprTuple): + return try: - field: griffe.ExprCall = attr.annotation.slice.elements[1] - except AttributeError: + field = annotation.slice.elements[1] + except IndexError: + return + if not isinstance(field, griffe.ExprCall): return if field.canonical_path == "mkdocstrings_handlers.python._internal.config._Field": diff --git a/src/mkdocstrings_handlers/python/_internal/handler.py b/src/mkdocstrings_handlers/python/_internal/handler.py index b84cd8b..50e59b7 100644 --- a/src/mkdocstrings_handlers/python/_internal/handler.py +++ b/src/mkdocstrings_handlers/python/_internal/handler.py @@ -3,13 +3,14 @@ from __future__ import annotations import glob +import inspect import os import posixpath import sys from contextlib import suppress from dataclasses import asdict from pathlib import Path -from typing import TYPE_CHECKING, Any, BinaryIO, ClassVar +from typing import TYPE_CHECKING, Any, BinaryIO, Callable, ClassVar from griffe import ( AliasResolutionError, @@ -18,6 +19,10 @@ ModulesCollection, Parser, load_extensions, + parse_auto, + parse_google, + parse_numpy, + parse_sphinx, patch_loggers, ) from mkdocs.exceptions import PluginError @@ -54,6 +59,34 @@ def chdir(path: str) -> Iterator[None]: patch_loggers(get_logger) +_PARSER_FUNCTIONS: dict[Parser, Callable] = { + Parser.auto: parse_auto, + Parser.google: parse_google, + Parser.numpy: parse_numpy, + Parser.sphinx: parse_sphinx, +} + + +def _filter_parser_options(parser: Parser | None, options: dict[str, Any] | None) -> dict[str, Any] | None: + """Filter options unsupported by the selected Griffe parser.""" + if parser is None or options is None: + return options + + accepted_options = set(inspect.signature(_PARSER_FUNCTIONS[parser]).parameters) - {"docstring"} + filtered_options = {} + for name, value in options.items(): + if name in accepted_options: + if parser is Parser.auto and name == "per_style_options": + filtered_options[name] = { + style: _filter_parser_options(Parser(style), style_options) + for style, style_options in value.items() + } + else: + filtered_options[name] = value + else: + _logger.warning(f"Ignoring unsupported {parser.value} docstring parser option: {name}") + return filtered_options + class PythonHandler(BaseHandler): """The Python handler class.""" @@ -195,7 +228,9 @@ def collect(self, identifier: str, options: PythonOptions) -> CollectorItem: parser_name = options.docstring_style parser = parser_name and Parser(parser_name) - parser_options = options.docstring_options and asdict(options.docstring_options) + parser_options: dict[str, Any] | None = None + if options.docstring_options is not None: + parser_options = _filter_parser_options(parser, asdict(options.docstring_options)) if unknown_module: extensions = self.normalize_extension_paths(options.extensions) @@ -203,7 +238,7 @@ def collect(self, identifier: str, options: PythonOptions) -> CollectorItem: extensions=load_extensions(*extensions), search_paths=self._paths, docstring_parser=parser, - docstring_options=parser_options, + docstring_options=parser_options, # type: ignore[arg-type] modules_collection=self._modules_collection, lines_collection=self._lines_collection, allow_inspection=options.allow_inspection, diff --git a/src/mkdocstrings_handlers/python/_internal/rendering.py b/src/mkdocstrings_handlers/python/_internal/rendering.py index d5ffc8e..446ad2d 100644 --- a/src/mkdocstrings_handlers/python/_internal/rendering.py +++ b/src/mkdocstrings_handlers/python/_internal/rendering.py @@ -107,7 +107,7 @@ class _StashCrossRefFilter: @staticmethod def _gen_key(length: int) -> str: - return "_" + "".join(random.choice(string.ascii_letters + string.digits) for _ in range(max(1, length - 1))) # noqa: S311 + return "_" + "".join(random.choice(string.ascii_letters + string.digits) for _ in range(max(2, length - 1))) # noqa: S311 def _gen_stash_key(self, length: int) -> str: key = self._gen_key(length) @@ -420,22 +420,20 @@ def _keep_object(name: str, filters: Sequence[tuple[Pattern, bool]]) -> bool: def _parents(obj: Alias) -> set[str]: - parent: Object | Alias = obj.parent - parents = {obj.path, parent.path} - if parent.is_alias: - parents.add(parent.final_target.path) - while parent.parent: - parent = parent.parent + parents = {obj.path} + parent = obj.parent + while parent is not None: parents.add(parent.path) - if parent.is_alias: + if isinstance(parent, Alias): parents.add(parent.final_target.path) + parent = parent.parent return parents def _remove_cycles(objects: list[Object | Alias]) -> Iterator[Object | Alias]: suppress_errors = suppress(AliasResolutionError, CyclicAliasError) for obj in objects: - if obj.is_alias: + if isinstance(obj, Alias): with suppress_errors: if obj.final_target.path in _parents(obj): continue @@ -784,6 +782,8 @@ def expand_identifier(self, identifier: str) -> str: obj = self.current_object while identifier and identifier[0] == ".": identifier = identifier[1:] + if obj.parent is None: + break obj = obj.parent identifier = f"{obj.path}.{identifier}" if identifier else obj.path @@ -814,12 +814,13 @@ def get_context(self) -> AutorefsHookInterface.Context: "module": "mod", }.get(self.current_object.kind.value.lower(), "obj") origin = self.current_object.path - try: - filepath = self.current_object.docstring.parent.filepath - lineno = self.current_object.docstring.lineno or 0 - except AttributeError: + docstring = self.current_object.docstring + if docstring is None or docstring.parent is None: filepath = self.current_object.filepath lineno = 0 + else: + filepath = docstring.parent.filepath + lineno = docstring.lineno or 0 return AutorefsHookInterface.Context( domain="py", diff --git a/tests/test_api.py b/tests/test_api.py index 85432b5..a1c8412 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -36,7 +36,7 @@ def _fixture_public_api(loader: griffe.GriffeLoader) -> griffe.Module: def _yield_public_objects( - obj: griffe.Module | griffe.Class, + obj: griffe.Module | griffe.Class | griffe.Alias, *, modules: bool = False, modulelevel: bool = True, @@ -48,6 +48,7 @@ def _yield_public_objects( if member.is_module: if member.is_alias or not member.is_public: continue + assert isinstance(member, griffe.Module) if modules: yield member yield from _yield_public_objects( @@ -62,6 +63,7 @@ def _yield_public_objects( else: continue if member.is_class and not modulelevel: + assert isinstance(member, (griffe.Class, griffe.Alias)) yield from _yield_public_objects( member, modules=modules, diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..5c12a41 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,61 @@ +"""Tests for configuration options.""" + +from __future__ import annotations + +import inspect +from dataclasses import asdict, fields +from typing import TYPE_CHECKING, Any + +import pytest +from griffe import Parser, parse_google, parse_numpy, parse_sphinx + +from mkdocstrings_handlers.python import AutoStyleOptions, GoogleStyleOptions, NumpyStyleOptions, SphinxStyleOptions +from mkdocstrings_handlers.python._internal.handler import _filter_parser_options + +if TYPE_CHECKING: + from collections.abc import Callable + + +@pytest.mark.parametrize( + ("options_class", "parser"), + [ + (GoogleStyleOptions, parse_google), + (NumpyStyleOptions, parse_numpy), + (SphinxStyleOptions, parse_sphinx), + ], +) +def test_style_options_match_griffe_parser(options_class: type[Any], parser: Callable[..., object]) -> None: + """Ensure style options stay in sync with Griffe parser options.""" + option_names = {field.name for field in fields(options_class)} + parser_parameters = inspect.signature(parser).parameters + parser_option_names = set(parser_parameters) - {"docstring"} + + assert parser_option_names <= option_names + + +def test_filter_style_options(caplog: pytest.LogCaptureFixture) -> None: + """Ensure unsupported options are not passed to Griffe and are reported.""" + options = asdict(SphinxStyleOptions()) + + filtered_options = _filter_parser_options(Parser.sphinx, options) + + assert filtered_options == { + name: value for name, value in options.items() if name in inspect.signature(parse_sphinx).parameters + } + assert "warn_missing_types" in options + for name in set(options) - set(filtered_options or {}): + assert f"Ignoring unsupported sphinx docstring parser option: {name}" in caplog.text + + +def test_filter_auto_style_options(caplog: pytest.LogCaptureFixture) -> None: + """Ensure unsupported options nested in auto style options are reported.""" + options = asdict(AutoStyleOptions()) + + filtered_options = _filter_parser_options(Parser.auto, options) + + assert filtered_options is not None + if "warn_missing_types" in inspect.signature(parse_sphinx).parameters: + assert "warn_missing_types" in filtered_options["per_style_options"]["sphinx"] + else: + assert "warn_missing_types" not in filtered_options["per_style_options"]["sphinx"] + assert "Ignoring unsupported sphinx docstring parser option: warn_missing_types" in caplog.text diff --git a/tests/test_rendering.py b/tests/test_rendering.py index 91f945a..3709ec9 100644 --- a/tests/test_rendering.py +++ b/tests/test_rendering.py @@ -4,10 +4,10 @@ import re from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any, Callable, cast import pytest -from griffe import ModulesCollection, temporary_visited_module +from griffe import Alias, ModulesCollection, Object, temporary_visited_module from mkdocstrings_handlers.python._internal import rendering @@ -78,7 +78,7 @@ def test_filter_objects(names: list[str], filter_params: dict[str, Any], expecte expected_names: Names expected to be kept. """ objects = {name: _FakeObject(name) for name in names} - filtered = rendering.do_filter_objects(objects, **filter_params) + filtered = rendering.do_filter_objects(cast("dict[str, Object | Alias]", objects), **filter_params) filtered_names = {obj.name for obj in filtered} assert set(filtered_names) == set(expected_names)