From 351473b6a188a71acd0524b00f19bb2577e6b1c1 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Sun, 26 Apr 2026 23:43:03 +0200 Subject: [PATCH 01/13] gh-148587: Make sys.lazy_modules an immutable frozendict snapshot Expose ``sys.lazy_modules`` via ``sys.__getattr__`` (PEP 562) as a ``frozendict[str, frozenset[str]]`` snapshot rebuilt on each access. The live registry is kept private to the import system, preventing user code from mutating it. Add ``_imp._clear_lazy_modules()`` for the test suite to reset registry state between tests. --- Include/internal/pycore_import.h | 2 + Lib/test/test_lazy_import/__init__.py | 36 ++++++++--- ...-04-26-20-44-58.gh-issue-148587.f791BR.rst | 6 ++ Python/clinic/import.c.h | 22 ++++++- Python/import.c | 63 +++++++++++++++++++ Python/sysmodule.c | 38 +++++++++-- 6 files changed, 153 insertions(+), 14 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-04-26-20-44-58.gh-issue-148587.f791BR.rst diff --git a/Include/internal/pycore_import.h b/Include/internal/pycore_import.h index 32ed3a62b2b4a7..93524e4279f844 100644 --- a/Include/internal/pycore_import.h +++ b/Include/internal/pycore_import.h @@ -84,6 +84,8 @@ extern void _PyImport_ClearModulesByIndex(PyInterpreterState *interp); extern PyObject * _PyImport_InitLazyModules( PyInterpreterState *interp); extern void _PyImport_ClearLazyModules(PyInterpreterState *interp); +extern PyObject * _PyImport_GetLazyModulesSnapshot( + PyInterpreterState *interp); extern int _PyImport_InitDefaultImportFunc(PyInterpreterState *interp); extern int _PyImport_IsDefaultImportFunc( diff --git a/Lib/test/test_lazy_import/__init__.py b/Lib/test/test_lazy_import/__init__.py index a9a8cd143e0d75..e912563b9a864c 100644 --- a/Lib/test/test_lazy_import/__init__.py +++ b/Lib/test/test_lazy_import/__init__.py @@ -1,5 +1,6 @@ """Tests for PEP 810 lazy imports.""" +import _imp import io import dis import subprocess @@ -31,7 +32,7 @@ def tearDown(self): sys.set_lazy_imports_filter(None) sys.set_lazy_imports("normal") - sys.lazy_modules.clear() + _imp._clear_lazy_modules() def test_basic_unused(self): """Lazy imported module should not be loaded if never accessed.""" @@ -484,9 +485,28 @@ def my_filter(name): sys.set_lazy_imports_filter(my_filter) self.assertIs(sys.get_lazy_imports_filter(), my_filter) - def test_lazy_modules_attribute_is_dict(self): - """sys.lazy_modules should be a dict per PEP 810.""" - self.assertIsInstance(sys.lazy_modules, dict) + def test_lazy_modules_attribute_is_frozendict(self): + """sys.lazy_modules should be a frozendict snapshot.""" + snapshot = sys.lazy_modules + self.assertIsInstance(snapshot, frozendict) + for value in snapshot.values(): + self.assertIsInstance(value, frozenset) + + def test_lazy_modules_returns_fresh_snapshot(self): + """Each access of sys.lazy_modules should return a fresh snapshot.""" + first = sys.lazy_modules + second = sys.lazy_modules + # Snapshots are independent objects, even though they may compare equal. + self.assertIsNot(first, second) + self.assertEqual(first, second) + + def test_lazy_modules_is_immutable(self): + """Mutation through sys.lazy_modules must not be possible.""" + snapshot = sys.lazy_modules + with self.assertRaises(TypeError): + snapshot["foo"] = frozenset() + with self.assertRaises(AttributeError): + snapshot.clear() @support.requires_subprocess() def test_lazy_modules_tracks_lazy_imports(self): @@ -966,8 +986,8 @@ def test_module_added_to_lazy_modules_on_lazy_import(self): def test_lazy_modules_is_per_interpreter(self): """Each interpreter should have independent sys.lazy_modules.""" - # Basic test that sys.lazy_modules exists and is a dict - self.assertIsInstance(sys.lazy_modules, dict) + # Basic test that sys.lazy_modules exists and is an immutable snapshot. + self.assertIsInstance(sys.lazy_modules, frozendict) def test_lazy_module_without_children_is_tracked(self): code = textwrap.dedent(""" @@ -1804,7 +1824,9 @@ def create_lazy_imports(idx): t.join() assert not errors, f"Errors: {errors}" - assert isinstance(sys.lazy_modules, dict), "sys.lazy_modules is not a dict" + assert isinstance(sys.lazy_modules, frozendict), ( + "sys.lazy_modules is not a frozendict" + ) print("OK") """) diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-04-26-20-44-58.gh-issue-148587.f791BR.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-04-26-20-44-58.gh-issue-148587.f791BR.rst new file mode 100644 index 00000000000000..8d66acc61f62ca --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-04-26-20-44-58.gh-issue-148587.f791BR.rst @@ -0,0 +1,6 @@ +:data:`sys.lazy_modules` is now an immutable :class:`frozendict` snapshot of +the lazy import registry, with :class:`frozenset` values, instead of a live +mutable :class:`dict` of :class:`set` objects. Each access of +``sys.lazy_modules`` returns a fresh snapshot built via +:meth:`sys.__getattr__`, so the internal bookkeeping structure stays private +to the import system. diff --git a/Python/clinic/import.c.h b/Python/clinic/import.c.h index de62714ebddafa..83e0fc96f5ceff 100644 --- a/Python/clinic/import.c.h +++ b/Python/clinic/import.c.h @@ -657,6 +657,26 @@ _imp__set_lazy_attributes(PyObject *module, PyObject *const *args, Py_ssize_t na return return_value; } +PyDoc_STRVAR(_imp__clear_lazy_modules__doc__, +"_clear_lazy_modules($module, /)\n" +"--\n" +"\n" +"Clear the per-interpreter lazy import registry.\n" +"\n" +"(internal-only) Used by the test suite to reset state between tests."); + +#define _IMP__CLEAR_LAZY_MODULES_METHODDEF \ + {"_clear_lazy_modules", (PyCFunction)_imp__clear_lazy_modules, METH_NOARGS, _imp__clear_lazy_modules__doc__}, + +static PyObject * +_imp__clear_lazy_modules_impl(PyObject *module); + +static PyObject * +_imp__clear_lazy_modules(PyObject *module, PyObject *Py_UNUSED(ignored)) +{ + return _imp__clear_lazy_modules_impl(module); +} + #ifndef _IMP_CREATE_DYNAMIC_METHODDEF #define _IMP_CREATE_DYNAMIC_METHODDEF #endif /* !defined(_IMP_CREATE_DYNAMIC_METHODDEF) */ @@ -664,4 +684,4 @@ _imp__set_lazy_attributes(PyObject *module, PyObject *const *args, Py_ssize_t na #ifndef _IMP_EXEC_DYNAMIC_METHODDEF #define _IMP_EXEC_DYNAMIC_METHODDEF #endif /* !defined(_IMP_EXEC_DYNAMIC_METHODDEF) */ -/*[clinic end generated code: output=5fa42f580441b3fa input=a9049054013a1b77]*/ +/*[clinic end generated code: output=daca49038f5e889c input=a9049054013a1b77]*/ diff --git a/Python/import.c b/Python/import.c index 7aa96196ec1e10..55c9372d06c33a 100644 --- a/Python/import.c +++ b/Python/import.c @@ -282,6 +282,48 @@ _PyImport_ClearLazyModules(PyInterpreterState *interp) Py_CLEAR(LAZY_MODULES(interp)); } +PyObject * +_PyImport_GetLazyModulesSnapshot(PyInterpreterState *interp) +{ + PyObject *lazy_modules = LAZY_MODULES(interp); + if (lazy_modules == NULL) { + return PyFrozenDict_New(NULL); + } + + PyObject *tmp = PyDict_New(); + if (tmp == NULL) { + return NULL; + } + + int err = 0; + Py_BEGIN_CRITICAL_SECTION(lazy_modules); + Py_ssize_t pos = 0; + PyObject *key, *value; + while (PyDict_Next(lazy_modules, &pos, &key, &value)) { + PyObject *frozen = PyFrozenSet_New(value); + if (frozen == NULL) { + err = -1; + break; + } + if (PyDict_SetItem(tmp, key, frozen) < 0) { + Py_DECREF(frozen); + err = -1; + break; + } + Py_DECREF(frozen); + } + Py_END_CRITICAL_SECTION(); + + if (err < 0) { + Py_DECREF(tmp); + return NULL; + } + + PyObject *snapshot = PyFrozenDict_New(tmp); + Py_DECREF(tmp); + return snapshot; +} + static int import_ensure_initialized(PyInterpreterState *interp, PyObject *mod, PyObject *name) { @@ -5672,6 +5714,26 @@ _imp__set_lazy_attributes_impl(PyObject *module, PyObject *modobj, return ret; } +/*[clinic input] +_imp._clear_lazy_modules + +Clear the per-interpreter lazy import registry. + +(internal-only) Used by the test suite to reset state between tests. +[clinic start generated code]*/ + +static PyObject * +_imp__clear_lazy_modules_impl(PyObject *module) +/*[clinic end generated code: output=8c1e605969f1d16b input=0c01a8d30cdebed2]*/ +{ + PyInterpreterState *interp = _PyInterpreterState_GET(); + PyObject *lazy_modules = LAZY_MODULES(interp); + if (lazy_modules != NULL) { + PyDict_Clear(lazy_modules); + } + Py_RETURN_NONE; +} + PyDoc_STRVAR(doc_imp, "(Extremely) low-level import machinery bits as used by importlib."); @@ -5696,6 +5758,7 @@ static PyMethodDef imp_methods[] = { _IMP__FIX_CO_FILENAME_METHODDEF _IMP_SOURCE_HASH_METHODDEF _IMP__SET_LAZY_ATTRIBUTES_METHODDEF + _IMP__CLEAR_LAZY_MODULES_METHODDEF {NULL, NULL} /* sentinel */ }; diff --git a/Python/sysmodule.c b/Python/sysmodule.c index 1ee0b3bec684f9..b3a900f5a0a537 100644 --- a/Python/sysmodule.c +++ b/Python/sysmodule.c @@ -2909,8 +2909,36 @@ sys_get_lazy_imports_impl(PyObject *module) } } +PyDoc_STRVAR(sys_getattr_doc, +"__getattr__($module, name, /)\n" +"--\n" +"\n" +"Module-level fallback attribute access for sys.\n" +"\n" +"Used to expose dynamic, snapshot-style attributes such as\n" +"``sys.lazy_modules``, which is rebuilt on each access as a\n" +"``frozendict[str, frozenset[str]]`` view of the lazy import registry."); + +static PyObject * +sys_getattr(PyObject *self, PyObject *name) +{ + if (!PyUnicode_Check(name)) { + PyErr_Format(PyExc_TypeError, + "attribute name must be string, not '%T'", name); + return NULL; + } + if (_PyUnicode_EqualToASCIIString(name, "lazy_modules")) { + PyInterpreterState *interp = _PyInterpreterState_GET(); + return _PyImport_GetLazyModulesSnapshot(interp); + } + PyErr_Format(PyExc_AttributeError, + "module 'sys' has no attribute %R", name); + return NULL; +} + static PyMethodDef sys_methods[] = { /* Might as well keep this in alphabetic order */ + {"__getattr__", sys_getattr, METH_O, sys_getattr_doc}, SYS_ADDAUDITHOOK_METHODDEF SYS_AUDIT_METHODDEF {"breakpointhook", _PyCFunction_CAST(sys_breakpointhook), @@ -4353,12 +4381,10 @@ _PySys_Create(PyThreadState *tstate, PyObject **sysmod_p) goto error; } - PyObject *lazy_modules = _PyImport_InitLazyModules(interp); // borrowed reference - if (lazy_modules == NULL) { - goto error; - } - - if (PyDict_SetItemString(sysdict, "lazy_modules", lazy_modules) < 0) { + // The lazy import registry is kept private. ``sys.lazy_modules`` is + // exposed via ``sys.__getattr__`` as a frozendict snapshot built on + // each access (see ``sys_getattr``). + if (_PyImport_InitLazyModules(interp) == NULL) { goto error; } From 083bbe99cf66881ca407bdf5e81b4f4c569e2521 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 5 May 2026 00:02:14 +0200 Subject: [PATCH 02/13] gh-148587: Expose live lazy import registry as sys._lazy_modules The live mutable registry of lazy imports is exposed (undocumented) as ``sys._lazy_modules``. ``sys.lazy_modules`` remains an immutable ``frozendict`` snapshot rebuilt on each access via ``sys.__getattr__``. Tests reset state with ``sys._lazy_modules.clear()``; the temporary ``_imp._clear_lazy_modules()`` helper is removed. --- Lib/test/test_lazy_import/__init__.py | 13 +++++++++-- ...-04-26-20-44-58.gh-issue-148587.f791BR.rst | 4 ++-- Python/clinic/import.c.h | 22 +------------------ Python/import.c | 21 ------------------ Python/sysmodule.c | 16 +++++++++----- 5 files changed, 25 insertions(+), 51 deletions(-) diff --git a/Lib/test/test_lazy_import/__init__.py b/Lib/test/test_lazy_import/__init__.py index e912563b9a864c..f18c787268f167 100644 --- a/Lib/test/test_lazy_import/__init__.py +++ b/Lib/test/test_lazy_import/__init__.py @@ -1,6 +1,5 @@ """Tests for PEP 810 lazy imports.""" -import _imp import io import dis import subprocess @@ -32,7 +31,7 @@ def tearDown(self): sys.set_lazy_imports_filter(None) sys.set_lazy_imports("normal") - _imp._clear_lazy_modules() + sys._lazy_modules.clear() def test_basic_unused(self): """Lazy imported module should not be loaded if never accessed.""" @@ -508,6 +507,16 @@ def test_lazy_modules_is_immutable(self): with self.assertRaises(AttributeError): snapshot.clear() + def test_underscore_lazy_modules_is_live_dict(self): + """sys._lazy_modules should be the live, mutable registry.""" + registry = sys._lazy_modules + self.assertIsInstance(registry, dict) + self.assertNotIsInstance(registry, frozendict) + # Same identity across accesses (it is the registry itself). + self.assertIs(sys._lazy_modules, registry) + # Snapshot reflects the live registry contents. + self.assertEqual(dict(sys.lazy_modules), dict(registry)) + @support.requires_subprocess() def test_lazy_modules_tracks_lazy_imports(self): """sys.lazy_modules should track lazily imported module names.""" diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-04-26-20-44-58.gh-issue-148587.f791BR.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-04-26-20-44-58.gh-issue-148587.f791BR.rst index 8d66acc61f62ca..ff04623afdb0c9 100644 --- a/Misc/NEWS.d/next/Core_and_Builtins/2026-04-26-20-44-58.gh-issue-148587.f791BR.rst +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-04-26-20-44-58.gh-issue-148587.f791BR.rst @@ -2,5 +2,5 @@ the lazy import registry, with :class:`frozenset` values, instead of a live mutable :class:`dict` of :class:`set` objects. Each access of ``sys.lazy_modules`` returns a fresh snapshot built via -:meth:`sys.__getattr__`, so the internal bookkeeping structure stays private -to the import system. +:meth:`sys.__getattr__`. The live registry remains available (undocumented) +as ``sys._lazy_modules``. diff --git a/Python/clinic/import.c.h b/Python/clinic/import.c.h index 83e0fc96f5ceff..de62714ebddafa 100644 --- a/Python/clinic/import.c.h +++ b/Python/clinic/import.c.h @@ -657,26 +657,6 @@ _imp__set_lazy_attributes(PyObject *module, PyObject *const *args, Py_ssize_t na return return_value; } -PyDoc_STRVAR(_imp__clear_lazy_modules__doc__, -"_clear_lazy_modules($module, /)\n" -"--\n" -"\n" -"Clear the per-interpreter lazy import registry.\n" -"\n" -"(internal-only) Used by the test suite to reset state between tests."); - -#define _IMP__CLEAR_LAZY_MODULES_METHODDEF \ - {"_clear_lazy_modules", (PyCFunction)_imp__clear_lazy_modules, METH_NOARGS, _imp__clear_lazy_modules__doc__}, - -static PyObject * -_imp__clear_lazy_modules_impl(PyObject *module); - -static PyObject * -_imp__clear_lazy_modules(PyObject *module, PyObject *Py_UNUSED(ignored)) -{ - return _imp__clear_lazy_modules_impl(module); -} - #ifndef _IMP_CREATE_DYNAMIC_METHODDEF #define _IMP_CREATE_DYNAMIC_METHODDEF #endif /* !defined(_IMP_CREATE_DYNAMIC_METHODDEF) */ @@ -684,4 +664,4 @@ _imp__clear_lazy_modules(PyObject *module, PyObject *Py_UNUSED(ignored)) #ifndef _IMP_EXEC_DYNAMIC_METHODDEF #define _IMP_EXEC_DYNAMIC_METHODDEF #endif /* !defined(_IMP_EXEC_DYNAMIC_METHODDEF) */ -/*[clinic end generated code: output=daca49038f5e889c input=a9049054013a1b77]*/ +/*[clinic end generated code: output=5fa42f580441b3fa input=a9049054013a1b77]*/ diff --git a/Python/import.c b/Python/import.c index 55c9372d06c33a..b30dad9dda8918 100644 --- a/Python/import.c +++ b/Python/import.c @@ -5714,26 +5714,6 @@ _imp__set_lazy_attributes_impl(PyObject *module, PyObject *modobj, return ret; } -/*[clinic input] -_imp._clear_lazy_modules - -Clear the per-interpreter lazy import registry. - -(internal-only) Used by the test suite to reset state between tests. -[clinic start generated code]*/ - -static PyObject * -_imp__clear_lazy_modules_impl(PyObject *module) -/*[clinic end generated code: output=8c1e605969f1d16b input=0c01a8d30cdebed2]*/ -{ - PyInterpreterState *interp = _PyInterpreterState_GET(); - PyObject *lazy_modules = LAZY_MODULES(interp); - if (lazy_modules != NULL) { - PyDict_Clear(lazy_modules); - } - Py_RETURN_NONE; -} - PyDoc_STRVAR(doc_imp, "(Extremely) low-level import machinery bits as used by importlib."); @@ -5758,7 +5738,6 @@ static PyMethodDef imp_methods[] = { _IMP__FIX_CO_FILENAME_METHODDEF _IMP_SOURCE_HASH_METHODDEF _IMP__SET_LAZY_ATTRIBUTES_METHODDEF - _IMP__CLEAR_LAZY_MODULES_METHODDEF {NULL, NULL} /* sentinel */ }; diff --git a/Python/sysmodule.c b/Python/sysmodule.c index b3a900f5a0a537..bac201074a9e34 100644 --- a/Python/sysmodule.c +++ b/Python/sysmodule.c @@ -2917,7 +2917,8 @@ PyDoc_STRVAR(sys_getattr_doc, "\n" "Used to expose dynamic, snapshot-style attributes such as\n" "``sys.lazy_modules``, which is rebuilt on each access as a\n" -"``frozendict[str, frozenset[str]]`` view of the lazy import registry."); +"``frozendict[str, frozenset[str]]`` snapshot of the live lazy import\n" +"registry exposed (undocumented) as ``sys._lazy_modules``."); static PyObject * sys_getattr(PyObject *self, PyObject *name) @@ -4381,10 +4382,15 @@ _PySys_Create(PyThreadState *tstate, PyObject **sysmod_p) goto error; } - // The lazy import registry is kept private. ``sys.lazy_modules`` is - // exposed via ``sys.__getattr__`` as a frozendict snapshot built on - // each access (see ``sys_getattr``). - if (_PyImport_InitLazyModules(interp) == NULL) { + // The live lazy import registry is exposed (undocumented) as + // ``sys._lazy_modules``. ``sys.lazy_modules`` is exposed via + // ``sys.__getattr__`` as a frozendict snapshot rebuilt on each access + // (see ``sys_getattr``). + PyObject *lazy_modules = _PyImport_InitLazyModules(interp); + if (lazy_modules == NULL) { + goto error; + } + if (PyDict_SetItemString(sysdict, "_lazy_modules", lazy_modules) < 0) { goto error; } From eac60ede1e87f8eab8008a2a79a1e7e00a256bfb Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 5 May 2026 02:11:43 +0200 Subject: [PATCH 03/13] gh-148587: Address review feedback on sys.lazy_modules - Reword NEWS to describe contract (read-only frozendict mapping module names to frozensets of submodule names) rather than implementation details. Match PEP 810 wording ("mapping"). - Drop the sys.__getattr__ docstring and restore the borrowed-reference comment in _PySys_Create. - Use "mapping" in test docstrings/comments that describe the contract while leaving "snapshot" in implementation-detail spots. --- Lib/test/test_lazy_import/__init__.py | 8 ++++---- ...-04-26-20-44-58.gh-issue-148587.f791BR.rst | 11 +++++----- Python/sysmodule.c | 20 ++++--------------- 3 files changed, 13 insertions(+), 26 deletions(-) diff --git a/Lib/test/test_lazy_import/__init__.py b/Lib/test/test_lazy_import/__init__.py index f18c787268f167..5e4d9b7442e206 100644 --- a/Lib/test/test_lazy_import/__init__.py +++ b/Lib/test/test_lazy_import/__init__.py @@ -485,14 +485,14 @@ def my_filter(name): self.assertIs(sys.get_lazy_imports_filter(), my_filter) def test_lazy_modules_attribute_is_frozendict(self): - """sys.lazy_modules should be a frozendict snapshot.""" + """sys.lazy_modules should be an immutable frozendict mapping.""" snapshot = sys.lazy_modules self.assertIsInstance(snapshot, frozendict) for value in snapshot.values(): self.assertIsInstance(value, frozenset) - def test_lazy_modules_returns_fresh_snapshot(self): - """Each access of sys.lazy_modules should return a fresh snapshot.""" + def test_lazy_modules_returns_fresh_mapping(self): + """Each access of sys.lazy_modules should return a fresh mapping.""" first = sys.lazy_modules second = sys.lazy_modules # Snapshots are independent objects, even though they may compare equal. @@ -995,7 +995,7 @@ def test_module_added_to_lazy_modules_on_lazy_import(self): def test_lazy_modules_is_per_interpreter(self): """Each interpreter should have independent sys.lazy_modules.""" - # Basic test that sys.lazy_modules exists and is an immutable snapshot. + # Basic test that sys.lazy_modules exists and is an immutable mapping. self.assertIsInstance(sys.lazy_modules, frozendict) def test_lazy_module_without_children_is_tracked(self): diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-04-26-20-44-58.gh-issue-148587.f791BR.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-04-26-20-44-58.gh-issue-148587.f791BR.rst index ff04623afdb0c9..ac46e0c77ac91e 100644 --- a/Misc/NEWS.d/next/Core_and_Builtins/2026-04-26-20-44-58.gh-issue-148587.f791BR.rst +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-04-26-20-44-58.gh-issue-148587.f791BR.rst @@ -1,6 +1,5 @@ -:data:`sys.lazy_modules` is now an immutable :class:`frozendict` snapshot of -the lazy import registry, with :class:`frozenset` values, instead of a live -mutable :class:`dict` of :class:`set` objects. Each access of -``sys.lazy_modules`` returns a fresh snapshot built via -:meth:`sys.__getattr__`. The live registry remains available (undocumented) -as ``sys._lazy_modules``. +:data:`sys.lazy_modules` is now a read-only :class:`frozendict` mapping +each module's fully qualified name to the :class:`frozenset` of its +lazily-imported submodule names, instead of a live mutable :class:`dict` +of :class:`set` objects. User code can no longer mutate the lazy import +registry through this attribute. diff --git a/Python/sysmodule.c b/Python/sysmodule.c index bac201074a9e34..37ab17d5aabe8d 100644 --- a/Python/sysmodule.c +++ b/Python/sysmodule.c @@ -2909,17 +2909,6 @@ sys_get_lazy_imports_impl(PyObject *module) } } -PyDoc_STRVAR(sys_getattr_doc, -"__getattr__($module, name, /)\n" -"--\n" -"\n" -"Module-level fallback attribute access for sys.\n" -"\n" -"Used to expose dynamic, snapshot-style attributes such as\n" -"``sys.lazy_modules``, which is rebuilt on each access as a\n" -"``frozendict[str, frozenset[str]]`` snapshot of the live lazy import\n" -"registry exposed (undocumented) as ``sys._lazy_modules``."); - static PyObject * sys_getattr(PyObject *self, PyObject *name) { @@ -2939,7 +2928,7 @@ sys_getattr(PyObject *self, PyObject *name) static PyMethodDef sys_methods[] = { /* Might as well keep this in alphabetic order */ - {"__getattr__", sys_getattr, METH_O, sys_getattr_doc}, + {"__getattr__", sys_getattr, METH_O, NULL}, SYS_ADDAUDITHOOK_METHODDEF SYS_AUDIT_METHODDEF {"breakpointhook", _PyCFunction_CAST(sys_breakpointhook), @@ -4383,10 +4372,9 @@ _PySys_Create(PyThreadState *tstate, PyObject **sysmod_p) } // The live lazy import registry is exposed (undocumented) as - // ``sys._lazy_modules``. ``sys.lazy_modules`` is exposed via - // ``sys.__getattr__`` as a frozendict snapshot rebuilt on each access - // (see ``sys_getattr``). - PyObject *lazy_modules = _PyImport_InitLazyModules(interp); + // ``sys._lazy_modules``. The public ``sys.lazy_modules`` is built on + // each access by ``sys.__getattr__`` (see ``sys_getattr``). + PyObject *lazy_modules = _PyImport_InitLazyModules(interp); // borrowed reference if (lazy_modules == NULL) { goto error; } From 0424a64806a0eb2c2ed31a85d7faf204dd4c6401 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 5 May 2026 02:28:12 +0200 Subject: [PATCH 04/13] gh-148587: Trim sys.lazy_modules contract test and NEWS Drop test_lazy_modules_is_immutable; the frozendict assertion already covers immutability. Trim the trailing sentence in the NEWS entry that restated the same contract. --- Lib/test/test_lazy_import/__init__.py | 8 -------- .../2026-04-26-20-44-58.gh-issue-148587.f791BR.rst | 3 +-- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/Lib/test/test_lazy_import/__init__.py b/Lib/test/test_lazy_import/__init__.py index 5e4d9b7442e206..eb62b932a137fb 100644 --- a/Lib/test/test_lazy_import/__init__.py +++ b/Lib/test/test_lazy_import/__init__.py @@ -499,14 +499,6 @@ def test_lazy_modules_returns_fresh_mapping(self): self.assertIsNot(first, second) self.assertEqual(first, second) - def test_lazy_modules_is_immutable(self): - """Mutation through sys.lazy_modules must not be possible.""" - snapshot = sys.lazy_modules - with self.assertRaises(TypeError): - snapshot["foo"] = frozenset() - with self.assertRaises(AttributeError): - snapshot.clear() - def test_underscore_lazy_modules_is_live_dict(self): """sys._lazy_modules should be the live, mutable registry.""" registry = sys._lazy_modules diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-04-26-20-44-58.gh-issue-148587.f791BR.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-04-26-20-44-58.gh-issue-148587.f791BR.rst index ac46e0c77ac91e..a4c653b1aa12a7 100644 --- a/Misc/NEWS.d/next/Core_and_Builtins/2026-04-26-20-44-58.gh-issue-148587.f791BR.rst +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-04-26-20-44-58.gh-issue-148587.f791BR.rst @@ -1,5 +1,4 @@ :data:`sys.lazy_modules` is now a read-only :class:`frozendict` mapping each module's fully qualified name to the :class:`frozenset` of its lazily-imported submodule names, instead of a live mutable :class:`dict` -of :class:`set` objects. User code can no longer mutate the lazy import -registry through this attribute. +of :class:`set` objects. From 44049725abc3360b01a42f391dad5161ef317312 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 5 May 2026 02:33:30 +0200 Subject: [PATCH 05/13] gh-148587: Align sys.__getattr__ with _ctypes/zlib pattern Drop the defensive PyUnicode_Check that raised TypeError, and use the public PyUnicode_EqualToUTF8 instead of _PyUnicode_EqualToASCIIString. A non-string name now falls through to AttributeError, matching the existing C-module __getattr__ idiom in _ctypes/callproc.c and zlibmodule.c. --- Python/sysmodule.c | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/Python/sysmodule.c b/Python/sysmodule.c index 37ab17d5aabe8d..c8ecf972d6bc47 100644 --- a/Python/sysmodule.c +++ b/Python/sysmodule.c @@ -2912,12 +2912,7 @@ sys_get_lazy_imports_impl(PyObject *module) static PyObject * sys_getattr(PyObject *self, PyObject *name) { - if (!PyUnicode_Check(name)) { - PyErr_Format(PyExc_TypeError, - "attribute name must be string, not '%T'", name); - return NULL; - } - if (_PyUnicode_EqualToASCIIString(name, "lazy_modules")) { + if (PyUnicode_Check(name) && PyUnicode_EqualToUTF8(name, "lazy_modules")) { PyInterpreterState *interp = _PyInterpreterState_GET(); return _PyImport_GetLazyModulesSnapshot(interp); } From f4f8fb2a47c07af69f7d8c724e55cb7a741c0f1a Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 5 May 2026 02:35:19 +0200 Subject: [PATCH 06/13] gh-148587: Use METH_VARARGS for sys.__getattr__ Switch sys.__getattr__ to METH_VARARGS with PyArg_UnpackTuple, matching the existing C-module __getattr__ pattern in _ctypes/callproc.c and zlibmodule.c. --- Python/sysmodule.c | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/Python/sysmodule.c b/Python/sysmodule.c index c8ecf972d6bc47..5c1ffe883f1386 100644 --- a/Python/sysmodule.c +++ b/Python/sysmodule.c @@ -2910,20 +2910,25 @@ sys_get_lazy_imports_impl(PyObject *module) } static PyObject * -sys_getattr(PyObject *self, PyObject *name) +sys_getattr(PyObject *self, PyObject *args) { + PyObject *name; + if (!PyArg_UnpackTuple(args, "__getattr__", 1, 1, &name)) { + return NULL; + } + if (PyUnicode_Check(name) && PyUnicode_EqualToUTF8(name, "lazy_modules")) { PyInterpreterState *interp = _PyInterpreterState_GET(); return _PyImport_GetLazyModulesSnapshot(interp); } - PyErr_Format(PyExc_AttributeError, - "module 'sys' has no attribute %R", name); + + PyErr_Format(PyExc_AttributeError, "module 'sys' has no attribute %R", name); return NULL; } static PyMethodDef sys_methods[] = { /* Might as well keep this in alphabetic order */ - {"__getattr__", sys_getattr, METH_O, NULL}, + {"__getattr__", sys_getattr, METH_VARARGS}, SYS_ADDAUDITHOOK_METHODDEF SYS_AUDIT_METHODDEF {"breakpointhook", _PyCFunction_CAST(sys_breakpointhook), From a94f4e7a6672819948c173088688b17859ccf127 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 5 May 2026 02:38:36 +0200 Subject: [PATCH 07/13] gh-148587: Pre-size and short-circuit empty in lazy modules snapshot Pre-size the temporary dict in _PyImport_GetLazyModulesSnapshot via _PyDict_NewPresized to avoid resizes during the inline copy. Add a fast path that returns an empty frozendict directly when the registry has no entries, skipping the temporary dict allocation entirely. --- Python/import.c | 44 +++++++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/Python/import.c b/Python/import.c index b30dad9dda8918..e2767d2b787837 100644 --- a/Python/import.c +++ b/Python/import.c @@ -290,34 +290,44 @@ _PyImport_GetLazyModulesSnapshot(PyInterpreterState *interp) return PyFrozenDict_New(NULL); } - PyObject *tmp = PyDict_New(); - if (tmp == NULL) { - return NULL; - } - + PyObject *tmp = NULL; int err = 0; + Py_BEGIN_CRITICAL_SECTION(lazy_modules); - Py_ssize_t pos = 0; - PyObject *key, *value; - while (PyDict_Next(lazy_modules, &pos, &key, &value)) { - PyObject *frozen = PyFrozenSet_New(value); - if (frozen == NULL) { + Py_ssize_t size = PyDict_GET_SIZE(lazy_modules); + if (size > 0) { + tmp = _PyDict_NewPresized(size); + if (tmp == NULL) { err = -1; - break; } - if (PyDict_SetItem(tmp, key, frozen) < 0) { - Py_DECREF(frozen); - err = -1; - break; + else { + Py_ssize_t pos = 0; + PyObject *key, *value; + while (PyDict_Next(lazy_modules, &pos, &key, &value)) { + PyObject *frozen = PyFrozenSet_New(value); + if (frozen == NULL) { + err = -1; + break; + } + if (PyDict_SetItem(tmp, key, frozen) < 0) { + Py_DECREF(frozen); + err = -1; + break; + } + Py_DECREF(frozen); + } } - Py_DECREF(frozen); } Py_END_CRITICAL_SECTION(); if (err < 0) { - Py_DECREF(tmp); + Py_XDECREF(tmp); return NULL; } + if (tmp == NULL) { + // Registry is empty. + return PyFrozenDict_New(NULL); + } PyObject *snapshot = PyFrozenDict_New(tmp); Py_DECREF(tmp); From 926a02eecdd6e9a8d2c7d92641e3a02fbe738121 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 5 May 2026 02:42:25 +0200 Subject: [PATCH 08/13] Revert pre-size and empty fast path in lazy modules snapshot Drop _PyDict_NewPresized and the empty-registry short-circuit. Both were micro-optimizations with negligible impact for typical registry sizes; the simpler unconditional PyDict_New + iterate path is easier to read. --- Python/import.c | 44 +++++++++++++++++--------------------------- 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/Python/import.c b/Python/import.c index e2767d2b787837..b30dad9dda8918 100644 --- a/Python/import.c +++ b/Python/import.c @@ -290,44 +290,34 @@ _PyImport_GetLazyModulesSnapshot(PyInterpreterState *interp) return PyFrozenDict_New(NULL); } - PyObject *tmp = NULL; - int err = 0; + PyObject *tmp = PyDict_New(); + if (tmp == NULL) { + return NULL; + } + int err = 0; Py_BEGIN_CRITICAL_SECTION(lazy_modules); - Py_ssize_t size = PyDict_GET_SIZE(lazy_modules); - if (size > 0) { - tmp = _PyDict_NewPresized(size); - if (tmp == NULL) { + Py_ssize_t pos = 0; + PyObject *key, *value; + while (PyDict_Next(lazy_modules, &pos, &key, &value)) { + PyObject *frozen = PyFrozenSet_New(value); + if (frozen == NULL) { err = -1; + break; } - else { - Py_ssize_t pos = 0; - PyObject *key, *value; - while (PyDict_Next(lazy_modules, &pos, &key, &value)) { - PyObject *frozen = PyFrozenSet_New(value); - if (frozen == NULL) { - err = -1; - break; - } - if (PyDict_SetItem(tmp, key, frozen) < 0) { - Py_DECREF(frozen); - err = -1; - break; - } - Py_DECREF(frozen); - } + if (PyDict_SetItem(tmp, key, frozen) < 0) { + Py_DECREF(frozen); + err = -1; + break; } + Py_DECREF(frozen); } Py_END_CRITICAL_SECTION(); if (err < 0) { - Py_XDECREF(tmp); + Py_DECREF(tmp); return NULL; } - if (tmp == NULL) { - // Registry is empty. - return PyFrozenDict_New(NULL); - } PyObject *snapshot = PyFrozenDict_New(tmp); Py_DECREF(tmp); From ca32d07bedc36ebb269f473fff119ce0cd34937b Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 5 May 2026 14:13:31 +0200 Subject: [PATCH 09/13] Add docstring to sys.__getattr__ --- Python/sysmodule.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Python/sysmodule.c b/Python/sysmodule.c index 5c1ffe883f1386..7dd376b34c03b7 100644 --- a/Python/sysmodule.c +++ b/Python/sysmodule.c @@ -2928,7 +2928,7 @@ sys_getattr(PyObject *self, PyObject *args) static PyMethodDef sys_methods[] = { /* Might as well keep this in alphabetic order */ - {"__getattr__", sys_getattr, METH_VARARGS}, + {"__getattr__", sys_getattr, METH_VARARGS, "Module __getattr__"}, SYS_ADDAUDITHOOK_METHODDEF SYS_AUDIT_METHODDEF {"breakpointhook", _PyCFunction_CAST(sys_breakpointhook), From 3dff9ef08fa8da73080233945f573e8951e36ed9 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 5 May 2026 14:17:59 +0200 Subject: [PATCH 10/13] Add sys.__dir__ so lazy_modules appears in tab completion --- Python/sysmodule.c | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Python/sysmodule.c b/Python/sysmodule.c index 7dd376b34c03b7..06f02af567c18f 100644 --- a/Python/sysmodule.c +++ b/Python/sysmodule.c @@ -2926,8 +2926,26 @@ sys_getattr(PyObject *self, PyObject *args) return NULL; } +static PyObject * +sys_dir(PyObject *self, PyObject *args) +{ + PyObject *names = PyMapping_Keys(((PyModuleObject *)self)->md_dict); + if (names == NULL) { + return NULL; + } + PyObject *lazy = PyUnicode_FromString("lazy_modules"); + int err = lazy ? PyList_Append(names, lazy) : -1; + Py_XDECREF(lazy); + if (err < 0) { + Py_DECREF(names); + return NULL; + } + return names; +} + static PyMethodDef sys_methods[] = { /* Might as well keep this in alphabetic order */ + {"__dir__", sys_dir, METH_NOARGS, "Module __dir__"}, {"__getattr__", sys_getattr, METH_VARARGS, "Module __getattr__"}, SYS_ADDAUDITHOOK_METHODDEF SYS_AUDIT_METHODDEF From 2c0e3f2d8e89c28c7334d7ed493ec841cc55a4e1 Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 5 May 2026 14:30:54 +0200 Subject: [PATCH 11/13] Fix METH_NOARGS signature and wrap long line in sys_dir/sys_getattr --- Python/sysmodule.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Python/sysmodule.c b/Python/sysmodule.c index 06f02af567c18f..6149baa3b9ad6a 100644 --- a/Python/sysmodule.c +++ b/Python/sysmodule.c @@ -2922,12 +2922,13 @@ sys_getattr(PyObject *self, PyObject *args) return _PyImport_GetLazyModulesSnapshot(interp); } - PyErr_Format(PyExc_AttributeError, "module 'sys' has no attribute %R", name); + PyErr_Format(PyExc_AttributeError, + "module 'sys' has no attribute %R", name); return NULL; } static PyObject * -sys_dir(PyObject *self, PyObject *args) +sys_dir(PyObject *self, PyObject *Py_UNUSED(ignored)) { PyObject *names = PyMapping_Keys(((PyModuleObject *)self)->md_dict); if (names == NULL) { From 17de69ef42653eb42c32f82f818affa1f6bda1cd Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 5 May 2026 18:17:08 +0200 Subject: [PATCH 12/13] Add sys.lazy_modules docs and fix CI test failures - Add ``sys.lazy_modules`` entry to Doc/library/sys.rst (frozendict snapshot, fresh per access, versionadded 3.15) - Fix test_builtin.test_vars: sys.lazy_modules is served by sys.__getattr__ so it appears in dir() but not vars() - Fix test_inspect.test_sys_module_has_signatures: sys.__dir__ and sys.__getattr__ are METH_NOARGS/METH_VARARGS builtins without Argument Clinic text signatures; add them to no_signature --- Doc/library/sys.rst | 21 +++++++++++++++++++++ Lib/test/test_builtin.py | 4 +++- Lib/test/test_inspect/test_inspect.py | 3 +++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/Doc/library/sys.rst b/Doc/library/sys.rst index 6946eb6eeaa5fa..fa5d85222cbf0d 100644 --- a/Doc/library/sys.rst +++ b/Doc/library/sys.rst @@ -1483,6 +1483,27 @@ always available. Unless explicitly noted otherwise, all variables are read-only They hold the legacy representation of ``sys.last_exc``, as returned from :func:`exc_info` above. + +.. data:: lazy_modules + + A read-only :class:`frozendict` snapshot of the lazy import registry for the + current interpreter. Each key is a module name string; the corresponding + value is a :class:`frozenset` of the child attribute names that were + registered as lazy imports from that module. A top-level lazy import (e.g. + ``lazy import json``) is represented by an empty :class:`frozenset`. + + A fresh snapshot is constructed on every attribute access, so the mapping + reflects the state of lazy imports at the time of access. The snapshot is + immutable; modifications must go through :func:`set_lazy_imports`. + + The live, mutable registry is available as the private :data:`sys._lazy_modules` + for internal use. + + See also :func:`set_lazy_imports`, :func:`get_lazy_imports`, and :pep:`810`. + + .. versionadded:: 3.15 + + .. data:: maxsize An integer giving the maximum value a variable of type :c:type:`Py_ssize_t` can diff --git a/Lib/test/test_builtin.py b/Lib/test/test_builtin.py index 844656eb0e2c2e..3ff0e28e939ede 100644 --- a/Lib/test/test_builtin.py +++ b/Lib/test/test_builtin.py @@ -2132,7 +2132,9 @@ def getDict(self): def test_vars(self): self.assertEqual(set(vars()), set(dir())) - self.assertEqual(set(vars(sys)), set(dir(sys))) + # sys.lazy_modules is a virtual attribute served by sys.__getattr__, + # so it appears in dir() but not in vars(). + self.assertEqual(set(vars(sys)) | {'lazy_modules'}, set(dir(sys))) self.assertEqual(self.get_vars_f0(), {}) self.assertEqual(self.get_vars_f2(), {'a': 1, 'b': 2}) self.assertRaises(TypeError, vars, 42, 42) diff --git a/Lib/test/test_inspect/test_inspect.py b/Lib/test/test_inspect/test_inspect.py index 68ea62f565d824..a26ef6fb1edf71 100644 --- a/Lib/test/test_inspect/test_inspect.py +++ b/Lib/test/test_inspect/test_inspect.py @@ -6179,6 +6179,9 @@ def test_sys_module_has_signatures(self): no_signature = {'getsizeof', 'set_asyncgen_hooks'} no_signature |= {name for name in ['getobjects'] if hasattr(sys, name)} + # sys.__dir__ and sys.__getattr__ are plain METH_NOARGS/METH_VARARGS + # builtins without Argument Clinic text signatures. + no_signature |= {'__dir__', '__getattr__'} self._test_module_has_signatures(sys, no_signature) def test_abc_module_has_signatures(self): From 46c6033a5eb6efb6944671a9fe6f759685ecac7c Mon Sep 17 00:00:00 2001 From: johnslavik Date: Tue, 5 May 2026 23:53:30 +0200 Subject: [PATCH 13/13] Remove internal _lazy_modules mention and bogus set_lazy_imports cross-ref from docs --- Doc/library/sys.rst | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/Doc/library/sys.rst b/Doc/library/sys.rst index fa5d85222cbf0d..a344405461fa55 100644 --- a/Doc/library/sys.rst +++ b/Doc/library/sys.rst @@ -1493,13 +1493,9 @@ always available. Unless explicitly noted otherwise, all variables are read-only ``lazy import json``) is represented by an empty :class:`frozenset`. A fresh snapshot is constructed on every attribute access, so the mapping - reflects the state of lazy imports at the time of access. The snapshot is - immutable; modifications must go through :func:`set_lazy_imports`. + reflects the state of lazy imports at the time of access. - The live, mutable registry is available as the private :data:`sys._lazy_modules` - for internal use. - - See also :func:`set_lazy_imports`, :func:`get_lazy_imports`, and :pep:`810`. + See also :pep:`810`. .. versionadded:: 3.15