Skip to content

Commit f3917e9

Browse files
committed
fix: Prevent infinite recursion by detecting parent-member cycles
Issue-griffe-368: mkdocstrings/griffe#368
1 parent bfb5b30 commit f3917e9

File tree

3 files changed

+58
-4
lines changed

3 files changed

+58
-4
lines changed

src/mkdocstrings_handlers/python/_internal/rendering.py

+32-3
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818

1919
from griffe import (
2020
Alias,
21+
AliasResolutionError,
22+
CyclicAliasError,
2123
DocstringAttribute,
2224
DocstringClass,
2325
DocstringFunction,
@@ -411,6 +413,29 @@ def _keep_object(name: str, filters: Sequence[tuple[Pattern, bool]]) -> bool:
411413
return keep
412414

413415

416+
def _parents(obj: Alias) -> set[str]:
417+
parent: Object | Alias = obj.parent # type: ignore[assignment]
418+
parents = {obj.path, parent.path}
419+
if parent.is_alias:
420+
parents.add(parent.final_target.path) # type: ignore[union-attr]
421+
while parent.parent:
422+
parent = parent.parent
423+
parents.add(parent.path)
424+
if parent.is_alias:
425+
parents.add(parent.final_target.path) # type: ignore[union-attr]
426+
return parents
427+
428+
429+
def _remove_cycles(objects: list[Object | Alias]) -> Iterator[Object | Alias]:
430+
suppress_errors = suppress(AliasResolutionError, CyclicAliasError)
431+
for obj in objects:
432+
if obj.is_alias:
433+
with suppress_errors:
434+
if obj.final_target.path in _parents(obj): # type: ignore[arg-type,union-attr]
435+
continue
436+
yield obj
437+
438+
414439
def do_filter_objects(
415440
objects_dictionary: dict[str, Object | Alias],
416441
*,
@@ -470,10 +495,14 @@ def do_filter_objects(
470495
objects = [
471496
obj for obj in objects if _keep_object(obj.name, filters) or (inherited_members_specified and obj.inherited)
472497
]
473-
if keep_no_docstrings:
474-
return objects
498+
if not keep_no_docstrings:
499+
objects = [obj for obj in objects if obj.has_docstrings or (inherited_members_specified and obj.inherited)]
500+
501+
# Prevent infinite recursion.
502+
if objects:
503+
objects = list(_remove_cycles(objects))
475504

476-
return [obj for obj in objects if obj.has_docstrings or (inherited_members_specified and obj.inherited)]
505+
return objects
477506

478507

479508
@lru_cache(maxsize=1)

tests/test_handler.py

+24-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@
1010
from typing import TYPE_CHECKING
1111

1212
import pytest
13-
from griffe import Docstring, DocstringSectionExamples, DocstringSectionKind, Module, temporary_visited_module
13+
from griffe import (
14+
Docstring,
15+
DocstringSectionExamples,
16+
DocstringSectionKind,
17+
Module,
18+
temporary_inspected_module,
19+
temporary_visited_module,
20+
)
1421
from mkdocstrings import CollectionError
1522

1623
from mkdocstrings_handlers.python import PythonConfig, PythonHandler, PythonOptions
@@ -275,3 +282,19 @@ def test_deduplicate_summary_sections(handler: PythonHandler, section: str, code
275282
),
276283
)
277284
assert html.count(f"{section}:") == 1
285+
286+
287+
def test_inheriting_self_from_parent_class(handler: PythonHandler) -> None:
288+
"""Inspect self only once when inheriting it from parent class."""
289+
with temporary_inspected_module(
290+
"""
291+
class A: ...
292+
class B(A): ...
293+
A.B = B
294+
""",
295+
) as module:
296+
# Assert no recusrion error.
297+
handler.render(
298+
module,
299+
handler.get_options({"inherited_members": True}),
300+
)

tests/test_rendering.py

+2
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ def test_format_signature(name: Markup, signature: str) -> None:
5858
class _FakeObject:
5959
name: str
6060
inherited: bool = False
61+
parent: None = None
62+
is_alias: bool = False
6163

6264

6365
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)