From 84ba73e8ca4e54afd29a4fe67b8bfb022e76c794 Mon Sep 17 00:00:00 2001 From: Chris Wilson <14631+cdwilson@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:52:42 -0700 Subject: [PATCH 1/2] fix: Don't render environment paths in source block labels When an object's file lives in a virtual environment inside the current working directory (for example a pydantic `BaseModel` subclass documented with `preload_modules: [pydantic]` and an in-project `.venv`), source blocks were labeled with the environment path, like `.venv/lib/python3.10/site-packages/pydantic/main.py`. The path is relative to the current working directory, so it slipped past the `is_absolute()` check that normally keeps environment paths out of rendered docs. Add a `source_location` filter that strips everything up to and including a `site-packages` directory from displayed source paths, and use it for every source block label: merged `__init__`, class, and function, in both themes. Centralizing the logic also fixes the label for single-file modules installed directly in `site-packages` (like `six.py`), whose package-relative path still contained the environment directory. Labels of objects belonging to the documented package itself are unchanged. Issue-333: https://github.com/mkdocstrings/python/issues/333 PR-338: https://github.com/mkdocstrings/python/pull/338 --- src/mkdocstrings_handlers/python/__init__.py | 2 + .../python/_internal/handler.py | 1 + .../python/_internal/rendering.py | 25 +++++++ .../templates/material/_base/class.html.jinja | 16 +--- .../material/_base/function.html.jinja | 8 +- .../readthedocs/_base/class.html.jinja | 16 +--- tests/test_handler.py | 75 +++++++++++++++++++ 7 files changed, 108 insertions(+), 35 deletions(-) diff --git a/src/mkdocstrings_handlers/python/__init__.py b/src/mkdocstrings_handlers/python/__init__.py index dbad0355..c8927f7d 100644 --- a/src/mkdocstrings_handlers/python/__init__.py +++ b/src/mkdocstrings_handlers/python/__init__.py @@ -31,6 +31,7 @@ do_format_type_alias, do_get_template, do_order_members, + do_source_location, do_split_path, do_stash_crossref, ) @@ -64,6 +65,7 @@ "do_format_type_alias", "do_get_template", "do_order_members", + "do_source_location", "do_split_path", "do_stash_crossref", "get_handler", diff --git a/src/mkdocstrings_handlers/python/_internal/handler.py b/src/mkdocstrings_handlers/python/_internal/handler.py index 50e59b7e..cd1feb3b 100644 --- a/src/mkdocstrings_handlers/python/_internal/handler.py +++ b/src/mkdocstrings_handlers/python/_internal/handler.py @@ -345,6 +345,7 @@ def update_env(self, config: Any) -> None: # noqa: ARG002 self.env.filters["filter_objects"] = rendering.do_filter_objects self.env.filters["stash_crossref"] = rendering.do_stash_crossref self.env.filters["get_template"] = rendering.do_get_template + self.env.filters["source_location"] = rendering.do_source_location self.env.filters["as_attributes_section"] = rendering.do_as_attributes_section self.env.filters["as_functions_section"] = rendering.do_as_functions_section self.env.filters["as_classes_section"] = rendering.do_as_classes_section diff --git a/src/mkdocstrings_handlers/python/_internal/rendering.py b/src/mkdocstrings_handlers/python/_internal/rendering.py index 446ad2d1..dcd4df61 100644 --- a/src/mkdocstrings_handlers/python/_internal/rendering.py +++ b/src/mkdocstrings_handlers/python/_internal/rendering.py @@ -11,6 +11,7 @@ from contextlib import suppress from dataclasses import replace from functools import lru_cache +from pathlib import Path from re import Pattern from typing import TYPE_CHECKING, Any, Callable, ClassVar, Literal, TypeVar @@ -590,6 +591,30 @@ def do_get_template(obj: Object | Alias) -> str: return f"{name}.html.jinja" +def do_source_location(obj: Object | Alias) -> Path: + """Get the file path displayed in an object's source block label. + + Environment paths are never displayed: when the object's file lives in + a `site-packages` directory (for example a virtual environment inside + the current working directory), the path below `site-packages` is + returned instead. + + Parameters: + obj: A Griffe object. + + Returns: + The file path to display. + """ + relative_filepath = obj.relative_filepath + parts = relative_filepath.parts + if "site-packages" in parts: + anchor = len(parts) - 1 - parts[::-1].index("site-packages") + return Path(*parts[anchor + 1 :]) + if relative_filepath.is_absolute(): + return obj.relative_package_filepath + return relative_filepath + + @pass_context def do_as_attributes_section( context: Context, # noqa: ARG001 diff --git a/src/mkdocstrings_handlers/python/templates/material/_base/class.html.jinja b/src/mkdocstrings_handlers/python/templates/material/_base/class.html.jinja index 57f9fd5a..17e9e29f 100644 --- a/src/mkdocstrings_handlers/python/templates/material/_base/class.html.jinja +++ b/src/mkdocstrings_handlers/python/templates/material/_base/class.html.jinja @@ -249,26 +249,14 @@ Context: {% if "__init__" in all_members and all_members["__init__"].source %} {% with init = all_members["__init__"] %}
- {{ lang.t("Source code in") }} - {%- if init.relative_filepath.is_absolute() -%} - {{ init.relative_package_filepath }} - {%- else -%} - {{ init.relative_filepath }} - {%- endif -%} - + {{ lang.t("Source code in") }} {{ init|source_location }} {{ init.source|highlight(language="python", linestart=init.lineno or 0, linenums=True) }}
{% endwith %} {% endif %} {% elif class.source %}
- {{ lang.t("Source code in") }} - {%- if class.relative_filepath.is_absolute() -%} - {{ class.relative_package_filepath }} - {%- else -%} - {{ class.relative_filepath }} - {%- endif -%} - + {{ lang.t("Source code in") }} {{ class|source_location }} {{ class.source|highlight(language="python", linestart=class.lineno or 0, linenums=True) }}
{% endif %} diff --git a/src/mkdocstrings_handlers/python/templates/material/_base/function.html.jinja b/src/mkdocstrings_handlers/python/templates/material/_base/function.html.jinja index 3cfc7f30..c824226e 100644 --- a/src/mkdocstrings_handlers/python/templates/material/_base/function.html.jinja +++ b/src/mkdocstrings_handlers/python/templates/material/_base/function.html.jinja @@ -146,13 +146,7 @@ Context: -#} {% if config.show_source and function.source %}
- {{ lang.t("Source code in") }} - {%- if function.relative_filepath.is_absolute() -%} - {{ function.relative_package_filepath }} - {%- else -%} - {{ function.relative_filepath }} - {%- endif -%} - + {{ lang.t("Source code in") }} {{ function|source_location }} {{ function.source|highlight(language="python", linestart=function.lineno or 0, linenums=True) }}
{% endif %} diff --git a/src/mkdocstrings_handlers/python/templates/readthedocs/_base/class.html.jinja b/src/mkdocstrings_handlers/python/templates/readthedocs/_base/class.html.jinja index 64b41ea6..5f011000 100644 --- a/src/mkdocstrings_handlers/python/templates/readthedocs/_base/class.html.jinja +++ b/src/mkdocstrings_handlers/python/templates/readthedocs/_base/class.html.jinja @@ -208,26 +208,14 @@ Context: {% if "__init__" in class.all_members and class.all_members["__init__"].source %} {% with init = class.all_members["__init__"] %}
- Source code in - {%- if init.relative_filepath.is_absolute() -%} - {{ init.relative_package_filepath }} - {%- else -%} - {{ init.relative_filepath }} - {%- endif -%} - + Source code in {{ init|source_location }} {{ init.source|highlight(language="python", linestart=init.lineno or 0, linenums=True) }}
{% endwith %} {% endif %} {% elif class.source %}
- Source code in - {%- if class.relative_filepath.is_absolute() -%} - {{ class.relative_package_filepath }} - {%- else -%} - {{ class.relative_filepath }} - {%- endif -%} - + Source code in {{ class|source_location }} {{ class.source|highlight(language="python", linestart=class.lineno or 0, linenums=True) }}
{% endif %} diff --git a/tests/test_handler.py b/tests/test_handler.py index 1cccd6c6..323d4266 100644 --- a/tests/test_handler.py +++ b/tests/test_handler.py @@ -11,6 +11,7 @@ from textwrap import dedent from typing import TYPE_CHECKING +import bs4 import mkdocstrings import pytest from griffe import ( @@ -333,3 +334,77 @@ def test_specifying_inventory_base_url(handler: PythonHandler) -> None: # Assert the URL is based on the provided base URL msg = "Expected inventory URL to start with base_url" assert item_url.startswith(base_url), msg + + +def _source_labels(html: str) -> list[Path]: + soup = bs4.BeautifulSoup(html, features="html.parser") + labels = [] + for summary in soup.find_all("summary"): + if "Source code in" in summary.get_text(): + code_tag = summary.find("code") + assert code_tag is not None + labels.append(Path(code_tag.get_text(strip=True))) + return labels + + +def _write_site_packages_package(tmp_path: Path, *, single_module: bool) -> None: + """Lay out the issue-333 scenario: a package installed in a virtual environment inside the project. + + The environment's `site-packages` directory is relative to the current + working directory, so the package's `relative_filepath` is relative too, + slipping past `is_absolute()` checks. + """ + code = """ + class Model: + '''Model docstring.''' + + def __init__(self) -> None: + '''Init docstring.''' + self.model_attribute = 0 + + def method(self) -> None: + '''Method docstring.''' + """ + site = tmp_path / "site-packages" + module_path = site / "pkg.py" if single_module else site / "pkg" / "__init__.py" + module_path.parent.mkdir(parents=True) + module_path.write_text(dedent(code), encoding="utf-8") + + +@pytest.mark.parametrize( + "handler", + [ + {"theme": "readthedocs"}, + {"theme": {"name": "material"}}, + ], + indirect=["handler"], +) +@pytest.mark.parametrize( + ("single_module", "extra_options", "expected_label"), + [ + pytest.param(False, {"merge_init_into_class": True}, Path("pkg", "__init__.py"), id="merged-init"), + pytest.param(False, {}, Path("pkg", "__init__.py"), id="class-and-methods"), + pytest.param(True, {"merge_init_into_class": True}, Path("pkg.py"), id="single-module"), + ], +) +def test_no_environment_path_in_source_labels( + *, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + handler: PythonHandler, + single_module: bool, + extra_options: dict, + expected_label: Path, +) -> None: + """Assert source labels never show an environment path.""" + _write_site_packages_package(tmp_path, single_module=single_module) + monkeypatch.chdir(tmp_path) + # `collect()` reads the search paths lazily from this attribute. + handler._paths = [str(tmp_path / "site-packages")] + options = handler.get_options({"show_source": True, **extra_options}) + html = handler.render(handler.collect("pkg.Model", options), options) + labels = _source_labels(html) + assert labels + assert set(labels) == {expected_label} + # The source bodies themselves are still rendered. + assert "model_attribute" in html From 624974a419d6f0bf30acb4808675fdd66c6f302d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Mon, 17 Aug 2026 18:55:55 +0200 Subject: [PATCH 2/2] chore: Prepare release 2.0.7 --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ba4d956..f4d520b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ 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.7](https://github.com/mkdocstrings/python/releases/tag/2.0.7) - 2026-08-17 + +[Compare with 2.0.6](https://github.com/mkdocstrings/python/compare/2.0.6...2.0.7) + +### Bug Fixes + +- Don't render environment paths in source block labels ([84ba73e](https://github.com/mkdocstrings/python/commit/84ba73e8ca4e54afd29a4fe67b8bfb022e76c794) by Chris Wilson). [Issue-333](https://github.com/mkdocstrings/python/issues/333), [PR-338](https://github.com/mkdocstrings/python/pull/338) + ## [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)