diff --git a/Doc/library/exceptions.rst b/Doc/library/exceptions.rst index 3775d5ac81a2736..ecf62fb6391b1b1 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 cc7faef93e1af7c..1505af8f250cf6e 100644 --- a/Lib/test/test_exceptions.py +++ b/Lib/test/test_exceptions.py @@ -10,6 +10,7 @@ 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, @@ -2047,6 +2048,129 @@ def blech(self): 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 000000000000000..40981acb37bbd70 --- /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 149595e64cec144..72bb143453a3752 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,64 @@ 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) { + 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 error; + } + if (modname && PyUnicode_Check(modname)) { + result = PyUnicode_FromFormat("module '%U' has no attribute '%U'", + modname, name); + Py_DECREF(modname); + } else { + Py_XDECREF(modname); + result = PyUnicode_FromFormat("module has no attribute '%U'", name); + } + } else if (PyType_Check(obj)) { + result = PyUnicode_FromFormat("type object '%N' has no attribute '%U'", + obj, name); + } else { + result = PyUnicode_FromFormat("'%T' object has no attribute '%U'", + obj, name); + } +error: + Py_DECREF(obj); + Py_DECREF(name); + return result; +} + static int AttributeError_traverse(PyObject *op, visitproc visit, void *arg) { @@ -2770,7 +2830,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