diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index aeda8d6..88d12dc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,20 +9,20 @@ jobs: fail-fast: true matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.8", "3.10", "3.12"] + python-version: ["3.9", "3.11", "3.13"] steps: - uses: actions/checkout@v3 - - uses: goanpeca/setup-miniconda@v2.2.0 + - uses: conda-incubator/setup-miniconda@v3.0.4 with: miniforge-version: latest - conda-version: ">=23.7.4" - conda-build-version: ">=3.26" + conda-version: ">=24.11" + conda-build-version: ">=25.1" environment-file: environment.yml activate-environment: mkxref-dev python-version: ${{ matrix.python-version }} condarc-file: github-condarc.yml auto-activate-base: true - use-mamba: true + use-mamba: false - name: Dev install package run: | conda run -n mkxref-dev pip install -e . --no-deps --no-build-isolation diff --git a/CHANGELOG.md b/CHANGELOG.md index 3591c88..87d6c69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # mkdocstring-python-xref changes +## 1.14.0 + +* Work with mkdocstrings-python 1.14 or later +* Drop python 3.8 support +* Fix extra files in wheel's RECORD + ## 1.6.2 * Use griffe 1.0 or later diff --git a/Makefile b/Makefile index be20f2c..d6128bc 100644 --- a/Makefile +++ b/Makefile @@ -79,6 +79,7 @@ help: @$(ECHO) "build - Build wheel" @$(ECHO) "build-wheel - Build wheel." @$(ECHO) "build-conda - Build conda package (requires whl2conda)" + @$(ECHO) "unpacked-wheel - Build wheel and unpack it (for debugging)" @$(ECHO) @$(ECHO) "$(SECTION_COLOR)--- upload ---$(COLORLESS)" @$(ECHO) "upload - Upload wheel to pypi (requires authorization)" @@ -164,6 +165,9 @@ build-conda: $(CONDA_FILE) build: build-wheel build-sdist build-conda +unpacked-wheel: $(WHEEL_FILE) + $(CONDA_RUN) wheel unpack $(WHEEL_FILE) -d dist + site/index.html: $(MKDOC_FILES) $(SRC_FILES) $(CONDA_RUN) mkdocs build -f $(MKDOC_CONFIG) diff --git a/docs/install.md b/docs/install.md index fafd479..8273d8f 100644 --- a/docs/install.md +++ b/docs/install.md @@ -8,3 +8,12 @@ pip install mkdocstrings-python-xref ``` conda install -c conda-forge mkdocstrings-python-xref ``` + +## A note on dependencies + +This project is implemented by directly extending the [mkdocstrings-python] +project, so you may want to specify a specific upper bound on the version +of that project in your project's dependency file to guard against any +surprises in case of a breaking change in the future. + +[mkdocstrings-python]: https://mkdocstrings.github.io/python/ diff --git a/environment.yml b/environment.yml index 437c633..71142d8 100644 --- a/environment.yml +++ b/environment.yml @@ -4,8 +4,8 @@ channels: dependencies: # runtime - - python >=3.8,<3.13 - - mkdocstrings-python >=1.6.2,<2.0 + - python >=3.9,<3.14 + - mkdocstrings-python >=1.14,<2.0 - griffe >=1.0 # build - python-build >=1.0.0 diff --git a/pyproject.toml b/pyproject.toml index a1d7f52..2b83978 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,19 +13,19 @@ classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Topic :: Software Development :: Documentation", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ] keywords = [ "documentation-tool", "mkdocstrings", "mkdocstrings-handler", "python" ] dynamic = ["version"] -requires-python = ">=3.8" +requires-python = ">=3.9" dependencies = [ - "mkdocstrings-python >=1.6.2,<2.0", + "mkdocstrings-python >=1.14,<2.0", "griffe >=1.0" ] @@ -47,13 +47,13 @@ include = [ [tool.hatch.build.targets.sdist] packages = [ "src/mkdocstrings_handlers", - "src/mkdocstrings_handlers/python_xref", +# "src/mkdocstrings_handlers/python_xref", ] [tool.hatch.build.targets.wheel] packages = [ "src/mkdocstrings_handlers", - "src/mkdocstrings_handlers/python_xref", +# "src/mkdocstrings_handlers/python_xref", ] [tool.mypy] diff --git a/src/mkdocstrings_handlers/python_xref/VERSION b/src/mkdocstrings_handlers/python_xref/VERSION index fdd3be6..850e742 100644 --- a/src/mkdocstrings_handlers/python_xref/VERSION +++ b/src/mkdocstrings_handlers/python_xref/VERSION @@ -1 +1 @@ -1.6.2 +1.14.0 diff --git a/src/mkdocstrings_handlers/python_xref/__init__.py b/src/mkdocstrings_handlers/python_xref/__init__.py index 56a5170..0a0d569 100644 --- a/src/mkdocstrings_handlers/python_xref/__init__.py +++ b/src/mkdocstrings_handlers/python_xref/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2022-2023. Analog Devices Inc. +# Copyright (c) 2022-2025. Analog Devices Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,8 +15,7 @@ Extended mkdocstrings python handler """ -from .handler import PythonRelXRefHandler +from .handler import get_handler __all__ = ["get_handler"] -get_handler = PythonRelXRefHandler diff --git a/src/mkdocstrings_handlers/python_xref/crossref.py b/src/mkdocstrings_handlers/python_xref/crossref.py index 1035d4e..9e3f656 100644 --- a/src/mkdocstrings_handlers/python_xref/crossref.py +++ b/src/mkdocstrings_handlers/python_xref/crossref.py @@ -1,4 +1,4 @@ -# Copyright (c) 2022-2024. Analog Devices Inc. +# Copyright (c) 2022-2025. Analog Devices Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/src/mkdocstrings_handlers/python_xref/handler.py b/src/mkdocstrings_handlers/python_xref/handler.py index 5128e2f..6d26907 100644 --- a/src/mkdocstrings_handlers/python_xref/handler.py +++ b/src/mkdocstrings_handlers/python_xref/handler.py @@ -1,4 +1,4 @@ -# Copyright (c) 2022=2023. Analog Devices Inc. +# Copyright (c) 2022-2025. Analog Devices Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,12 +17,17 @@ from __future__ import annotations -from collections import ChainMap +import sys +from dataclasses import dataclass, fields from pathlib import Path -from typing import Any, List, Mapping, Optional +from textwrap import dedent +from typing import Annotated, Any, ClassVar, Mapping, MutableMapping, Optional +from warnings import warn -from griffe import Object +from mkdocs.config.defaults import MkDocsConfig +from mkdocstrings.handlers.base import CollectorItem from mkdocstrings.loggers import get_logger +from mkdocstrings_handlers.python.config import PythonOptions, Field, PythonConfig from mkdocstrings_handlers.python.handler import PythonHandler from .crossref import substitute_relative_crossrefs @@ -33,6 +38,39 @@ logger = get_logger(__name__) + +# TODO mkdocstrings 0.28 +# - `name` and `domain` (py) must be specified as class attributes +# - `handler` arg to superclass is deprecated +# - add `mdx` arg to constructor to pass on to superclass +# - `config_file_path` arg will no longer be passed +# + +# TODO python 3.9 - remove when 3.9 support is dropped +_dataclass_options = {"frozen": True} +if sys.version_info >= (3, 10): + _dataclass_options["kw_only"] = True + +@dataclass(**_dataclass_options) +class PythonRelXRefOptions(PythonOptions): + check_crossrefs: Annotated[ + bool, + Field( + group="docstrings", + parent="docstring_options", + description=dedent( + """ + Enables early checking of all cross-references. + + Note that this option only takes affect if **relative_crossrefs** is + also true. This option is true by default, so this option is used to + disable checking. Checking can also be disabled on a per-case basis by + prefixing the reference with '?', e.g. `[something][?dontcheckme]`. + """ + ), + ), + ] = True + class PythonRelXRefHandler(PythonHandler): """Extended version of mkdocstrings Python handler @@ -40,56 +78,82 @@ class PythonRelXRefHandler(PythonHandler): * Checks cross-references early in order to produce errors with source location """ - handler_name: str = __name__.rsplit('.', 2)[1] - - default_config = dict( - PythonHandler.default_config, - relative_crossrefs = False, - check_crossrefs = True, - ) - - def __init__(self, - theme: str, - custom_templates: Optional[str] = None, - config_file_path: Optional[str] = None, - paths: Optional[List[str]] = None, - locale: str = "en", - **_config: Any, - ): - super().__init__( - handler = self.handler_name, - theme = theme, - custom_templates = custom_templates, - config_file_path = config_file_path, - paths = paths, - locale=locale, + name: ClassVar[str] = "python_xref" + """Override the handler name""" + + def __init__(self, config: PythonConfig, base_dir: Path, **kwargs: Any) -> None: + """Initialize the handler. + + Parameters: + config: The handler configuration. + base_dir: The base directory of the project. + **kwargs: Arguments passed to the parent constructor. + """ + check_crossrefs = config.options.pop('check_crossrefs', None) # Remove + super().__init__(config, base_dir, **kwargs) + if check_crossrefs is not None: + self.global_options["check_crossrefs"] = check_crossrefs + + def get_options(self, local_options: Mapping[str, Any]) -> PythonRelXRefOptions: + local_options = dict(local_options) + check_crossrefs = local_options.pop('check_crossrefs', None) + _opts = super().get_options(local_options) + opts = PythonRelXRefOptions( + **{field.name: getattr(_opts, field.name) for field in fields(_opts)} ) - - def render(self, data: Object, config: Mapping[str,Any]) -> str: - final_config = ChainMap(config, self.default_config) # type: ignore[arg-type] - - if final_config["relative_crossrefs"]: - checkref = self._check_ref if final_config["check_crossrefs"] else None + if check_crossrefs is not None: + opts.check_crossrefs = bool(check_crossrefs) + return opts + + def render(self, data: CollectorItem, options: PythonOptions) -> str: + if options.relative_crossrefs: + if isinstance(options, PythonRelXRefOptions): + checkref = self._check_ref if options.check_crossrefs else None + else: + checkref = None substitute_relative_crossrefs(data, checkref=checkref) try: - return super().render(data, config) + return super().render(data, options) except Exception: # pragma: no cover print(f"{data.path=}") raise def get_templates_dir(self, handler: Optional[str] = None) -> Path: """See [render][.barf]""" - if handler == self.handler_name: + if handler == self.name: handler = 'python' return super().get_templates_dir(handler) def _check_ref(self, ref:str) -> bool: """Check for existence of reference""" try: - self.collect(ref, {}) + self.collect(ref, PythonOptions()) return True except Exception: # pylint: disable=broad-except # Only expect a CollectionError but we may as well catch everything. return False +def get_handler( + handler_config: MutableMapping[str, Any], + tool_config: MkDocsConfig, + **kwargs: Any, +) -> PythonHandler: + """Simply return an instance of `PythonRelXRefHandler`. + + Arguments: + handler_config: The handler configuration. + tool_config: The tool (SSG) configuration. + + Returns: + An instance of `PythonRelXRefHandler`. + """ + base_dir = Path(tool_config.config_file_path or "./mkdocs.yml").parent + if "inventories" not in handler_config and "import" in handler_config: + warn("The 'import' key is renamed 'inventories' for the Python handler", FutureWarning, stacklevel=1) + handler_config["inventories"] = handler_config.pop("import", []) + return PythonRelXRefHandler( + config=PythonConfig.from_data(**handler_config), + base_dir=base_dir, + **kwargs, + ) diff --git a/tests/test_crossref.py b/tests/test_crossref.py index 48e102d..d0908fd 100644 --- a/tests/test_crossref.py +++ b/tests/test_crossref.py @@ -1,4 +1,4 @@ -# Copyright (c) 2022-2024. Analog Devices Inc. +# Copyright (c) 2022-2025. Analog Devices Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/tests/test_handler.py b/tests/test_handler.py index bd61bf4..85aed70 100644 --- a/tests/test_handler.py +++ b/tests/test_handler.py @@ -1,4 +1,4 @@ -# Copyright (c) 2022-2024. Analog Devices Inc. +# Copyright (c) 2022-2025. Analog Devices Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -25,19 +25,22 @@ from griffe import Docstring, Object, Module from mkdocstrings.handlers.base import CollectionError +from mkdocstrings_handlers.python.config import PythonConfig from mkdocstrings_handlers.python.handler import PythonHandler -from mkdocstrings_handlers.python_xref.handler import PythonRelXRefHandler +from mkdocstrings_handlers.python_xref.handler import ( + PythonRelXRefHandler, + PythonRelXRefOptions +) def test_handler(tmpdir: PathLike, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture) -> None: - """Unit test for GarpyPythonHandler class + """Unit test for PythonRelXRefHandler class This is a minimal whitebox test that just checks whether PythonHandler class has been overridden correctly. A separate test should do doc generation and check the results. """ - config_file = os.path.join(tmpdir, 'mkdocs.yml') os.mkdir(os.path.join(tmpdir, 'path1')) os.mkdir(os.path.join(tmpdir, 'path2')) os.makedirs(os.path.join(tmpdir, 'custom_templates', 'python')) @@ -46,18 +49,22 @@ def test_handler(tmpdir: PathLike, # Test construction # + config = PythonConfig( # type: ignore[call-arg] + paths = ['path1', 'path2'], + ) + handler = PythonRelXRefHandler( - 'material', - config_file_path = config_file, + config, + Path(tmpdir), + theme = 'material', custom_templates = 'custom_templates', - paths = ['path1', 'path2'] ) - assert handler.handler_name == 'python_xref' + assert handler.name == 'python_xref' # NOTE: these could break if PythonHandler changes # pylint: disable=protected-access - assert handler.handler_name == 'python_xref' - assert handler._config_file_path == config_file + assert handler.name == 'python_xref' + # assert handler._config_file_path == config_file assert os.path.join(tmpdir, 'path1') in handler._paths assert os.path.join(tmpdir, 'path2') in handler._paths @@ -65,7 +72,7 @@ def test_handler(tmpdir: PathLike, # Test get_templates_dir() redirection # - assert handler.get_templates_dir(handler.handler_name) == handler.get_templates_dir('python') + assert handler.get_templates_dir(handler.name) == handler.get_templates_dir('python') # # Test render() @@ -88,13 +95,19 @@ def fake_render(_self: PythonHandler, data: Object, _config: dict) -> str: docstring = "[foo][.] [bar][bad.]" obj.docstring = Docstring(docstring, parent=obj) - rendered = handler.render(obj, {}) + rendered = handler.render(obj, PythonRelXRefOptions()) assert rendered == docstring - rendered = handler.render(obj, dict(relative_crossrefs=False)) + rendered = handler.render( + obj, + PythonRelXRefOptions(relative_crossrefs=False), # type: ignore[call-arg] + ) assert rendered == docstring - rendered = handler.render(obj, dict(relative_crossrefs=True)) + rendered = handler.render( + obj, + PythonRelXRefOptions(relative_crossrefs=True), # type: ignore[call-arg] + ) assert rendered == "[foo][mod.foo] [bar][bad.bar]" assert len(caplog.records) == 1 _, level, msg = caplog.record_tuples[0] @@ -102,17 +115,26 @@ def fake_render(_self: PythonHandler, data: Object, _config: dict) -> str: assert "Cannot load reference 'bad.bar'" in msg caplog.clear() - rendered = handler.render(obj, dict(relative_crossrefs=True, check_crossrefs=False)) + rendered = handler.render( + obj, + PythonRelXRefOptions(relative_crossrefs=True, check_crossrefs=False), # type: ignore[call-arg] + ) assert rendered == "[foo][mod.foo] [bar][bad.bar]" assert len(caplog.records) == 0 - rendered = handler.render(obj, dict(relative_crossrefs=True, check_crossrefs=False)) + rendered = handler.render( + obj, + PythonRelXRefOptions(relative_crossrefs=True, check_crossrefs=False), # type: ignore[call-arg] + ) assert rendered == "[foo][mod.foo] [bar][bad.bar]" assert len(caplog.records) == 0 docstring = "\n\n[foo][bad.foo]" obj.docstring = Docstring(docstring, parent=obj) - rendered = handler.render(obj, dict(relative_crossrefs=True)) + rendered = handler.render( + obj, + PythonRelXRefOptions(relative_crossrefs=True), # type: ignore[call-arg] + ) assert rendered == "[foo][bad.foo]" assert len(caplog.records) == 1 _, level, msg = caplog.record_tuples[0] @@ -122,6 +144,9 @@ def fake_render(_self: PythonHandler, data: Object, _config: dict) -> str: docstring = "[foo][?bad.foo] [bar][?bad.]" obj.docstring = Docstring(docstring, parent=obj) - rendered = handler.render(obj, dict(relative_crossrefs=True, check_crossrefs=True)) + rendered = handler.render( + obj, + PythonRelXRefOptions(relative_crossrefs=True, check_crossrefs=True), # type: ignore[call-arg] + ) assert rendered == "[foo][bad.foo] [bar][bad.bar]" assert len(caplog.records) == 0 diff --git a/tests/test_integration.py b/tests/test_integration.py index 05b82ed..472dd22 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -18,7 +18,7 @@ import subprocess as sp from os import PathLike from pathlib import Path -from typing import Dict, List, Tuple +from typing import Any, Dict, List, Tuple import bs4 @@ -29,7 +29,7 @@ bar_src_file = test_project_dir.joinpath('src', 'myproj', 'bar.py') -def check_autorefs(autorefs: List[bs4.Tag], cases: Dict[Tuple[str,str],str] ) -> None: +def check_autorefs(autorefs: List[Any], cases: Dict[Tuple[str,str],str] ) -> None: """ Verify autorefs contain expected cases @@ -66,7 +66,13 @@ def test_integration(tmpdir: PathLike) -> None: '-d', str(site_dir) ] - result = sp.run(mkdocs_cmd, stdout=sp.PIPE, stderr=sp.PIPE, encoding='utf8', check=False) + result = sp.run( + mkdocs_cmd, + stdout=sp.PIPE, + stderr=sp.PIPE, + encoding='utf8', + check=False, + ) assert result.returncode == 0 @@ -86,7 +92,7 @@ def test_integration(tmpdir: PathLike) -> None: bar_html = site_dir.joinpath('bar', 'index.html').read_text() bar_bs = bs4.BeautifulSoup(bar_html, 'html.parser') - autorefs: List[bs4.Tag] = bar_bs.find_all('a', attrs=['autorefs']) + autorefs = bar_bs.find_all('a', {'class' : 'autorefs'}) assert len(autorefs) >= 5 check_autorefs( @@ -104,7 +110,7 @@ def test_integration(tmpdir: PathLike) -> None: baz_html = site_dir.joinpath('pkg-baz', 'index.html').read_text() baz_bs = bs4.BeautifulSoup(baz_html, 'html.parser') - autorefs = baz_bs.find_all('a', attrs=['autorefs']) + autorefs = baz_bs.find_all('a', attrs={'class':'autorefs'}) assert len(autorefs) >= 1 check_autorefs(