mirror of
https://github.com/python/cpython.git
synced 2025-11-24 20:30:18 +00:00
gh-137967: Restore suggestions on nested attribute access (#137968)
Some checks failed
Tests / Docs (push) Blocked by required conditions
Tests / Windows MSI (push) Blocked by required conditions
Tests / (push) Blocked by required conditions
Tests / Change detection (push) Waiting to run
Tests / Check if Autoconf files are up to date (push) Blocked by required conditions
Tests / Check if generated files are up to date (push) Blocked by required conditions
Tests / Ubuntu SSL tests with AWS-LC (push) Blocked by required conditions
Tests / Android (aarch64) (push) Blocked by required conditions
Tests / Android (x86_64) (push) Blocked by required conditions
Tests / WASI (push) Blocked by required conditions
Tests / Address sanitizer (push) Blocked by required conditions
Tests / Ubuntu SSL tests with OpenSSL (push) Blocked by required conditions
Tests / Hypothesis tests on Ubuntu (push) Blocked by required conditions
Tests / Cross build Linux (push) Blocked by required conditions
Tests / CIFuzz (push) Blocked by required conditions
Tests / All required checks pass (push) Blocked by required conditions
Lint / lint (push) Waiting to run
mypy / Run mypy on Tools/cases_generator (push) Waiting to run
mypy / Run mypy on Lib/_pyrepl (push) Waiting to run
mypy / Run mypy on Lib/test/libregrtest (push) Waiting to run
mypy / Run mypy on Lib/tomllib (push) Waiting to run
Tests / Sanitizers (push) Blocked by required conditions
Tail calling interpreter / aarch64-apple-darwin/clang (push) Waiting to run
Tail calling interpreter / aarch64-unknown-linux-gnu/gcc (push) Waiting to run
Tail calling interpreter / x86_64-pc-windows-msvc/msvc (push) Waiting to run
Tail calling interpreter / x86_64-apple-darwin/clang (push) Waiting to run
Tail calling interpreter / free-threading (push) Waiting to run
Tail calling interpreter / x86_64-unknown-linux-gnu/gcc (push) Waiting to run
mypy / Run mypy on Tools/build (push) Waiting to run
mypy / Run mypy on Tools/clinic (push) Waiting to run
mypy / Run mypy on Tools/jit (push) Waiting to run
mypy / Run mypy on Tools/peg_generator (push) Waiting to run
JIT / Interpreter (Debug) (push) Has been cancelled
JIT / aarch64-pc-windows-msvc/msvc (Release) (push) Has been cancelled
JIT / aarch64-pc-windows-msvc/msvc (Debug) (push) Has been cancelled
JIT / i686-pc-windows-msvc/msvc (Debug) (push) Has been cancelled
JIT / aarch64-apple-darwin/clang (Release) (push) Has been cancelled
JIT / x86_64-pc-windows-msvc/msvc (Release) (push) Has been cancelled
JIT / x86_64-pc-windows-msvc/msvc (Debug) (push) Has been cancelled
JIT / x86_64-apple-darwin/clang (Release) (push) Has been cancelled
JIT / x86_64-unknown-linux-gnu/gcc (Release) (push) Has been cancelled
JIT / x86_64-unknown-linux-gnu/gcc (Debug) (push) Has been cancelled
JIT / i686-pc-windows-msvc/msvc (Release) (push) Has been cancelled
JIT / aarch64-unknown-linux-gnu/gcc (Release) (push) Has been cancelled
JIT / aarch64-apple-darwin/clang (Debug) (push) Has been cancelled
JIT / aarch64-unknown-linux-gnu/gcc (Debug) (push) Has been cancelled
JIT / x86_64-apple-darwin/clang (Debug) (push) Has been cancelled
Some checks failed
Tests / Docs (push) Blocked by required conditions
Tests / Windows MSI (push) Blocked by required conditions
Tests / (push) Blocked by required conditions
Tests / Change detection (push) Waiting to run
Tests / Check if Autoconf files are up to date (push) Blocked by required conditions
Tests / Check if generated files are up to date (push) Blocked by required conditions
Tests / Ubuntu SSL tests with AWS-LC (push) Blocked by required conditions
Tests / Android (aarch64) (push) Blocked by required conditions
Tests / Android (x86_64) (push) Blocked by required conditions
Tests / WASI (push) Blocked by required conditions
Tests / Address sanitizer (push) Blocked by required conditions
Tests / Ubuntu SSL tests with OpenSSL (push) Blocked by required conditions
Tests / Hypothesis tests on Ubuntu (push) Blocked by required conditions
Tests / Cross build Linux (push) Blocked by required conditions
Tests / CIFuzz (push) Blocked by required conditions
Tests / All required checks pass (push) Blocked by required conditions
Lint / lint (push) Waiting to run
mypy / Run mypy on Tools/cases_generator (push) Waiting to run
mypy / Run mypy on Lib/_pyrepl (push) Waiting to run
mypy / Run mypy on Lib/test/libregrtest (push) Waiting to run
mypy / Run mypy on Lib/tomllib (push) Waiting to run
Tests / Sanitizers (push) Blocked by required conditions
Tail calling interpreter / aarch64-apple-darwin/clang (push) Waiting to run
Tail calling interpreter / aarch64-unknown-linux-gnu/gcc (push) Waiting to run
Tail calling interpreter / x86_64-pc-windows-msvc/msvc (push) Waiting to run
Tail calling interpreter / x86_64-apple-darwin/clang (push) Waiting to run
Tail calling interpreter / free-threading (push) Waiting to run
Tail calling interpreter / x86_64-unknown-linux-gnu/gcc (push) Waiting to run
mypy / Run mypy on Tools/build (push) Waiting to run
mypy / Run mypy on Tools/clinic (push) Waiting to run
mypy / Run mypy on Tools/jit (push) Waiting to run
mypy / Run mypy on Tools/peg_generator (push) Waiting to run
JIT / Interpreter (Debug) (push) Has been cancelled
JIT / aarch64-pc-windows-msvc/msvc (Release) (push) Has been cancelled
JIT / aarch64-pc-windows-msvc/msvc (Debug) (push) Has been cancelled
JIT / i686-pc-windows-msvc/msvc (Debug) (push) Has been cancelled
JIT / aarch64-apple-darwin/clang (Release) (push) Has been cancelled
JIT / x86_64-pc-windows-msvc/msvc (Release) (push) Has been cancelled
JIT / x86_64-pc-windows-msvc/msvc (Debug) (push) Has been cancelled
JIT / x86_64-apple-darwin/clang (Release) (push) Has been cancelled
JIT / x86_64-unknown-linux-gnu/gcc (Release) (push) Has been cancelled
JIT / x86_64-unknown-linux-gnu/gcc (Debug) (push) Has been cancelled
JIT / i686-pc-windows-msvc/msvc (Release) (push) Has been cancelled
JIT / aarch64-unknown-linux-gnu/gcc (Release) (push) Has been cancelled
JIT / aarch64-apple-darwin/clang (Debug) (push) Has been cancelled
JIT / aarch64-unknown-linux-gnu/gcc (Debug) (push) Has been cancelled
JIT / x86_64-apple-darwin/clang (Debug) (push) Has been cancelled
This commit is contained in:
parent
339f5da639
commit
539a4ca1b9
4 changed files with 257 additions and 1 deletions
|
|
@ -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
|
||||
======================
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
Show error suggestions on nested attribute access. Patch by Pablo Galindo
|
||||
Loading…
Add table
Add a link
Reference in a new issue