From ae72d0c92bf57448e256cdf8cfac7d4fd345b1a4 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Bartosz=20S=C5=82awecki?= Date: Fri, 24 Jul 2026 14:14:47 +0200 Subject: [PATCH] gh-153785: Generate `AttributeError` messages from context (#153786) Co-authored-by: Victor Stinner --- Doc/library/exceptions.rst | 2 + Lib/test/test_exceptions.py | 124 ++++++++++++++++++ ...-07-16-02-50-01.gh-issue-153785.fJqPKC.rst | 4 + Objects/exceptions.c | 63 ++++++++- 4 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-07-16-02-50-01.gh-issue-153785.fJqPKC.rst diff --git a/Doc/library/exceptions.rst b/Doc/library/exceptions.rst index 3775d5ac81a2..ecf62fb6391b 100644 --- a/Doc/library/exceptions.rst +++ b/Doc/library/exceptions.rst @@ -215,6 +215,8 @@ The following exceptions are the exceptions that are usually raised. The object that was accessed for the named attribute. + When possible, :attr:`name` and :attr:`obj` are set automatically. + .. versionchanged:: 3.10 Added the :attr:`name` and :attr:`obj` attributes. diff --git a/Lib/test/test_exceptions.py b/Lib/test/test_exceptions.py index ee4888c18598..0cee756958f3 100644 --- a/Lib/test/test_exceptions.py +++ b/Lib/test/test_exceptions.py @@ -10,6 +10,7 @@ import errno from codecs import BOM_UTF8 from itertools import product from textwrap import dedent +from types import ModuleType from test.support import (captured_stderr, check_impl_detail, cpython_only, gc_collect, @@ -2054,6 +2055,129 @@ class AttributeErrorTests(unittest.TestCase): self.assertEqual("bluch", exc.name) self.assertEqual(obj, exc.obj) + def test_getattr_error_message(self): + def fqn(type): + return f'{type.__module__}.{type.__qualname__}' + + class RaiseWithName: + def __getattr__(self, name): + raise AttributeError(name) + obj = RaiseWithName() + with self.assertRaises(AttributeError) as cm: + getattr(obj, "missing1") + self.assertEqual(str(cm.exception), + f"'{fqn(RaiseWithName)}' object has no attribute 'missing1'") + self.assertIs(cm.exception.obj, obj) + self.assertEqual(cm.exception.name, "missing1") + + class BareRaise: + def __getattr__(self, name): + raise AttributeError + obj = BareRaise() + with self.assertRaises(AttributeError) as cm: + getattr(obj, "missing2") + self.assertEqual(str(cm.exception), + f"'{fqn(BareRaise)}' object has no attribute 'missing2'") + self.assertIs(cm.exception.obj, obj) + self.assertEqual(cm.exception.name, "missing2") + + class RaiseCustom: + def __getattr__(self, name): + raise AttributeError("custom") + obj = RaiseCustom() + with self.assertRaises(AttributeError) as cm: + getattr(obj, "missing3") + self.assertEqual(str(cm.exception), "custom") + self.assertIs(cm.exception.obj, obj) + self.assertEqual(cm.exception.name, "missing3") + + def test_class_getattr_error_message(self): + def fqn(type): + return f'{type.__module__}.{type.__qualname__}' + + class MetaclassRaiseWithName(type): + def __getattr__(self, name): + raise AttributeError(name) + cls = MetaclassRaiseWithName("spam", (), {}) + with self.assertRaises(AttributeError) as cm: + getattr(cls, "missing1") + self.assertEqual(str(cm.exception), + f"type object '{fqn(cls)}' has no attribute 'missing1'") + self.assertIs(cm.exception.obj, cls) + self.assertEqual(cm.exception.name, "missing1") + + class MetaclassBareRaise(type): + def __getattr__(self, name): + raise AttributeError + cls = MetaclassBareRaise("eggs", (), {}) + with self.assertRaises(AttributeError) as cm: + getattr(cls, "missing2") + self.assertEqual(str(cm.exception), + f"type object '{fqn(cls)}' has no attribute 'missing2'") + self.assertIs(cm.exception.obj, cls) + self.assertEqual(cm.exception.name, "missing2") + + class MetaclassRaiseCustom(type): + def __getattr__(self, name): + raise AttributeError("custom") + cls = MetaclassRaiseCustom("ham", (), {}) + with self.assertRaises(AttributeError) as cm: + getattr(cls, "missing3") + self.assertEqual(str(cm.exception), "custom") + self.assertIs(cm.exception.obj, cls) + self.assertEqual(cm.exception.name, "missing3") + + def test_module_getattr_error_message(self): + raisewithname_mod = ModuleType("raisewithname") + def raise_with_name(name): + raise AttributeError(name) + raisewithname_mod.__getattr__ = raise_with_name + with self.assertRaises(AttributeError) as cm: + getattr(raisewithname_mod, "missing1") + self.assertEqual(str(cm.exception), + "module 'raisewithname' has no attribute 'missing1'") + self.assertIs(cm.exception.obj, raisewithname_mod) + self.assertEqual(cm.exception.name, "missing1") + + bareraise_mod = ModuleType("bareraise") + def bare_raise(name): + raise AttributeError + bareraise_mod.__getattr__ = bare_raise + with self.assertRaises(AttributeError) as cm: + getattr(bareraise_mod, "missing2") + self.assertEqual(str(cm.exception), + "module 'bareraise' has no attribute 'missing2'") + self.assertIs(cm.exception.obj, bareraise_mod) + self.assertEqual(cm.exception.name, "missing2") + + custom_mod = ModuleType("custom") + def raise_custom(name): + raise AttributeError("custom") + custom_mod.__getattr__ = raise_custom + with self.assertRaises(AttributeError) as cm: + getattr(custom_mod, "missing3") + self.assertEqual(str(cm.exception), "custom") + self.assertIs(cm.exception.obj, custom_mod) + self.assertEqual(cm.exception.name, "missing3") + + nameless_mod = ModuleType("forgettable") + del nameless_mod.__dict__["__name__"] + nameless_mod.__getattr__ = raise_with_name + with self.assertRaises(AttributeError) as cm: + getattr(nameless_mod, "missing4") + self.assertEqual(str(cm.exception), "module has no attribute 'missing4'") + self.assertIs(cm.exception.obj, nameless_mod) + self.assertEqual(cm.exception.name, "missing4") + + nameless_mod = ModuleType("broken") + nameless_mod.__dict__["__name__"] = 10j + nameless_mod.__getattr__ = raise_with_name + with self.assertRaises(AttributeError) as cm: + getattr(nameless_mod, "missing4") + self.assertEqual(str(cm.exception), "module has no attribute 'missing4'") + self.assertIs(cm.exception.obj, nameless_mod) + self.assertEqual(cm.exception.name, "missing4") + # Note: name suggestion tests live in `test_traceback`. diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-07-16-02-50-01.gh-issue-153785.fJqPKC.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-07-16-02-50-01.gh-issue-153785.fJqPKC.rst new file mode 100644 index 000000000000..40981acb37bb --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-07-16-02-50-01.gh-issue-153785.fJqPKC.rst @@ -0,0 +1,4 @@ +:exc:`AttributeError`: The default error message is now generated from ``name`` +and ``obj`` attributes when both are set and the exception was constructed with +no positional arguments, or with a single positional argument equal to ``name``. +Patch by Bartosz Sławecki. diff --git a/Objects/exceptions.c b/Objects/exceptions.c index 149595e64cec..fb546ad26735 100644 --- a/Objects/exceptions.c +++ b/Objects/exceptions.c @@ -11,9 +11,11 @@ #include "pycore_exceptions.h" // struct _Py_exc_state #include "pycore_initconfig.h" #include "pycore_modsupport.h" // _PyArg_NoKeywords() +#include "pycore_moduleobject.h" // _PyModule_CAST() #include "pycore_object.h" #include "pycore_pyerrors.h" // struct _PyErr_SetRaisedException #include "pycore_tuple.h" // _PyTuple_FromPair +#include "pycore_unicodeobject.h" // _PyUnicode_Equal() #include "osdefs.h" // SEP #include "clinic/exceptions.c.h" @@ -2702,6 +2704,65 @@ AttributeError_dealloc(PyObject *self) Py_TYPE(self)->tp_free(self); } +static PyObject * +AttributeError_str(PyObject *op) +{ + PyAttributeErrorObject *self = PyAttributeErrorObject_CAST(op); + PyObject *arg; // borrowed ref + PyObject *obj = NULL, *name = NULL; + + /* .name and .obj are set automatically when attribute lookup fails, so + synthesize a more informative message from them when the caller + didn't supply a meaningful one of their own -- that is, when args is + empty, or contains only the attribute name. Otherwise, use the + message the caller gave. */ + + Py_BEGIN_CRITICAL_SECTION(self); + if ( + self->obj && self->name && PyUnicode_Check(self->name) + && ((PyTuple_GET_SIZE(self->args) == 1 + && PyUnicode_Check(arg = PyTuple_GET_ITEM(self->args, 0)) + && _PyUnicode_Equal(arg, self->name)) + || PyTuple_GET_SIZE(self->args) == 0) + ) { + obj = Py_NewRef(self->obj); + name = Py_NewRef(self->name); + } + Py_END_CRITICAL_SECTION(); + + if (!obj) { + assert(!name); + return BaseException_str(op); /* re-acquires lock */ + } + + PyObject *result = NULL; + if (PyModule_Check(obj)) { + PyModuleObject *mod = _PyModule_CAST(obj); + PyObject *modname; + if (PyDict_GetItemRef(mod->md_dict, &_Py_ID(__name__), &modname) < 0) { + goto done; + } + if (modname && PyUnicode_Check(modname)) { + result = PyUnicode_FromFormat("module %R has no attribute %R", + modname, name); + Py_DECREF(modname); + } else { + Py_XDECREF(modname); + result = PyUnicode_FromFormat("module has no attribute %R", name); + } + } else if (PyType_Check(obj)) { + result = PyUnicode_FromFormat("type object '%N' has no attribute %R", + obj, name); + } else { + result = PyUnicode_FromFormat("'%T' object has no attribute %R", + obj, name); + } +done: + Py_DECREF(obj); + Py_DECREF(name); + return result; +} + static int AttributeError_traverse(PyObject *op, visitproc visit, void *arg) { @@ -2770,7 +2831,7 @@ static PyMethodDef AttributeError_methods[] = { ComplexExtendsException(PyExc_Exception, AttributeError, AttributeError, 0, AttributeError_methods, AttributeError_members, - 0, BaseException_str, 0, "Attribute not found."); + 0, AttributeError_str, 0, "Attribute not found."); /* * SyntaxError extends Exception -- 2.47.3