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..b62034c1ac419be 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,63 @@ def blech(self): self.assertEqual("bluch", exc.name) self.assertEqual(obj, exc.obj) + def test_getattr_error_message(self): + class RaiseWithName: + def __getattr__(self, name): + raise AttributeError(name) + with self.assertRaises(AttributeError) as cm: + getattr(RaiseWithName(), "missing1") + self.assertEqual(str(cm.exception), + "'RaiseWithName' object has no attribute 'missing1'") + + class BareRaise: + def __getattr__(self, name): + raise AttributeError + with self.assertRaises(AttributeError) as cm: + getattr(BareRaise(), "missing2") + self.assertEqual(str(cm.exception), + "'BareRaise' object has no attribute 'missing2'") + + class RaiseCustom: + def __getattr__(self, name): + raise AttributeError("custom") + with self.assertRaises(AttributeError) as cm: + getattr(RaiseCustom(), "missing3") + self.assertEqual(str(cm.exception), "custom") + + 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'") + + 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'") + + 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") + + nameless_mod = ModuleType.__new__(ModuleType) + 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'") + # 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..2852419c6d6e8a7 --- /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 @@ +The default error message is now generated from ``name`` and ``obj`` attributes +of :exc:`AttributeError` 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..f91cc85bdb0e246 100644 --- a/Objects/exceptions.c +++ b/Objects/exceptions.c @@ -14,6 +14,7 @@ #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 +2703,49 @@ AttributeError_dealloc(PyObject *self) Py_TYPE(self)->tp_free(self); } +static PyObject * +AttributeError_str(PyObject *op) +{ + PyAttributeErrorObject *self = PyAttributeErrorObject_CAST(op); + PyObject *arg, *obj = NULL, *name; + + 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; + if (PyModule_Check(obj)) { + PyModuleObject *mod = _PyModule_CAST(obj); + /* In a typical case, module's __name__ is examined instead. */ + if (mod->md_name) { + result = PyUnicode_FromFormat("module '%U' has no attribute '%U'", + mod->md_name, + name); + } else { + result = PyUnicode_FromFormat("module has no attribute '%U'", name); + } + } else { + result = PyUnicode_FromFormat("'%.200s' object has no attribute '%U'", + Py_TYPE(obj)->tp_name, name); + } + Py_DECREF(obj); + Py_DECREF(name); + return result; +} + static int AttributeError_traverse(PyObject *op, visitproc visit, void *arg) { @@ -2770,7 +2814,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