Skip to content

Commit 181fcc9

Browse files
cdwilsonclaude
andcommitted
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: mkdocstrings#333 Assisted-by: Claude:claude-fable-5 Co-Authored-By: Claude Fable 5 <[email protected]>
1 parent 6ff2749 commit 181fcc9

7 files changed

Lines changed: 148 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
@@ -310,6 +310,7 @@ def update_env(self, config: Any) -> None: # noqa: ARG002
310310
self.env.filters["filter_objects"] = rendering.do_filter_objects
311311
self.env.filters["stash_crossref"] = rendering.do_stash_crossref
312312
self.env.filters["get_template"] = rendering.do_get_template
313+
self.env.filters["source_location"] = rendering.do_source_location
313314
self.env.filters["as_attributes_section"] = rendering.do_as_attributes_section
314315
self.env.filters["as_functions_section"] = rendering.do_as_functions_section
315316
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

@@ -592,6 +593,30 @@ def do_get_template(obj: Object | Alias) -> str:
592593
return f"{name}.html.jinja"
593594

594595

596+
def do_source_location(obj: Object | Alias) -> Path:
597+
"""Get the file path displayed in an object's source block label.
598+
599+
Environment paths are never displayed: when the object's file lives in
600+
a `site-packages` directory (for example a virtual environment inside
601+
the current working directory), the path below `site-packages` is
602+
returned instead.
603+
604+
Parameters:
605+
obj: A Griffe object.
606+
607+
Returns:
608+
The file path to display.
609+
"""
610+
relative_filepath = obj.relative_filepath
611+
parts = relative_filepath.parts
612+
if "site-packages" in parts:
613+
anchor = len(parts) - 1 - parts[::-1].index("site-packages")
614+
return Path(*parts[anchor + 1 :])
615+
if relative_filepath.is_absolute():
616+
return obj.relative_package_filepath
617+
return relative_filepath
618+
619+
595620
@pass_context
596621
def do_as_attributes_section(
597622
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: 115 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,117 @@ 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+
_BASE_CLASS = """
351+
class Base:
352+
'''Base docstring.'''
353+
354+
def __init__(self) -> None:
355+
'''Init docstring.'''
356+
self.base_attribute = 0
357+
358+
def base_method(self) -> None:
359+
'''Method docstring.'''
360+
"""
361+
362+
363+
def _write_venv_project(tmp_path: Path, *, single_module: bool) -> None:
364+
"""Lay out the issue-333 scenario: an in-project virtual environment.
365+
366+
A `Base` class is "installed" in a `site-packages` directory relative to the
367+
current working directory, and the documented package inherits from it.
368+
"""
369+
site_packages = tmp_path / "site-packages"
370+
if single_module:
371+
site_packages.mkdir(parents=True)
372+
(site_packages / "basemod.py").write_text(dedent(_BASE_CLASS))
373+
base_module = "basemod"
374+
else:
375+
base_pkg = site_packages / "basepkg"
376+
base_pkg.mkdir(parents=True)
377+
(base_pkg / "__init__.py").write_text(dedent(_BASE_CLASS))
378+
base_module = "basepkg"
379+
pkg = tmp_path / "pkg"
380+
pkg.mkdir()
381+
(pkg / "__init__.py").write_text(
382+
dedent(
383+
f"""
384+
from {base_module} import Base
385+
386+
class Model(Base):
387+
'''Model docstring.'''
388+
""",
389+
),
390+
)
391+
392+
393+
@pytest.mark.parametrize(
394+
"handler",
395+
[
396+
{"theme": "readthedocs"},
397+
{"theme": {"name": "material"}},
398+
],
399+
indirect=["handler"],
400+
)
401+
@pytest.mark.parametrize(
402+
("single_module", "identifier", "extra_options", "expected_label"),
403+
[
404+
pytest.param(
405+
False,
406+
"pkg.Model",
407+
{"merge_init_into_class": True},
408+
Path("basepkg", "__init__.py"),
409+
id="merged-init",
410+
),
411+
pytest.param(
412+
False,
413+
"pkg.Model",
414+
{"merge_init_into_class": True, "inherited_members": True},
415+
Path("basepkg", "__init__.py"),
416+
id="inherited-method",
417+
),
418+
pytest.param(False, "basepkg.Base", {}, Path("basepkg", "__init__.py"), id="class-and-methods"),
419+
pytest.param(True, "pkg.Model", {"merge_init_into_class": True}, Path("basemod.py"), id="single-module"),
420+
],
421+
)
422+
def test_no_environment_path_in_source_labels(
423+
*,
424+
tmp_path: Path,
425+
monkeypatch: pytest.MonkeyPatch,
426+
handler: PythonHandler,
427+
single_module: bool,
428+
identifier: str,
429+
extra_options: dict,
430+
expected_label: Path,
431+
) -> None:
432+
"""Assert source labels never show an environment path, whatever renders them."""
433+
_write_venv_project(tmp_path, single_module=single_module)
434+
monkeypatch.chdir(tmp_path)
435+
# `collect()` reads the search paths lazily from this attribute.
436+
handler._paths = [str(tmp_path), str(tmp_path / "site-packages")]
437+
base_module = "basemod" if single_module else "basepkg"
438+
options = handler.get_options(
439+
{
440+
"preload_modules": [base_module],
441+
"show_source": True,
442+
**extra_options,
443+
},
444+
)
445+
html = handler.render(handler.collect(identifier, options), options)
446+
labels = _source_labels(html)
447+
assert labels
448+
assert set(labels) == {expected_label}
449+
# The init source body is still rendered.
450+
assert "base_attribute" in html

0 commit comments

Comments
 (0)