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