diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 43c40e4d0f3154..eaaf1b9966bc72 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -169,6 +169,45 @@ production systems where traditional profiling approaches would be too intrusive (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 + print(container.area) + ^^^^^^^^^^^^^^ + AttributeError: 'Container' object has no attribute 'area'. Did you mean: 'inner.area'? + + Other language changes ====================== diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index d45b3b96d2a85f..046385478b5f19 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -4262,6 +4262,184 @@ def __getattribute__(self, attr): 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) diff --git a/Lib/traceback.py b/Lib/traceback.py index 9d40b1df93c645..8e2d8d72a0a32d 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -1601,6 +1601,34 @@ def _substitution_cost(ch_a, ch_b): 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 @@ -1666,7 +1694,9 @@ def _compute_suggestion_error(exc_value, tb, wrong_name): 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 @@ -1691,6 +1721,14 @@ def _compute_suggestion_error(exc_value, tb, wrong_name): 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 diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2025-08-19-18-52-22.gh-issue-137967.uw67Ys.rst b/Misc/NEWS.d/next/Core_and_Builtins/2025-08-19-18-52-22.gh-issue-137967.uw67Ys.rst new file mode 100644 index 00000000000000..717cdecdfccbf0 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2025-08-19-18-52-22.gh-issue-137967.uw67Ys.rst @@ -0,0 +1 @@ +Show error suggestions on nested attribute access. Patch by Pablo Galindo