Skip to content

Commit 84ba73e

Browse files
authored
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: #333 PR-338: #338
1 parent a025f49 commit 84ba73e

7 files changed

Lines changed: 108 additions & 35 deletions

File tree

src/mkdocstrings_handlers/python/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
do_format_type_alias,
3232
do_get_template,
3333
do_order_members,
34+
do_source_location,
3435
do_split_path,
3536
do_stash_crossref,
3637
)
@@ -64,6 +65,7 @@
6465
"do_format_type_alias",
6566
"do_get_template",
6667
"do_order_members",
68+
"do_source_location",
6769
"do_split_path",
6870
"do_stash_crossref",
6971
"get_handler",

src/mkdocstrings_handlers/python/_internal/handler.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,7 @@ def update_env(self, config: Any) -> None: # noqa: ARG002
345345
self.env.filters["filter_objects"] = rendering.do_filter_objects
346346
self.env.filters["stash_crossref"] = rendering.do_stash_crossref
347347
self.env.filters["get_template"] = rendering.do_get_template
348+
self.env.filters["source_location"] = rendering.do_source_location
348349
self.env.filters["as_attributes_section"] = rendering.do_as_attributes_section
349350
self.env.filters["as_functions_section"] = rendering.do_as_functions_section
350351
self.env.filters["as_classes_section"] = rendering.do_as_classes_section

src/mkdocstrings_handlers/python/_internal/rendering.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from contextlib import suppress
1212
from dataclasses import replace
1313
from functools import lru_cache
14+
from pathlib import Path
1415
from re import Pattern
1516
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Literal, TypeVar
1617

@@ -590,6 +591,30 @@ def do_get_template(obj: Object | Alias) -> str:
590591
return f"{name}.html.jinja"
591592

592593

594+
def do_source_location(obj: Object | Alias) -> Path:
595+
"""Get the file path displayed in an object's source block label.
596+
597+
Environment paths are never displayed: when the object's file lives in
598+
a `site-packages` directory (for example a virtual environment inside
599+
the current working directory), the path below `site-packages` is
600+
returned instead.
601+
602+
Parameters:
603+
obj: A Griffe object.
604+
605+
Returns:
606+
The file path to display.
607+
"""
608+
relative_filepath = obj.relative_filepath
609+
parts = relative_filepath.parts
610+
if "site-packages" in parts:
611+
anchor = len(parts) - 1 - parts[::-1].index("site-packages")
612+
return Path(*parts[anchor + 1 :])
613+
if relative_filepath.is_absolute():
614+
return obj.relative_package_filepath
615+
return relative_filepath
616+
617+
593618
@pass_context
594619
def do_as_attributes_section(
595620
context: Context, # noqa: ARG001

src/mkdocstrings_handlers/python/templates/material/_base/class.html.jinja

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -249,26 +249,14 @@ Context:
249249
{% if "__init__" in all_members and all_members["__init__"].source %}
250250
{% with init = all_members["__init__"] %}
251251
<details class="mkdocstrings-source">
252-
<summary>{{ lang.t("Source code in") }} <code>
253-
{%- if init.relative_filepath.is_absolute() -%}
254-
{{ init.relative_package_filepath }}
255-
{%- else -%}
256-
{{ init.relative_filepath }}
257-
{%- endif -%}
258-
</code></summary>
252+
<summary>{{ lang.t("Source code in") }} <code>{{ init|source_location }}</code></summary>
259253
{{ init.source|highlight(language="python", linestart=init.lineno or 0, linenums=True) }}
260254
</details>
261255
{% endwith %}
262256
{% endif %}
263257
{% elif class.source %}
264258
<details class="mkdocstrings-source">
265-
<summary>{{ lang.t("Source code in") }} <code>
266-
{%- if class.relative_filepath.is_absolute() -%}
267-
{{ class.relative_package_filepath }}
268-
{%- else -%}
269-
{{ class.relative_filepath }}
270-
{%- endif -%}
271-
</code></summary>
259+
<summary>{{ lang.t("Source code in") }} <code>{{ class|source_location }}</code></summary>
272260
{{ class.source|highlight(language="python", linestart=class.lineno or 0, linenums=True) }}
273261
</details>
274262
{% endif %}

src/mkdocstrings_handlers/python/templates/material/_base/function.html.jinja

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -146,13 +146,7 @@ Context:
146146
-#}
147147
{% if config.show_source and function.source %}
148148
<details class="mkdocstrings-source">
149-
<summary>{{ lang.t("Source code in") }} <code>
150-
{%- if function.relative_filepath.is_absolute() -%}
151-
{{ function.relative_package_filepath }}
152-
{%- else -%}
153-
{{ function.relative_filepath }}
154-
{%- endif -%}
155-
</code></summary>
149+
<summary>{{ lang.t("Source code in") }} <code>{{ function|source_location }}</code></summary>
156150
{{ function.source|highlight(language="python", linestart=function.lineno or 0, linenums=True) }}
157151
</details>
158152
{% endif %}

src/mkdocstrings_handlers/python/templates/readthedocs/_base/class.html.jinja

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -208,26 +208,14 @@ Context:
208208
{% if "__init__" in class.all_members and class.all_members["__init__"].source %}
209209
{% with init = class.all_members["__init__"] %}
210210
<details class="quote">
211-
<summary>Source code in <code>
212-
{%- if init.relative_filepath.is_absolute() -%}
213-
{{ init.relative_package_filepath }}
214-
{%- else -%}
215-
{{ init.relative_filepath }}
216-
{%- endif -%}
217-
</code></summary>
211+
<summary>Source code in <code>{{ init|source_location }}</code></summary>
218212
{{ init.source|highlight(language="python", linestart=init.lineno or 0, linenums=True) }}
219213
</details>
220214
{% endwith %}
221215
{% endif %}
222216
{% elif class.source %}
223217
<details class="quote">
224-
<summary>Source code in <code>
225-
{%- if class.relative_filepath.is_absolute() -%}
226-
{{ class.relative_package_filepath }}
227-
{%- else -%}
228-
{{ class.relative_filepath }}
229-
{%- endif -%}
230-
</code></summary>
218+
<summary>Source code in <code>{{ class|source_location }}</code></summary>
231219
{{ class.source|highlight(language="python", linestart=class.lineno or 0, linenums=True) }}
232220
</details>
233221
{% endif %}

tests/test_handler.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from textwrap import dedent
1212
from typing import TYPE_CHECKING
1313

14+
import bs4
1415
import mkdocstrings
1516
import pytest
1617
from griffe import (
@@ -333,3 +334,77 @@ def test_specifying_inventory_base_url(handler: PythonHandler) -> None:
333334
# Assert the URL is based on the provided base URL
334335
msg = "Expected inventory URL to start with base_url"
335336
assert item_url.startswith(base_url), msg
337+
338+
339+
def _source_labels(html: str) -> list[Path]:
340+
soup = bs4.BeautifulSoup(html, features="html.parser")
341+
labels = []
342+
for summary in soup.find_all("summary"):
343+
if "Source code in" in summary.get_text():
344+
code_tag = summary.find("code")
345+
assert code_tag is not None
346+
labels.append(Path(code_tag.get_text(strip=True)))
347+
return labels
348+
349+
350+
def _write_site_packages_package(tmp_path: Path, *, single_module: bool) -> None:
351+
"""Lay out the issue-333 scenario: a package installed in a virtual environment inside the project.
352+
353+
The environment's `site-packages` directory is relative to the current
354+
working directory, so the package's `relative_filepath` is relative too,
355+
slipping past `is_absolute()` checks.
356+
"""
357+
code = """
358+
class Model:
359+
'''Model docstring.'''
360+
361+
def __init__(self) -> None:
362+
'''Init docstring.'''
363+
self.model_attribute = 0
364+
365+
def method(self) -> None:
366+
'''Method docstring.'''
367+
"""
368+
site = tmp_path / "site-packages"
369+
module_path = site / "pkg.py" if single_module else site / "pkg" / "__init__.py"
370+
module_path.parent.mkdir(parents=True)
371+
module_path.write_text(dedent(code), encoding="utf-8")
372+
373+
374+
@pytest.mark.parametrize(
375+
"handler",
376+
[
377+
{"theme": "readthedocs"},
378+
{"theme": {"name": "material"}},
379+
],
380+
indirect=["handler"],
381+
)
382+
@pytest.mark.parametrize(
383+
("single_module", "extra_options", "expected_label"),
384+
[
385+
pytest.param(False, {"merge_init_into_class": True}, Path("pkg", "__init__.py"), id="merged-init"),
386+
pytest.param(False, {}, Path("pkg", "__init__.py"), id="class-and-methods"),
387+
pytest.param(True, {"merge_init_into_class": True}, Path("pkg.py"), id="single-module"),
388+
],
389+
)
390+
def test_no_environment_path_in_source_labels(
391+
*,
392+
tmp_path: Path,
393+
monkeypatch: pytest.MonkeyPatch,
394+
handler: PythonHandler,
395+
single_module: bool,
396+
extra_options: dict,
397+
expected_label: Path,
398+
) -> None:
399+
"""Assert source labels never show an environment path."""
400+
_write_site_packages_package(tmp_path, single_module=single_module)
401+
monkeypatch.chdir(tmp_path)
402+
# `collect()` reads the search paths lazily from this attribute.
403+
handler._paths = [str(tmp_path / "site-packages")]
404+
options = handler.get_options({"show_source": True, **extra_options})
405+
html = handler.render(handler.collect("pkg.Model", options), options)
406+
labels = _source_labels(html)
407+
assert labels
408+
assert set(labels) == {expected_label}
409+
# The source bodies themselves are still rendered.
410+
assert "model_attribute" in html

0 commit comments

Comments
 (0)