Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/mkdocstrings_handlers/python/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
do_format_type_alias,
do_get_template,
do_order_members,
do_source_location,
do_split_path,
do_stash_crossref,
)
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/mkdocstrings_handlers/python/_internal/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions src/mkdocstrings_handlers/python/_internal/rendering.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,26 +249,14 @@ Context:
{% if "__init__" in all_members and all_members["__init__"].source %}
{% with init = all_members["__init__"] %}
<details class="mkdocstrings-source">
<summary>{{ lang.t("Source code in") }} <code>
{%- if init.relative_filepath.is_absolute() -%}
{{ init.relative_package_filepath }}
{%- else -%}
{{ init.relative_filepath }}
{%- endif -%}
</code></summary>
<summary>{{ lang.t("Source code in") }} <code>{{ init|source_location }}</code></summary>
{{ init.source|highlight(language="python", linestart=init.lineno or 0, linenums=True) }}
</details>
{% endwith %}
{% endif %}
{% elif class.source %}
<details class="mkdocstrings-source">
<summary>{{ lang.t("Source code in") }} <code>
{%- if class.relative_filepath.is_absolute() -%}
{{ class.relative_package_filepath }}
{%- else -%}
{{ class.relative_filepath }}
{%- endif -%}
</code></summary>
<summary>{{ lang.t("Source code in") }} <code>{{ class|source_location }}</code></summary>
{{ class.source|highlight(language="python", linestart=class.lineno or 0, linenums=True) }}
</details>
{% endif %}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,13 +146,7 @@ Context:
-#}
{% if config.show_source and function.source %}
<details class="mkdocstrings-source">
<summary>{{ lang.t("Source code in") }} <code>
{%- if function.relative_filepath.is_absolute() -%}
{{ function.relative_package_filepath }}
{%- else -%}
{{ function.relative_filepath }}
{%- endif -%}
</code></summary>
<summary>{{ lang.t("Source code in") }} <code>{{ function|source_location }}</code></summary>
{{ function.source|highlight(language="python", linestart=function.lineno or 0, linenums=True) }}
</details>
{% endif %}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,26 +208,14 @@ Context:
{% if "__init__" in class.all_members and class.all_members["__init__"].source %}
{% with init = class.all_members["__init__"] %}
<details class="quote">
<summary>Source code in <code>
{%- if init.relative_filepath.is_absolute() -%}
{{ init.relative_package_filepath }}
{%- else -%}
{{ init.relative_filepath }}
{%- endif -%}
</code></summary>
<summary>Source code in <code>{{ init|source_location }}</code></summary>
{{ init.source|highlight(language="python", linestart=init.lineno or 0, linenums=True) }}
</details>
{% endwith %}
{% endif %}
{% elif class.source %}
<details class="quote">
<summary>Source code in <code>
{%- if class.relative_filepath.is_absolute() -%}
{{ class.relative_package_filepath }}
{%- else -%}
{{ class.relative_filepath }}
{%- endif -%}
</code></summary>
<summary>Source code in <code>{{ class|source_location }}</code></summary>
{{ class.source|highlight(language="python", linestart=class.lineno or 0, linenums=True) }}
</details>
{% endif %}
Expand Down
75 changes: 75 additions & 0 deletions tests/test_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from textwrap import dedent
from typing import TYPE_CHECKING

import bs4
import mkdocstrings
import pytest
from griffe import (
Expand Down Expand Up @@ -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
Loading