Skip to content

gh-153785: Generate AttributeError messages from context#153786

Open
johnslavik wants to merge 39 commits into
python:mainfrom
johnslavik:gh-153785
Open

gh-153785: Generate AttributeError messages from context#153786
johnslavik wants to merge 39 commits into
python:mainfrom
johnslavik:gh-153785

Conversation

@johnslavik

@johnslavik johnslavik commented Jul 16, 2026

Copy link
Copy Markdown
Member

Motivation

Code very often raises AttributeError(name) or, more rarely, a bare AttributeError from __getattr__ (there are ~40 such sites in CPython itself). In both cases the exception's obj and name attributes are populated automatically by _PyObject_SetAttributeErrorContext, but the message rendered in the traceback ignores them:

  • raise AttributeError(name) shows just the attribute name, losing which object it was accessed on.
  • bare raise AttributeError shows nothing at all.

So today the message throws away context that the interpreter already has on hand (it uses obj/name for the "Did you mean …?" suggestions, but not for the message itself).

This also produces the garbled output reported in #143811, where a bare AttributeError renders as an empty message glued to a suggestion:

AttributeError: . Did you mean: 'foobar'?

Change

This gives AttributeError a custom __str__ (AttributeError_str) that synthesizes the familiar message from the collected obj/name context:

AttributeError: '<qualname>' object has no attribute 'missing'
AttributeError: module '<name>' has no attribute 'missing'

It is the same trick KeyError already uses to wrap its key in repr() — see KeyError_str. Doing it in AttributeError.__str__ (rather than in traceback) means every consumer of the message benefits, not just tracebacks.

To avoid clobbering messages the caller actually meant, the generated message is only used when obj and name are both set and the exception was constructed either with no positional arguments or with a single positional argument equal to name. An explanatory string passed by the caller (e.g. AttributeError("custom")) is preserved unchanged.

As a side effect this also fixes #143811, which motivated the current issue — the functools.singledispatchmethod case now reports a proper message instead of the garbled one.

@python-cla-bot

This comment was marked as resolved.

@johnslavik johnslavik changed the title Generate AttributeError messages from context gh-153785: Generate AttributeError messages from context Jul 16, 2026
@read-the-docs-community

read-the-docs-community Bot commented Jul 16, 2026

Copy link
Copy Markdown

@johnslavik
johnslavik requested a review from DinoV July 16, 2026 01:23
@johnslavik
johnslavik marked this pull request as draft July 16, 2026 02:11
@johnslavik
johnslavik marked this pull request as ready for review July 16, 2026 02:21
Comment thread Objects/exceptions.c Outdated
Comment thread Objects/exceptions.c Outdated
@johnslavik
johnslavik requested a review from pablogsal July 17, 2026 10:15
Comment thread Objects/exceptions.c Outdated
@github-project-automation github-project-automation Bot moved this to Todo in Sprint Jul 18, 2026
@github-project-automation github-project-automation Bot moved this from Todo to In Progress in Sprint Jul 18, 2026
Comment thread Lib/test/test_exceptions.py Outdated
Comment thread Objects/exceptions.c
result = PyUnicode_FromFormat("module has no attribute '%U'", name);
}
} else {
result = PyUnicode_FromFormat("'%T' object has no attribute '%U'",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This formats class objects as instances of their metaclass. Can we preserve the standard type object 'C' has no attribute ... form here?

Comment thread Objects/exceptions.c
if (PyModule_Check(obj)) {
PyModuleObject *mod = _PyModule_CAST(obj);
/* In a typical case, module's __name__ is examined instead. */
if (mod->md_name) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This uses cached md_name, so assigning module.__name__ makes the generated message report the old name. We should read the current module name here.

Comment thread Objects/exceptions.c

PyObject *result;
if (PyModule_Check(obj)) {
PyModuleObject *mod = _PyModule_CAST(obj);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: we use _PyModule_CAST and md_name here but only get pycore_moduleobject.h transitively through pycore_object.h. Can we include it explicitly like the other headers?


When possible, :attr:`name` and :attr:`obj` are set automatically.

.. versionchanged:: 3.10

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We dropped the .. versionchanged:: next note that documented the generated message. This is a user-visible change to str(), so I think we should keep a versionchanged here, no?

@vstinner vstinner left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The following code writes 'lazy_import' object has no attribute 'missing1': it mentions lazy_import rather than module 'asyncio'. But I don't think that AttributeError_str() would be the right place to reify the asyncio module!

lazy import asyncio

class RaiseWithName:
    def __getattr__(self, name):
        raise AttributeError(name)

try:
    getattr(globals()['asyncio'], "missing1")
except AttributeError as exc:
#    assert exc.obj is ...
    assert exc.name == "missing1"
    print(str(exc))


The object that was accessed for the named attribute.

When possible, :attr:`name` and :attr:`obj` are set automatically.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change surprised me but it's correct.

This addition surprised me since the PR doesn't change that. The change documents that _PyObject_SetAttributeErrorContext() is called by many functions such as PyObject_GetAttr() to set name and obj attributes of an AttributeError.

Comment thread Objects/exceptions.c
Comment on lines +2715 to +2718
&& ((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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please add a comment explaining why you use exc.name only if it's equal to exc.args[0] or if exc.args is empty? IMO it's a good thing to implement this change, but it wasn't obvious the first time that I read this code.

Comment thread Objects/exceptions.c
AttributeError_str(PyObject *op)
{
PyAttributeErrorObject *self = PyAttributeErrorObject_CAST(op);
PyObject *arg, *obj = NULL, *name;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dislike the arg = ... assignment in the middle of the big if condition, but you can keep it if you like it.

Suggested change
PyObject *arg, *obj = NULL, *name;
PyObject *arg; // borrowed ref
PyObject *obj = NULL, *name = NULL;

Comment thread Objects/exceptions.c
Comment on lines +2735 to +2736
mod->md_name,
name);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
mod->md_name,
name);
mod->md_name, name);

self.assertEqual("bluch", exc.name)
self.assertEqual(obj, exc.obj)

def test_getattr_error_message(self):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please check also obj and name attributes of AttributeError:

Details
    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.assertIs(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.assertIs(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.assertIs(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.assertIs(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.assertIs(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.assertIs(cm.exception.name, "missing4")

        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'")
        self.assertIs(cm.exception.obj, nameless_mod)
        self.assertIs(cm.exception.name, "missing4")

Comment on lines +1 to +2
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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
:exc:`AttributeError`: The default error message is now generated from ``name``
and ``obj`` attributes when both are set and the exception was constructed with

@jaraco

jaraco commented Jul 18, 2026

Copy link
Copy Markdown
Member

I think this is the best place to do this. We do the same trick in KeyError.

This also fixed issue #143811 that motivated the current one.

* Issue: [Generate `AttributeError` messages from their context #153785](https://github.com/python/cpython/issues/153785)

"this" and "current one" is unclear here. Please update the PR description to describe the motivation. Also consider linking to the code for the trick in KeyError.

@jaraco

jaraco commented Jul 18, 2026

Copy link
Copy Markdown
Member

I think this is the best place to do this. We do the same trick in KeyError.
This also fixed issue #143811 that motivated the current one.

* Issue: [Generate `AttributeError` messages from their context #153785](https://github.com/python/cpython/issues/153785)

"this" and "current one" is unclear here. Please update the PR description to describe the motivation. Also consider linking to the code for the trick in KeyError.

I used Claude to infer the motivations and update the description.

@jaraco

jaraco commented Jul 18, 2026

Copy link
Copy Markdown
Member

It is the same trick KeyError already uses to wrap its key in repr() — see KeyError_str.

This trick has been applied to KeyError and AttributeError; should it be applied to other exceptions? Should it be implemented in such a way that the concern is shared across exceptions (explicitly or implicitly)?

@jaraco

jaraco commented Jul 18, 2026

Copy link
Copy Markdown
Member

This trick has been applied to KeyError and AttributeError; should it be applied to other exceptions? Should it be implemented in such a way that the concern is shared across exceptions (explicitly or implicitly)?

Short answer, not really.

Details

(Analysis by Claude, on my behalf.)

A worthwhile question. Worth noting first that the two "tricks" only look alike at the surface (both override tp_str); underneath they differ:

  • KeyError_str reformats what's already in args — wrapping args[0] in repr() so {}[''] prints KeyError: ''. Pure presentation.
  • AttributeError_str synthesizes a message from side-channel context (obj/name) that the raiser never put in args, reconstructing what the interpreter attached out-of-band.

Only the second is the interesting reusable pattern ("I have structured context attributes; render them lazily instead of forcing every raise site to build the string"). The natural cohort there is NameError/UnboundLocalError (name), ImportError/ModuleNotFoundError (name, path). But both of those already build their full messages eagerly at the C raise site, so the problem AttributeError has — user __getattr__ implementations conventionally raising bare or name-only — barely exists for them.

So I'd lean against building a shared framework here:

  • Two thin, differing data points; abstracting them would abstract over a coincidence of mechanism, not a shared concern.
  • The templates ("object has no attribute" vs. "name is not defined" vs. "cannot import name from") are irreducibly per-type — only the dispatch around them could be shared.
  • Every __str__ change is a compatibility surface (people do match on message text), and each type wants its own guard about when overriding is safe.

The one genuinely reusable piece is the guard predicate — "was this constructed with a default/absent message, or one that merely duplicates a structured field, so I'm safe to override?" (here: args of size 0, or size 1 equal to name). If anything gets factored out, I'd make it that small internal helper and leave each template local. Otherwise I'd revisit sharing only when a third genuine case actually lands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

Bare AttributeError in __getattr__ shows a garbled message with attribute suggestions

6 participants