From 047f4d45271b714dbb664e3650b5e6f9891feb1e Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Wed, 30 Sep 2026 17:58:56 +0200 Subject: [PATCH 1/9] Add/move assertions to replace_value (These serve as documentation on when the function can be called.) --- Objects/dictobject.c | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Objects/dictobject.c b/Objects/dictobject.c index 57874de6ee74970..75b60bff58393ab 100644 --- a/Objects/dictobject.c +++ b/Objects/dictobject.c @@ -2014,9 +2014,11 @@ static void replace_value(PyDictObject *mp, PyObject *key, Py_ssize_t ix, PyObject *old_value, PyObject *value) { + assert(can_modify_dict(mp)); + assert(old_value != NULL); + if (old_value != value) { _PyDict_NotifyEvent(PyDict_EVENT_MODIFIED, mp, key, value); - assert(old_value != NULL); if (DK_IS_UNICODE(mp->ma_keys)) { if (_PyDict_HasSplitTable(mp)) { STORE_SPLIT_VALUE(mp, ix, value); @@ -2032,6 +2034,8 @@ replace_value(PyDictObject *mp, PyObject *key, Py_ssize_t ix, } } Py_DECREF(old_value); /* which **CAN** re-enter (see issue #22653) */ + + ASSERT_CONSISTENT(mp); } /* @@ -2082,7 +2086,6 @@ insertdict(PyDictObject *mp, } replace_value(mp, key, ix, old_value, value); - ASSERT_CONSISTENT(mp); Py_DECREF(key); return 0; @@ -3098,7 +3101,6 @@ _PyDict_ReplaceItemIf(PyObject *op, PyObject *key, else if (current == expected) { // Do not look up the key again: equality can execute Python code. replace_value(mp, key, ix, current, Py_NewRef(replacement)); - ASSERT_CONSISTENT(mp); result = 1; } Py_END_CRITICAL_SECTION(); From 3612cf928e7e5adf76029e831158a5797087f88b Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Wed, 30 Sep 2026 17:59:20 +0200 Subject: [PATCH 2/9] Replace BPO issue number with GH --- Objects/dictobject.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Objects/dictobject.c b/Objects/dictobject.c index 75b60bff58393ab..958fce92519d031 100644 --- a/Objects/dictobject.c +++ b/Objects/dictobject.c @@ -2033,7 +2033,7 @@ replace_value(PyDictObject *mp, PyObject *key, Py_ssize_t ix, STORE_VALUE(ep, value); } } - Py_DECREF(old_value); /* which **CAN** re-enter (see issue #22653) */ + Py_DECREF(old_value); /* which **CAN** re-enter (see gh-66843) */ ASSERT_CONSISTENT(mp); } From c5068e02139a065414f383851f73081ee33d8e54 Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Wed, 30 Sep 2026 18:00:30 +0200 Subject: [PATCH 3/9] Switch matches to Py_ssize_t --- Objects/lazyimportobject.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Objects/lazyimportobject.c b/Objects/lazyimportobject.c index 72624b746fe64c3..9be303959fbd7e5 100644 --- a/Objects/lazyimportobject.c +++ b/Objects/lazyimportobject.c @@ -486,9 +486,9 @@ lazy_import_replace_child(PyThreadState *tstate, PyObject *placeholder, if (end - dot - 1 != PyUnicode_GET_LENGTH(name)) { return 0; } - int matches = PyUnicode_Tailmatch(root->lz_from, name, dot + 1, end, 1); + Py_ssize_t matches = PyUnicode_Tailmatch(root->lz_from, name, dot + 1, end, 1); if (matches <= 0) { - return matches; + return matches ? -1 : 0; } PyObject *parent_name = PyUnicode_Substring(root->lz_from, 0, dot); if (parent_name == NULL) { From d7a24822d5788547292e102510ac92085169e269 Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Wed, 30 Sep 2026 18:00:57 +0200 Subject: [PATCH 4/9] PEP 257 --- Objects/lazyimportobject.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Objects/lazyimportobject.c b/Objects/lazyimportobject.c index 9be303959fbd7e5..043f2a86ccc7765 100644 --- a/Objects/lazyimportobject.c +++ b/Objects/lazyimportobject.c @@ -563,7 +563,7 @@ lazy_import_resolve(PyObject *self, PyObject *args) static PyMethodDef lazy_import_methods[] = { { "resolve", lazy_import_resolve, METH_NOARGS, - PyDoc_STR("resolves the lazy import and returns the actual object") + PyDoc_STR("Resolve the lazy import and return the imported object.") }, {NULL, NULL} }; From 456a83f10ba94660b7b54b8cae34f7b16e3e067a Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Wed, 30 Sep 2026 18:03:59 +0200 Subject: [PATCH 5/9] Expand asserts for readability & better error reporting --- Objects/dictobject.c | 4 +++- Objects/lazyimportobject.c | 13 ++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/Objects/dictobject.c b/Objects/dictobject.c index 958fce92519d031..c38d867875bdaac 100644 --- a/Objects/dictobject.c +++ b/Objects/dictobject.c @@ -3085,7 +3085,9 @@ _PyDict_ReplaceItemIf(PyObject *op, PyObject *key, PyObject *expected, PyObject *replacement) { assert(PyDict_Check(op)); - assert(expected != NULL && replacement != NULL); + assert(expected != NULL); + assert(replacement != NULL); + Py_hash_t hash = PyObject_Hash(key); if (hash == -1) { return -1; diff --git a/Objects/lazyimportobject.c b/Objects/lazyimportobject.c index 043f2a86ccc7765..f506da1a6e26f88 100644 --- a/Objects/lazyimportobject.c +++ b/Objects/lazyimportobject.c @@ -45,9 +45,16 @@ _PyLazyImport_New(_PyInterpreterFrame *frame, PyObject *builtins, "lazy_import: fromlist must be None, a string, or a tuple"); return NULL; } - assert(PyLazyImport_CheckExact(name) ? builtins == NULL : builtins != NULL); - assert(!PyLazyImport_CheckExact(name) || - (fromlist != NULL && PyUnicode_Check(fromlist))); +#ifndef NDEBUG + if (PyLazyImport_CheckExact(name)) { + assert(builtins == NULL); + assert(fromlist != NULL); + assert(PyUnicode_Check(fromlist)); + } + else { + assert(builtins != NULL); + } +#endif PyLazyImportObject *m = PyObject_GC_New( PyLazyImportObject, &PyLazyImport_Type); if (m == NULL) { From 88146d081263dd42bbd844e52c0db576975f5922 Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Wed, 30 Sep 2026 18:04:23 +0200 Subject: [PATCH 6/9] Add notes --- Objects/lazyimportobject.c | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Objects/lazyimportobject.c b/Objects/lazyimportobject.c index f506da1a6e26f88..e4a949a5456cfd2 100644 --- a/Objects/lazyimportobject.c +++ b/Objects/lazyimportobject.c @@ -16,8 +16,10 @@ typedef struct { PyObject_HEAD PyObject *lz_builtins; // Roots own the mapping; projections retain the root. - // A root stores its absolute name and original fromlist. A projection - // stores its source placeholder and the attribute to import from it. + // A root stores its absolute name (PyUnicode) in lz_from, and original + // fromlist in lz_attr. + // A projection stores its source placeholder (a PyLazyImportObject) + // in lz_from, and the attribute to import from (PyUnicode) it in lz_attr. PyObject *lz_from; PyObject *lz_attr; // Declaration location. @@ -47,11 +49,13 @@ _PyLazyImport_New(_PyInterpreterFrame *frame, PyObject *builtins, } #ifndef NDEBUG if (PyLazyImport_CheckExact(name)) { + // projection assert(builtins == NULL); assert(fromlist != NULL); assert(PyUnicode_Check(fromlist)); } else { + // root assert(builtins != NULL); } #endif @@ -78,6 +82,7 @@ _PyLazyImport_New(_PyInterpreterFrame *frame, PyObject *builtins, // Reuse concrete attributes of initialized modules without waiting for imports // or resolving lazy attributes. Failed cache lookups are retried at resolution. +// May return NULL with or without an exception set. static PyObject * lazy_import_get_loaded_attr(PyThreadState *tstate, PyObject *name, PyObject *attr_name) @@ -473,6 +478,7 @@ _PyImport_LoadLazyImportTstate(PyThreadState *tstate, PyObject *lazy_import) // Loading pkg.child can replace a placeholder in pkg.child with the module // before a from-import retrieves the value that belongs in that binding. +// This is an optimization that can be safely skipped. static int lazy_import_replace_child(PyThreadState *tstate, PyObject *placeholder, PyObject *name, PyObject *namespace, From e3b48f586154519748563ff5da98c8fd74078e2a Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Thu, 1 Oct 2026 13:15:12 +0200 Subject: [PATCH 7/9] Add test for adding notes to chained exceptions --- Lib/test/test_lazy_import/__init__.py | 23 +++++++++++++++++++ .../data/broken_module_chained_cause.py | 3 +++ .../data/broken_module_chained_context.py | 5 ++++ .../data/broken_module_chained_suppressed.py | 2 ++ 4 files changed, 33 insertions(+) create mode 100644 Lib/test/test_lazy_import/data/broken_module_chained_cause.py create mode 100644 Lib/test/test_lazy_import/data/broken_module_chained_context.py create mode 100644 Lib/test/test_lazy_import/data/broken_module_chained_suppressed.py diff --git a/Lib/test/test_lazy_import/__init__.py b/Lib/test/test_lazy_import/__init__.py index 5026c9670d81d23..56f7fccfdf766ab 100644 --- a/Lib/test/test_lazy_import/__init__.py +++ b/Lib/test/test_lazy_import/__init__.py @@ -766,6 +766,29 @@ def test_missing_lazy_from_import_shows_chained_traceback(self): """) assert_python_ok("-c", code) + @support.subTests('name', ( + 'test.test_lazy_import.data.broken_module_chained_cause', + 'test.test_lazy_import.data.broken_module_chained_context', + 'test.test_lazy_import.data.broken_module_chained_suppressed', + )) + def test_chained_exception_import_shows_notes(self, name): + """Accessing missing attribute from lazy from-import should chain errors.""" + code = textwrap.dedent(f""" + import re + lazy import {name} + + try: + _ = test + except ValueError as e: + assert any( + note.startswith("lazy import of '{name}' declared in ") + for note in e.__notes__ + ), e.__notes__ + else: + raise AssertionError("ImportError was not raised") + """) + assert_python_ok("-c", code) + def test_reification_retries_on_failure(self): """Failed reification should allow retry on subsequent access. diff --git a/Lib/test/test_lazy_import/data/broken_module_chained_cause.py b/Lib/test/test_lazy_import/data/broken_module_chained_cause.py new file mode 100644 index 000000000000000..786607b7bb557e7 --- /dev/null +++ b/Lib/test/test_lazy_import/data/broken_module_chained_cause.py @@ -0,0 +1,3 @@ +# Module that raises an exception with explicit cause during import +cause = ValueError("Cause of failure") +raise ValueError("This module always fails to import") from cause diff --git a/Lib/test/test_lazy_import/data/broken_module_chained_context.py b/Lib/test/test_lazy_import/data/broken_module_chained_context.py new file mode 100644 index 000000000000000..82640856e3e2d8a --- /dev/null +++ b/Lib/test/test_lazy_import/data/broken_module_chained_context.py @@ -0,0 +1,5 @@ +# Module that raises an exception with context during import +try: + raise ValueError("Cause of failure") +except: + raise ValueError("This module always fails to import") diff --git a/Lib/test/test_lazy_import/data/broken_module_chained_suppressed.py b/Lib/test/test_lazy_import/data/broken_module_chained_suppressed.py new file mode 100644 index 000000000000000..5c34654afc13bf5 --- /dev/null +++ b/Lib/test/test_lazy_import/data/broken_module_chained_suppressed.py @@ -0,0 +1,2 @@ +# Module that raises an exception with suppressed context during import +raise ValueError("This module always fails to import") from None From 523fa99e93cb27a25acc02edabd3ce67f12037c0 Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Thu, 1 Oct 2026 14:27:37 +0200 Subject: [PATCH 8/9] Add test for fromlist type checking --- Lib/test/test_lazy_import/__init__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_lazy_import/__init__.py b/Lib/test/test_lazy_import/__init__.py index 56f7fccfdf766ab..2cf7857d8c91d68 100644 --- a/Lib/test/test_lazy_import/__init__.py +++ b/Lib/test/test_lazy_import/__init__.py @@ -13,7 +13,7 @@ import contextlib from test import support -from test.support.script_helper import assert_python_ok +from test.support.script_helper import assert_python_ok, assert_python_failure try: import _testcapi @@ -617,6 +617,14 @@ def test_dunder_lazy_import_invalid_arguments(self): with self.assertRaises(TypeError): __lazy_import__("sys", globals=1) + code = textwrap.dedent(f""" + __lazy_import__("sys", fromlist=(1, 2, 3)) + """) + result = assert_python_failure("-c", code, NO_COLOR='y') + self.assertIn( + b"TypeError: Item in ``from list'' must be str, not int", + result.err) + def test_dunder_lazy_import_builtins(self): """__lazy_import__ should use module's __builtins__ for __import__.""" from test.test_lazy_import.data import dunder_lazy_import_builtins From e44cc181f05cbdbb8bad3c9ad5e74a0c99dfdaa0 Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Thu, 1 Oct 2026 17:16:30 +0200 Subject: [PATCH 9/9] Apply batched suggestions from code review Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- Lib/test/test_lazy_import/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Lib/test/test_lazy_import/__init__.py b/Lib/test/test_lazy_import/__init__.py index 2cf7857d8c91d68..f9ef8ce2cfef8c9 100644 --- a/Lib/test/test_lazy_import/__init__.py +++ b/Lib/test/test_lazy_import/__init__.py @@ -617,7 +617,7 @@ def test_dunder_lazy_import_invalid_arguments(self): with self.assertRaises(TypeError): __lazy_import__("sys", globals=1) - code = textwrap.dedent(f""" + code = textwrap.dedent(""" __lazy_import__("sys", fromlist=(1, 2, 3)) """) result = assert_python_failure("-c", code, NO_COLOR='y') @@ -782,7 +782,6 @@ def test_missing_lazy_from_import_shows_chained_traceback(self): def test_chained_exception_import_shows_notes(self, name): """Accessing missing attribute from lazy from-import should chain errors.""" code = textwrap.dedent(f""" - import re lazy import {name} try: