From 5400e1c5981cac0dd7ce2d85ac01a2552d68eee4 Mon Sep 17 00:00:00 2001 From: Chris Wilson Date: Fri, 14 Aug 2026 16:51:31 -0700 Subject: [PATCH 1/3] 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 Assisted-by: Claude:claude-fable-5 --- 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 | 81 +++++++++++++++++++ 7 files changed, 114 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..671dee65 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,83 @@ 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 + + +_MODEL_CLASS = """ +class Model: + '''Model docstring.''' + + def __init__(self) -> None: + '''Init docstring.''' + self.model_attribute = 0 + + def method(self) -> None: + '''Method docstring.''' +""" + + +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. + """ + site_packages = tmp_path / "site-packages" + site_packages.mkdir(parents=True) + if single_module: + (site_packages / "pkg.py").write_text(dedent(_MODEL_CLASS)) + else: + pkg = site_packages / "pkg" + pkg.mkdir() + (pkg / "__init__.py").write_text(dedent(_MODEL_CLASS)) + + +@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 c7b79ba1b56c4b3af3fe932e29b06c52abdeb257 Mon Sep 17 00:00:00 2001 From: Chris Wilson Date: Sun, 16 Aug 2026 23:48:27 -0700 Subject: [PATCH 2/3] fixup! fix: Don't render environment paths in source block labels Assisted-by: Claude:claude-fable-5 Co-Authored-By: Claude Fable 5 --- tests/test_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_handler.py b/tests/test_handler.py index 671dee65..96ca7c1e 100644 --- a/tests/test_handler.py +++ b/tests/test_handler.py @@ -370,11 +370,11 @@ def _write_site_packages_package(tmp_path: Path, *, single_module: bool) -> None site_packages = tmp_path / "site-packages" site_packages.mkdir(parents=True) if single_module: - (site_packages / "pkg.py").write_text(dedent(_MODEL_CLASS)) + (site_packages / "pkg.py").write_text(dedent(_MODEL_CLASS), encoding="utf-8") else: pkg = site_packages / "pkg" pkg.mkdir() - (pkg / "__init__.py").write_text(dedent(_MODEL_CLASS)) + (pkg / "__init__.py").write_text(dedent(_MODEL_CLASS), encoding="utf-8") @pytest.mark.parametrize( From e2daa4346693bf11b0274e73f4a14ea0078c804c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Mon, 17 Aug 2026 17:06:28 +0200 Subject: [PATCH 3/3] fixup! fix: Don't render environment paths in source block labels --- tests/test_handler.py | 36 +++++++++++++++--------------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/tests/test_handler.py b/tests/test_handler.py index 96ca7c1e..323d4266 100644 --- a/tests/test_handler.py +++ b/tests/test_handler.py @@ -347,19 +347,6 @@ def _source_labels(html: str) -> list[Path]: return labels -_MODEL_CLASS = """ -class Model: - '''Model docstring.''' - - def __init__(self) -> None: - '''Init docstring.''' - self.model_attribute = 0 - - def method(self) -> None: - '''Method docstring.''' -""" - - 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. @@ -367,14 +354,21 @@ def _write_site_packages_package(tmp_path: Path, *, single_module: bool) -> None working directory, so the package's `relative_filepath` is relative too, slipping past `is_absolute()` checks. """ - site_packages = tmp_path / "site-packages" - site_packages.mkdir(parents=True) - if single_module: - (site_packages / "pkg.py").write_text(dedent(_MODEL_CLASS), encoding="utf-8") - else: - pkg = site_packages / "pkg" - pkg.mkdir() - (pkg / "__init__.py").write_text(dedent(_MODEL_CLASS), encoding="utf-8") + 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(