Skip to content

Commit 8f45cdc

Browse files
authored
fix: avoid RecursionError when formatting objects with __getattr__ traps (#9497)
`pandas.api.typing.Expression` returns a new `Expression` from `__getattr__` for any attribute name, so the formatter's `hasattr`-based protocol detection matched `_display_` and recursed indefinitely. `is_callable_method` now gates on `inspect.getattr_static` so only methods truly defined on the class are treated as protocol methods. Fixes #9494.
1 parent 5838cf2 commit 8f45cdc

2 files changed

Lines changed: 47 additions & 4 deletions

File tree

marimo/_utils/methods.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,33 @@
88

99

1010
def is_callable_method(obj: Any, attr: str) -> bool:
11-
"""Check if an attribute is callable on an object."""
12-
if not hasattr(obj, attr):
11+
"""Check if an attribute is a real callable method on an object.
12+
13+
Uses ``inspect.getattr_static`` so that attributes synthesized by
14+
``__getattr__`` (e.g. ``pandas.api.typing.Expression``, which returns a
15+
new ``Expression`` for *any* attribute name) are not treated as protocol
16+
methods. Without this, dynamic-attribute objects appear to implement
17+
``_display_`` / ``_mime_`` / ``_repr_*_`` and trigger infinite recursion
18+
in the formatter.
19+
"""
20+
# Use getattr_static first so that we don't trigger __getattr__ traps
21+
# (e.g. pandas Expression returns a new Expression for any attribute).
22+
# Note this is a static lookup that also matches instance attributes —
23+
# it just bypasses __getattr__ / __getattribute__.
24+
try:
25+
inspect.getattr_static(obj, attr)
26+
except AttributeError:
27+
return False
28+
29+
# Resolve the actual value so properties/descriptors still work. Swallow
30+
# any exception (e.g. property getters that raise) so protocol detection
31+
# stays robust and never crashes formatting.
32+
try:
33+
method = getattr(obj, attr)
34+
except Exception:
1335
return False
1436

15-
method = getattr(obj, attr)
16-
if inspect.isclass(obj) and not isinstance(method, (types.MethodType)):
37+
if inspect.isclass(obj) and not isinstance(method, types.MethodType):
1738
return False
1839
return callable(method)
1940

tests/_output/test_try_format.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,28 @@ def test_display_protocol():
131131
assert result.data == "<p>mime test</p>"
132132

133133

134+
def test_getattr_trap_does_not_recurse():
135+
"""Objects whose __getattr__ returns same-type objects (e.g.
136+
pandas.api.typing.Expression) must not trigger infinite recursion in the
137+
formatter via the _display_ / _mime_ / _repr_*_ protocols.
138+
Regression test for https://github.com/marimo-team/marimo/issues/9494.
139+
"""
140+
141+
class GetattrTrap:
142+
def __getattr__(self, name: str) -> GetattrTrap:
143+
if name.startswith("__"):
144+
raise AttributeError(name)
145+
return GetattrTrap()
146+
147+
def __repr__(self) -> str:
148+
return "GetattrTrap()"
149+
150+
result = try_format(GetattrTrap())
151+
assert result.exception is None
152+
assert result.traceback is None
153+
assert "GetattrTrap()" in result.data
154+
155+
134156
def test_error_handling():
135157
"""Test error handling during formatting."""
136158
obj = ErrorObject("test")

0 commit comments

Comments
 (0)