Use non-raising lookup for default optional attribute access (#8576)

* vm: use non-raising lookup for default attribute access

get_attribute_opt() backs hasattr() and getattr() with a default. It used
get_attr_inner() for every object, then converted AttributeError from a
missing attribute into None. The usual user-object miss therefore built and
dropped an exception just to answer an optional lookup.

Route objects using PyBaseObject::getattro through generic_getattr_opt(),
which returns the missing case without constructing AttributeError. Objects
with a custom getattro slot keep the raising path: their __getattribute__ and
__getattr__ hooks can run Python code, so their exceptions must preserve the
existing behavior. AttributeError from either route, including a descriptor,
is still converted to None; non-AttributeError exceptions propagate.

The new snippet covers ordinary hits and misses, custom __getattribute__ /
__getattr__, descriptor exceptions, and dynamic getattro-slot updates.

Refs youknowone#48

Assisted-by: Codex:GPT-5.6-terra

* vm: use non-raising lookup for optional attributes
This commit is contained in:
이광효 authored and GitHub committed 2026-08-22 20:55:24 +09:00
1 parent 50b60dc85c
commit dd32207408
2 files changed
+137 -4

No files matched your search

+11 -4
View File
@@ -23,8 +23,8 @@ mod vm_ops;
use crate::{
AsObject, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult,
builtins::{
self, PyBaseExceptionRef, PyDict, PyDictRef, PyInt, PyList, PyModule, PyStr, PyStrInterned,
PyStrRef, PyTypeRef, PyUtf8Str, PyUtf8StrInterned, PyWeak,
self, PyBaseExceptionRef, PyBaseObject, PyDict, PyDictRef, PyInt, PyList, PyModule, PyStr,
PyStrInterned, PyStrRef, PyTypeRef, PyUtf8Str, PyUtf8StrInterned, PyWeak,
code::PyCode,
dict::{PyDictItems, PyDictKeys, PyDictValues},
pystr::AsPyStr,
@@ -42,6 +42,7 @@ use crate::{
scope::Scope,
signal::{self, SignalHandlers},
stdlib,
types::{GetattroFunc, fn_addr},
warn::WarningsState,
};
use alloc::{borrow::Cow, collections::BTreeMap};
@@ -2854,8 +2855,14 @@ impl VirtualMachine {
attr_name: impl AsPyStr<'a>,
) -> PyResult<Option<PyObjectRef>> {
let attr_name = attr_name.as_pystr(&self.ctx);
match obj.get_attr_inner(attr_name, self) {
Ok(attr) => Ok(Some(attr)),
let getattro = obj.class().slots.getattro.load().unwrap();
let result = if fn_addr(getattro) == fn_addr(PyBaseObject::getattro as GetattroFunc) {
obj.generic_getattr_opt(attr_name, None, self)
} else {
obj.get_attr_inner(attr_name, self).map(Some)
};
match result {
Ok(attr) => Ok(attr),
Err(e) if e.fast_isinstance(self.ctx.exceptions.attribute_error) => Ok(None),
Err(e) => Err(e),
}
@@ -0,0 +1,126 @@
def assert_raises(exc_type, func, *args):
try:
func(*args)
except exc_type:
return
except BaseException as exc:
raise AssertionError(
f"expected {exc_type.__name__}, got {type(exc).__name__}"
) from exc
raise AssertionError(f"expected {exc_type.__name__}")
class Plain:
class_attr = "class"
plain = Plain()
plain.instance_attr = "instance"
sentinel = object()
assert hasattr(plain, "class_attr")
assert hasattr(plain, "instance_attr")
assert not hasattr(plain, "missing")
assert getattr(plain, "class_attr", sentinel) == "class"
assert getattr(plain, "instance_attr", sentinel) == "instance"
assert getattr(plain, "missing", sentinel) is sentinel
assert_raises(AttributeError, getattr, plain, "missing")
assert_raises(TypeError, hasattr, plain, 1)
assert_raises(TypeError, getattr, plain, 1, sentinel)
getattribute_calls = []
class CustomGetattribute:
def __getattribute__(self, name):
getattribute_calls.append(name)
if name == "missing":
raise AttributeError("custom miss")
if name == "bad":
raise ValueError("custom error")
return f"value:{name}"
custom_getattribute = CustomGetattribute()
assert not hasattr(custom_getattribute, "missing")
assert getattr(custom_getattribute, "missing", sentinel) is sentinel
assert getattribute_calls == ["missing", "missing"]
assert getattr(custom_getattribute, "present", sentinel) == "value:present"
assert_raises(ValueError, hasattr, custom_getattribute, "bad")
getattr_calls = []
class CustomGetattr:
def __getattr__(self, name):
getattr_calls.append(name)
if name == "present":
return "fallback value"
if name == "bad":
raise SystemExit("custom error")
raise AttributeError(name)
custom_getattr = CustomGetattr()
assert getattr(custom_getattr, "present", sentinel) == "fallback value"
assert not hasattr(custom_getattr, "missing")
assert getattr_calls == ["present", "missing"]
assert_raises(SystemExit, hasattr, custom_getattr, "bad")
class AttributeErrorSubclass(AttributeError):
pass
class AttributeErrorDescriptor:
def __get__(self, obj, owner):
raise AttributeError("descriptor miss")
class AttributeErrorSubclassDescriptor:
def __get__(self, obj, owner):
raise AttributeErrorSubclass("descriptor miss")
class ValueErrorDescriptor:
def __get__(self, obj, owner):
raise ValueError("descriptor error")
class DescriptorContainer:
attribute_error = AttributeErrorDescriptor()
attribute_error_subclass = AttributeErrorSubclassDescriptor()
value_error = ValueErrorDescriptor()
descriptor_container = DescriptorContainer()
assert not hasattr(descriptor_container, "attribute_error")
assert getattr(descriptor_container, "attribute_error", sentinel) is sentinel
assert not hasattr(descriptor_container, "attribute_error_subclass")
assert getattr(descriptor_container, "attribute_error_subclass", sentinel) is sentinel
assert_raises(ValueError, hasattr, descriptor_container, "value_error")
class Dynamic:
pass
dynamic = Dynamic()
assert not hasattr(dynamic, "missing")
dynamic_calls = []
def dynamic_getattribute(self, name):
dynamic_calls.append(name)
if name == "missing":
raise AttributeError(name)
return object.__getattribute__(self, name)
Dynamic.__getattribute__ = dynamic_getattribute
assert not hasattr(dynamic, "missing")
del Dynamic.__getattribute__
assert not hasattr(dynamic, "missing")
assert dynamic_calls == ["missing"]