(Contributed by Pablo Galindo and László Kiss Kollár in :gh:`135953`.)
+Improved error messages
+-----------------------
+
+* The interpreter now provides more helpful suggestions in :exc:`AttributeError`
+ exceptions when accessing an attribute on an object that does not exist, but
+ a similar attribute is available through one of its members.
+
+ For example, if the object has an attribute that itself exposes the requested
+ name, the error message will suggest accessing it via that inner attribute:
+
+ .. code-block:: python
+
+ @dataclass
+ class Circle:
+ radius: float
+
+ @property
+ def area(self) -> float:
+ return pi * self.radius**2
+
+ class Container:
+ def __init__(self, inner: Any) -> None:
+ self.inner = inner
+
+ square = Square(side=4)
+ container = Container(square)
+ print(container.area)
+
+ Running this code now produces a clearer suggestion:
+
+ .. code-block:: pycon
+
+ Traceback (most recent call last):
+ File "/home/pablogsal/github/python/main/lel.py", line 42, in <module>
+ print(container.area)
+ ^^^^^^^^^^^^^^
+ AttributeError: 'Container' object has no attribute 'area'. Did you mean: 'inner.area'?
+
+
Other language changes
======================
self.assertIn("Did you mean", actual)
self.assertIn("bluch", actual)
+ def test_getattr_nested_attribute_suggestions(self):
+ # Test that nested attributes are suggested when no direct match
+ class Inner:
+ def __init__(self):
+ self.value = 42
+ self.data = "test"
+
+ class Outer:
+ def __init__(self):
+ self.inner = Inner()
+
+ # Should suggest 'inner.value'
+ actual = self.get_suggestion(Outer(), 'value')
+ self.assertIn("Did you mean: 'inner.value'", actual)
+
+ # Should suggest 'inner.data'
+ actual = self.get_suggestion(Outer(), 'data')
+ self.assertIn("Did you mean: 'inner.data'", actual)
+
+ def test_getattr_nested_prioritizes_direct_matches(self):
+ # Test that direct attribute matches are prioritized over nested ones
+ class Inner:
+ def __init__(self):
+ self.foo = 42
+
+ class Outer:
+ def __init__(self):
+ self.inner = Inner()
+ self.fooo = 100 # Similar to 'foo'
+
+ # Should suggest 'fooo' (direct) not 'inner.foo' (nested)
+ actual = self.get_suggestion(Outer(), 'foo')
+ self.assertIn("Did you mean: 'fooo'", actual)
+ self.assertNotIn("inner.foo", actual)
+
+ def test_getattr_nested_with_property(self):
+ # Test that descriptors (including properties) are suggested in nested attributes
+ class Inner:
+ @property
+ def computed(self):
+ return 42
+
+ class Outer:
+ def __init__(self):
+ self.inner = Inner()
+
+ actual = self.get_suggestion(Outer(), 'computed')
+ # Descriptors should not be suggested to avoid executing arbitrary code
+ self.assertIn("inner.computed", actual)
+
+ def test_getattr_nested_no_suggestion_for_deep_nesting(self):
+ # Test that deeply nested attributes (2+ levels) are not suggested
+ class Deep:
+ def __init__(self):
+ self.value = 42
+
+ class Middle:
+ def __init__(self):
+ self.deep = Deep()
+
+ class Outer:
+ def __init__(self):
+ self.middle = Middle()
+
+ # Should not suggest 'middle.deep.value' (too deep)
+ actual = self.get_suggestion(Outer(), 'value')
+ self.assertNotIn("Did you mean", actual)
+
+ def test_getattr_nested_ignores_private_attributes(self):
+ # Test that nested suggestions ignore private attributes
+ class Inner:
+ def __init__(self):
+ self.public_value = 42
+
+ class Outer:
+ def __init__(self):
+ self._private_inner = Inner()
+
+ # Should not suggest '_private_inner.public_value'
+ actual = self.get_suggestion(Outer(), 'public_value')
+ self.assertNotIn("Did you mean", actual)
+
+ def test_getattr_nested_limits_attribute_checks(self):
+ # Test that nested suggestions are limited to checking first 20 non-private attributes
+ class Inner:
+ def __init__(self):
+ self.target_value = 42
+
+ class Outer:
+ def __init__(self):
+ # Add many attributes before 'inner'
+ for i in range(25):
+ setattr(self, f'attr_{i:02d}', i)
+ # Add the inner object after 20+ attributes
+ self.inner = Inner()
+
+ obj = Outer()
+ # Verify that 'inner' is indeed present but after position 20
+ attrs = [x for x in sorted(dir(obj)) if not x.startswith('_')]
+ inner_position = attrs.index('inner')
+ self.assertGreater(inner_position, 19, "inner should be after position 20 in sorted attributes")
+
+ # Should not suggest 'inner.target_value' because inner is beyond the first 20 attributes checked
+ actual = self.get_suggestion(obj, 'target_value')
+ self.assertNotIn("inner.target_value", actual)
+
+ def test_getattr_nested_returns_first_match_only(self):
+ # Test that only the first nested match is returned (not multiple)
+ class Inner1:
+ def __init__(self):
+ self.value = 1
+
+ class Inner2:
+ def __init__(self):
+ self.value = 2
+
+ class Inner3:
+ def __init__(self):
+ self.value = 3
+
+ class Outer:
+ def __init__(self):
+ # Multiple inner objects with same attribute
+ self.a_inner = Inner1()
+ self.b_inner = Inner2()
+ self.c_inner = Inner3()
+
+ # Should suggest only the first match (alphabetically)
+ actual = self.get_suggestion(Outer(), 'value')
+ self.assertIn("'a_inner.value'", actual)
+ # Verify it's a single suggestion, not multiple
+ self.assertEqual(actual.count("Did you mean"), 1)
+
+ def test_getattr_nested_handles_attribute_access_exceptions(self):
+ # Test that exceptions raised when accessing attributes don't crash the suggestion system
+ class ExplodingProperty:
+ @property
+ def exploding_attr(self):
+ raise RuntimeError("BOOM! This property always explodes")
+
+ def __repr__(self):
+ raise RuntimeError("repr also explodes")
+
+ class SafeInner:
+ def __init__(self):
+ self.target = 42
+
+ class Outer:
+ def __init__(self):
+ self.exploder = ExplodingProperty() # Accessing attributes will raise
+ self.safe_inner = SafeInner()
+
+ # Should still suggest 'safe_inner.target' without crashing
+ # even though accessing exploder.target would raise an exception
+ actual = self.get_suggestion(Outer(), 'target')
+ self.assertIn("'safe_inner.target'", actual)
+
+ def test_getattr_nested_handles_hasattr_exceptions(self):
+ # Test that exceptions in hasattr don't crash the system
+ class WeirdObject:
+ def __getattr__(self, name):
+ if name == 'target':
+ raise RuntimeError("Can't check for target attribute")
+ raise AttributeError(f"No attribute {name}")
+
+ class NormalInner:
+ def __init__(self):
+ self.target = 100
+
+ class Outer:
+ def __init__(self):
+ self.weird = WeirdObject() # hasattr will raise for 'target'
+ self.normal = NormalInner()
+
+ # Should still find 'normal.target' even though weird.target check fails
+ actual = self.get_suggestion(Outer(), 'target')
+ self.assertIn("'normal.target'", actual)
+
def make_module(self, code):
tmpdir = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, tmpdir)
return _MOVE_COST
+def _check_for_nested_attribute(obj, wrong_name, attrs):
+ """Check if any attribute of obj has the wrong_name as a nested attribute.
+
+ Returns the first nested attribute suggestion found, or None.
+ Limited to checking 20 attributes.
+ Only considers non-descriptor attributes to avoid executing arbitrary code.
+ """
+ # Check for nested attributes (only one level deep)
+ attrs_to_check = [x for x in attrs if not x.startswith('_')][:20] # Limit number of attributes to check
+ for attr_name in attrs_to_check:
+ with suppress(Exception):
+ # Check if attr_name is a descriptor - if so, skip it
+ attr_from_class = getattr(type(obj), attr_name, None)
+ if attr_from_class is not None and hasattr(attr_from_class, '__get__'):
+ continue # Skip descriptors to avoid executing arbitrary code
+
+ # Safe to get the attribute since it's not a descriptor
+ attr_obj = getattr(obj, attr_name)
+
+ # Check if the nested attribute exists and is not a descriptor
+ nested_attr_from_class = getattr(type(attr_obj), wrong_name, None)
+
+ if hasattr(attr_obj, wrong_name):
+ return f"{attr_name}.{wrong_name}"
+
+ return None
+
+
def _compute_suggestion_error(exc_value, tb, wrong_name):
if wrong_name is None or not isinstance(wrong_name, str):
return None
except ImportError:
pass
else:
- return _suggestions._generate_suggestions(d, wrong_name)
+ suggestion = _suggestions._generate_suggestions(d, wrong_name)
+ if suggestion:
+ return suggestion
# Compute closest match
if not suggestion or current_distance < best_distance:
suggestion = possible_name
best_distance = current_distance
+
+ # If no direct attribute match found, check for nested attributes
+ if not suggestion and isinstance(exc_value, AttributeError):
+ with suppress(Exception):
+ nested_suggestion = _check_for_nested_attribute(exc_value.obj, wrong_name, d)
+ if nested_suggestion:
+ return nested_suggestion
+
return suggestion