Skip to content

Commit 539a4ca

Browse files
authored
gh-137967: Restore suggestions on nested attribute access (#137968)
1 parent 339f5da commit 539a4ca

File tree

4 files changed

+257
-1
lines changed

4 files changed

+257
-1
lines changed

Doc/whatsnew/3.15.rst

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,45 @@ production systems where traditional profiling approaches would be too intrusive
169169
(Contributed by Pablo Galindo and László Kiss Kollár in :gh:`135953`.)
170170

171171

172+
Improved error messages
173+
-----------------------
174+
175+
* The interpreter now provides more helpful suggestions in :exc:`AttributeError`
176+
exceptions when accessing an attribute on an object that does not exist, but
177+
a similar attribute is available through one of its members.
178+
179+
For example, if the object has an attribute that itself exposes the requested
180+
name, the error message will suggest accessing it via that inner attribute:
181+
182+
.. code-block:: python
183+
184+
@dataclass
185+
class Circle:
186+
radius: float
187+
188+
@property
189+
def area(self) -> float:
190+
return pi * self.radius**2
191+
192+
class Container:
193+
def __init__(self, inner: Any) -> None:
194+
self.inner = inner
195+
196+
square = Square(side=4)
197+
container = Container(square)
198+
print(container.area)
199+
200+
Running this code now produces a clearer suggestion:
201+
202+
.. code-block:: pycon
203+
204+
Traceback (most recent call last):
205+
File "/home/pablogsal/github/python/main/lel.py", line 42, in <module>
206+
print(container.area)
207+
^^^^^^^^^^^^^^
208+
AttributeError: 'Container' object has no attribute 'area'. Did you mean: 'inner.area'?
209+
210+
172211
Other language changes
173212
======================
174213

Lib/test/test_traceback.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4262,6 +4262,184 @@ def __getattribute__(self, attr):
42624262
self.assertIn("Did you mean", actual)
42634263
self.assertIn("bluch", actual)
42644264

4265+
def test_getattr_nested_attribute_suggestions(self):
4266+
# Test that nested attributes are suggested when no direct match
4267+
class Inner:
4268+
def __init__(self):
4269+
self.value = 42
4270+
self.data = "test"
4271+
4272+
class Outer:
4273+
def __init__(self):
4274+
self.inner = Inner()
4275+
4276+
# Should suggest 'inner.value'
4277+
actual = self.get_suggestion(Outer(), 'value')
4278+
self.assertIn("Did you mean: 'inner.value'", actual)
4279+
4280+
# Should suggest 'inner.data'
4281+
actual = self.get_suggestion(Outer(), 'data')
4282+
self.assertIn("Did you mean: 'inner.data'", actual)
4283+
4284+
def test_getattr_nested_prioritizes_direct_matches(self):
4285+
# Test that direct attribute matches are prioritized over nested ones
4286+
class Inner:
4287+
def __init__(self):
4288+
self.foo = 42
4289+
4290+
class Outer:
4291+
def __init__(self):
4292+
self.inner = Inner()
4293+
self.fooo = 100 # Similar to 'foo'
4294+
4295+
# Should suggest 'fooo' (direct) not 'inner.foo' (nested)
4296+
actual = self.get_suggestion(Outer(), 'foo')
4297+
self.assertIn("Did you mean: 'fooo'", actual)
4298+
self.assertNotIn("inner.foo", actual)
4299+
4300+
def test_getattr_nested_with_property(self):
4301+
# Test that descriptors (including properties) are suggested in nested attributes
4302+
class Inner:
4303+
@property
4304+
def computed(self):
4305+
return 42
4306+
4307+
class Outer:
4308+
def __init__(self):
4309+
self.inner = Inner()
4310+
4311+
actual = self.get_suggestion(Outer(), 'computed')
4312+
# Descriptors should not be suggested to avoid executing arbitrary code
4313+
self.assertIn("inner.computed", actual)
4314+
4315+
def test_getattr_nested_no_suggestion_for_deep_nesting(self):
4316+
# Test that deeply nested attributes (2+ levels) are not suggested
4317+
class Deep:
4318+
def __init__(self):
4319+
self.value = 42
4320+
4321+
class Middle:
4322+
def __init__(self):
4323+
self.deep = Deep()
4324+
4325+
class Outer:
4326+
def __init__(self):
4327+
self.middle = Middle()
4328+
4329+
# Should not suggest 'middle.deep.value' (too deep)
4330+
actual = self.get_suggestion(Outer(), 'value')
4331+
self.assertNotIn("Did you mean", actual)
4332+
4333+
def test_getattr_nested_ignores_private_attributes(self):
4334+
# Test that nested suggestions ignore private attributes
4335+
class Inner:
4336+
def __init__(self):
4337+
self.public_value = 42
4338+
4339+
class Outer:
4340+
def __init__(self):
4341+
self._private_inner = Inner()
4342+
4343+
# Should not suggest '_private_inner.public_value'
4344+
actual = self.get_suggestion(Outer(), 'public_value')
4345+
self.assertNotIn("Did you mean", actual)
4346+
4347+
def test_getattr_nested_limits_attribute_checks(self):
4348+
# Test that nested suggestions are limited to checking first 20 non-private attributes
4349+
class Inner:
4350+
def __init__(self):
4351+
self.target_value = 42
4352+
4353+
class Outer:
4354+
def __init__(self):
4355+
# Add many attributes before 'inner'
4356+
for i in range(25):
4357+
setattr(self, f'attr_{i:02d}', i)
4358+
# Add the inner object after 20+ attributes
4359+
self.inner = Inner()
4360+
4361+
obj = Outer()
4362+
# Verify that 'inner' is indeed present but after position 20
4363+
attrs = [x for x in sorted(dir(obj)) if not x.startswith('_')]
4364+
inner_position = attrs.index('inner')
4365+
self.assertGreater(inner_position, 19, "inner should be after position 20 in sorted attributes")
4366+
4367+
# Should not suggest 'inner.target_value' because inner is beyond the first 20 attributes checked
4368+
actual = self.get_suggestion(obj, 'target_value')
4369+
self.assertNotIn("inner.target_value", actual)
4370+
4371+
def test_getattr_nested_returns_first_match_only(self):
4372+
# Test that only the first nested match is returned (not multiple)
4373+
class Inner1:
4374+
def __init__(self):
4375+
self.value = 1
4376+
4377+
class Inner2:
4378+
def __init__(self):
4379+
self.value = 2
4380+
4381+
class Inner3:
4382+
def __init__(self):
4383+
self.value = 3
4384+
4385+
class Outer:
4386+
def __init__(self):
4387+
# Multiple inner objects with same attribute
4388+
self.a_inner = Inner1()
4389+
self.b_inner = Inner2()
4390+
self.c_inner = Inner3()
4391+
4392+
# Should suggest only the first match (alphabetically)
4393+
actual = self.get_suggestion(Outer(), 'value')
4394+
self.assertIn("'a_inner.value'", actual)
4395+
# Verify it's a single suggestion, not multiple
4396+
self.assertEqual(actual.count("Did you mean"), 1)
4397+
4398+
def test_getattr_nested_handles_attribute_access_exceptions(self):
4399+
# Test that exceptions raised when accessing attributes don't crash the suggestion system
4400+
class ExplodingProperty:
4401+
@property
4402+
def exploding_attr(self):
4403+
raise RuntimeError("BOOM! This property always explodes")
4404+
4405+
def __repr__(self):
4406+
raise RuntimeError("repr also explodes")
4407+
4408+
class SafeInner:
4409+
def __init__(self):
4410+
self.target = 42
4411+
4412+
class Outer:
4413+
def __init__(self):
4414+
self.exploder = ExplodingProperty() # Accessing attributes will raise
4415+
self.safe_inner = SafeInner()
4416+
4417+
# Should still suggest 'safe_inner.target' without crashing
4418+
# even though accessing exploder.target would raise an exception
4419+
actual = self.get_suggestion(Outer(), 'target')
4420+
self.assertIn("'safe_inner.target'", actual)
4421+
4422+
def test_getattr_nested_handles_hasattr_exceptions(self):
4423+
# Test that exceptions in hasattr don't crash the system
4424+
class WeirdObject:
4425+
def __getattr__(self, name):
4426+
if name == 'target':
4427+
raise RuntimeError("Can't check for target attribute")
4428+
raise AttributeError(f"No attribute {name}")
4429+
4430+
class NormalInner:
4431+
def __init__(self):
4432+
self.target = 100
4433+
4434+
class Outer:
4435+
def __init__(self):
4436+
self.weird = WeirdObject() # hasattr will raise for 'target'
4437+
self.normal = NormalInner()
4438+
4439+
# Should still find 'normal.target' even though weird.target check fails
4440+
actual = self.get_suggestion(Outer(), 'target')
4441+
self.assertIn("'normal.target'", actual)
4442+
42654443
def make_module(self, code):
42664444
tmpdir = Path(tempfile.mkdtemp())
42674445
self.addCleanup(shutil.rmtree, tmpdir)

Lib/traceback.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1601,6 +1601,34 @@ def _substitution_cost(ch_a, ch_b):
16011601
return _MOVE_COST
16021602

16031603

1604+
def _check_for_nested_attribute(obj, wrong_name, attrs):
1605+
"""Check if any attribute of obj has the wrong_name as a nested attribute.
1606+
1607+
Returns the first nested attribute suggestion found, or None.
1608+
Limited to checking 20 attributes.
1609+
Only considers non-descriptor attributes to avoid executing arbitrary code.
1610+
"""
1611+
# Check for nested attributes (only one level deep)
1612+
attrs_to_check = [x for x in attrs if not x.startswith('_')][:20] # Limit number of attributes to check
1613+
for attr_name in attrs_to_check:
1614+
with suppress(Exception):
1615+
# Check if attr_name is a descriptor - if so, skip it
1616+
attr_from_class = getattr(type(obj), attr_name, None)
1617+
if attr_from_class is not None and hasattr(attr_from_class, '__get__'):
1618+
continue # Skip descriptors to avoid executing arbitrary code
1619+
1620+
# Safe to get the attribute since it's not a descriptor
1621+
attr_obj = getattr(obj, attr_name)
1622+
1623+
# Check if the nested attribute exists and is not a descriptor
1624+
nested_attr_from_class = getattr(type(attr_obj), wrong_name, None)
1625+
1626+
if hasattr(attr_obj, wrong_name):
1627+
return f"{attr_name}.{wrong_name}"
1628+
1629+
return None
1630+
1631+
16041632
def _compute_suggestion_error(exc_value, tb, wrong_name):
16051633
if wrong_name is None or not isinstance(wrong_name, str):
16061634
return None
@@ -1666,7 +1694,9 @@ def _compute_suggestion_error(exc_value, tb, wrong_name):
16661694
except ImportError:
16671695
pass
16681696
else:
1669-
return _suggestions._generate_suggestions(d, wrong_name)
1697+
suggestion = _suggestions._generate_suggestions(d, wrong_name)
1698+
if suggestion:
1699+
return suggestion
16701700

16711701
# Compute closest match
16721702

@@ -1691,6 +1721,14 @@ def _compute_suggestion_error(exc_value, tb, wrong_name):
16911721
if not suggestion or current_distance < best_distance:
16921722
suggestion = possible_name
16931723
best_distance = current_distance
1724+
1725+
# If no direct attribute match found, check for nested attributes
1726+
if not suggestion and isinstance(exc_value, AttributeError):
1727+
with suppress(Exception):
1728+
nested_suggestion = _check_for_nested_attribute(exc_value.obj, wrong_name, d)
1729+
if nested_suggestion:
1730+
return nested_suggestion
1731+
16941732
return suggestion
16951733

16961734

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Show error suggestions on nested attribute access. Patch by Pablo Galindo

0 commit comments

Comments
 (0)