]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-137967: Restore suggestions on nested attribute access (#137968)
authorPablo Galindo Salgado <Pablogsal@gmail.com>
Thu, 21 Aug 2025 15:56:57 +0000 (16:56 +0100)
committerGitHub <noreply@github.com>
Thu, 21 Aug 2025 15:56:57 +0000 (16:56 +0100)
Doc/whatsnew/3.15.rst
Lib/test/test_traceback.py
Lib/traceback.py
Misc/NEWS.d/next/Core_and_Builtins/2025-08-19-18-52-22.gh-issue-137967.uw67Ys.rst [new file with mode: 0644]

index 43c40e4d0f3154ee3567354aedfe0dd42478fea3..eaaf1b9966bc724997f84922307e152706d9342e 100644 (file)
@@ -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 <module>
+        print(container.area)
+              ^^^^^^^^^^^^^^
+     AttributeError: 'Container' object has no attribute 'area'. Did you mean: 'inner.area'?
+
+
 Other language changes
 ======================
 
index d45b3b96d2a85fd4c828ff96e12123bebbeccdb5..046385478b5f199e638a20fd00b999806ee5bc11 100644 (file)
@@ -4262,6 +4262,184 @@ class SuggestionFormattingTestBase:
         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)
index 9d40b1df93c645b652dd3eedbee521a4bd197bdb..8e2d8d72a0a32d00b7eac942a1356218ce28162b 100644 (file)
@@ -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 (file)
index 0000000..717cdec
--- /dev/null
@@ -0,0 +1 @@
+Show error suggestions on nested attribute access. Patch by Pablo Galindo