Skip to content

Commit e998137

Browse files
committed
Handle circular references while pretty printing
Fixes #157 Assisted-by: OpenAI GPT-6 (Codex)
1 parent bdf2bc0 commit e998137

3 files changed

Lines changed: 121 additions & 0 deletions

File tree

‎devtools/prettier.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ def __init__(
6767
yield_from_generators: bool = True,
6868
):
6969
self._stream = io.StringIO()
70+
self._active_ids: set[int] = set()
7071
self._indent_step = indent_step
7172
self._c = indent_char
7273
self._repr_strings = repr_strings
@@ -108,6 +109,18 @@ def _format(self, value: Any, indent_current: int, indent_first: bool) -> None:
108109
if indent_first:
109110
self._stream.write(indent_current * self._c)
110111

112+
value_id = id(value)
113+
if value_id in self._active_ids:
114+
self._stream.write(f'<Recursion on {type(value).__name__}>')
115+
return
116+
117+
self._active_ids.add(value_id)
118+
try:
119+
self._format_value(value, indent_current)
120+
finally:
121+
self._active_ids.remove(value_id)
122+
123+
def _format_value(self, value: Any, indent_current: int) -> None:
111124
try:
112125
pretty_func = value.__pretty__
113126
except AttributeError:

‎docs/usage.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ in `debug()`, but it can also be used directly:
7878

7979
--8<-- "examples/prettier.html"
8080

81+
Circular references encountered while formatting containers, object fields or `__pretty__` values
82+
are displayed as `<Recursion on TYPE>` rather than repeatedly expanding the same object. Other fields
83+
are still shown, and a shared object that appears in separate branches is formatted normally each time.
84+
8185
For more details on prettier printing, see
8286
[`prettier.py`](https://github.com/samuelcolvin/python-devtools/blob/main/devtools/prettier.py).
8387

‎tests/test_recursion.py‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
from collections.abc import Callable, Iterator
2+
from dataclasses import dataclass
3+
from typing import Any
4+
5+
import pytest
6+
7+
from devtools import Debug
8+
from devtools.prettier import PrettyFormat, pformat
9+
10+
11+
def test_recursive_dict():
12+
value: dict[str, Any] = {'before': 1}
13+
value['self'] = value
14+
value['after'] = 2
15+
expected = "{\n 'before': 1,\n 'self': <Recursion on dict>,\n 'after': 2,\n}"
16+
assert pformat(value) == expected
17+
assert pformat(value) == expected
18+
19+
20+
@pytest.mark.parametrize('cutoff', [0, 10])
21+
def test_recursive_list(cutoff: int):
22+
value: list[Any] = [1, 2]
23+
value.append(value)
24+
assert PrettyFormat(simple_cutoff=cutoff)(value) == '[\n 1,\n 2,\n <Recursion on list>,\n]'
25+
26+
27+
def test_mutually_recursive_containers():
28+
value: dict[str, Any] = {}
29+
value['child'] = [value, 1]
30+
assert pformat(value) == "{\n 'child': [\n <Recursion on dict>,\n 1,\n ],\n}"
31+
32+
33+
@pytest.mark.parametrize('slots', [False, True])
34+
def test_recursive_dataclass(slots: bool):
35+
@dataclass(slots=slots)
36+
class Node:
37+
child: Any = None
38+
39+
value = Node()
40+
value.child = value
41+
assert pformat(value) == 'Node(\n child=<Recursion on Node>,\n)'
42+
43+
44+
def test_recursive_pretty():
45+
class Pretty:
46+
def __pretty__(self, fmt: Callable[[Any], Any], skip_exc: type[Exception]) -> Iterator[Any]:
47+
yield 'Pretty('
48+
yield fmt(self)
49+
yield ')'
50+
51+
assert pformat(Pretty()) == 'Pretty(<Recursion on Pretty>)'
52+
53+
54+
def test_recursive_generator():
55+
def generate() -> Iterator[Any]:
56+
yield value
57+
yield 1
58+
59+
value = generate()
60+
assert pformat(value) == '(\n <Recursion on generator>,\n 1,\n)'
61+
62+
63+
def test_recursive_reentrant_repr():
64+
formatter = PrettyFormat()
65+
66+
class Reentrant:
67+
def __repr__(self) -> str:
68+
return formatter(self)
69+
70+
assert formatter(Reentrant()) == '<Recursion on Reentrant>'
71+
assert formatter(1) == '1'
72+
73+
74+
def test_shared_child_is_not_recursive():
75+
child = {'value': 1}
76+
assert PrettyFormat(simple_cutoff=0)([child, child]) == (
77+
"[\n {\n 'value': 1,\n },\n {\n 'value': 1,\n },\n]"
78+
)
79+
80+
81+
def test_formatting_error_does_not_mark_value_as_recursive():
82+
formatter = PrettyFormat()
83+
84+
class Broken:
85+
fail = True
86+
87+
def __repr__(self) -> str:
88+
if self.fail:
89+
raise ValueError('broken repr')
90+
return 'recovered'
91+
92+
value = Broken()
93+
with pytest.raises(ValueError, match='broken repr'):
94+
formatter(value)
95+
value.fail = False
96+
assert formatter(value) == 'recovered'
97+
98+
99+
def test_debug_recursive_value():
100+
value: dict[str, Any] = {'before': 1}
101+
value['self'] = value
102+
output = str(Debug(highlight=False).format(value))
103+
assert "'before': 1" in output
104+
assert "'self': <Recursion on dict>" in output

0 commit comments

Comments
 (0)