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