From 530c2f0ca9918a8f43ec3aeb06a698eb01419600 Mon Sep 17 00:00:00 2001 From: Mike Decker Date: Thu, 22 Oct 2026 09:44:17 -0400 Subject: [PATCH 1/4] gh-92041: Avoid registry scans for uncached registered frames Resolve a frame or traceback from its registered globals owner only when its canonical filename matches. Reuse the existing filename cache and retain the fallback for other objects and namespaces. Developed and tested with Codex (AI assistance). --- Lib/inspect.py | 28 ++++ Lib/test/test_inspect/test_inspect.py | 145 ++++++++++++++++++ ...26-10-22-00-00-00.gh-issue-92041.frame.rst | 3 + 3 files changed, 176 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-10-22-00-00-00.gh-issue-92041.frame.rst diff --git a/Lib/inspect.py b/Lib/inspect.py index d06c8078ded870..1793675bb47c37 100644 --- a/Lib/inspect.py +++ b/Lib/inspect.py @@ -929,6 +929,25 @@ def getabsfile(object, _filename=None): modulesbyfile = {} _filesbymodname = {} +def _getframemodule(frame, filename): + """Resolve a frame's registered globals owner with a matching origin.""" + namespace = frame.f_globals + name = namespace.get('__name__') + if type(name) is not str: + return None + module = sys.modules.get(name) + # Leave dynamic module metadata to the existing filename fallback. + if (type(module) is not types.ModuleType or + module.__dict__ is not namespace or '__getattr__' in namespace or + type(namespace.get('__file__')) is not str): + return None + try: + if filename == getabsfile(module): + return module + except (TypeError, FileNotFoundError): + pass + + def getmodule(object, _filename=None): """Return the module an object was defined in, or None if not found.""" if ismodule(object): @@ -946,6 +965,15 @@ def getmodule(object, _filename=None): return None if file in modulesbyfile: return sys.modules.get(modulesbyfile[file]) + frame = object if isframe(object) else ( + object.tb_frame if istraceback(object) else None) + if frame is not None and ( + _filename is None or _filename == frame.f_code.co_filename): + module = _getframemodule(frame, file) + if module is not None: + modulesbyfile[file] = module.__name__ + return module + # Update the filename to module name cache and check yet again # Copy sys.modules in order to cope with changes while iterating for modname, module in sys.modules.copy().items(): diff --git a/Lib/test/test_inspect/test_inspect.py b/Lib/test/test_inspect/test_inspect.py index c5581041f044b1..251f01549b1907 100644 --- a/Lib/test/test_inspect/test_inspect.py +++ b/Lib/test/test_inspect/test_inspect.py @@ -1045,6 +1045,151 @@ class C: nonlocal __firstlineno__ self.assertRaises(OSError, inspect.getsource, C) +class TestGetmoduleFrameOwner(unittest.TestCase): + def setUp(self): + self.enterContext(unittest.mock.patch.object(inspect, 'modulesbyfile', {})) + self.enterContext(unittest.mock.patch.object(inspect, '_filesbymodname', {})) + self.directory = self.enterContext(tempfile.TemporaryDirectory()) + self.filename = os.path.join(self.directory, 'owner.py') + self.module = types.ModuleType('_inspect_frame_owner') + self.module.__file__ = self.filename + self.enterContext(unittest.mock.patch.dict( + sys.modules, {self.module.__name__: self.module})) + + def capture(self, namespace=None, filename=None): + if namespace is None: + namespace = self.module.__dict__ + exec(compile("import sys\nframe = sys._getframe()\n" + "try: 1 / 0\n" + "except ZeroDivisionError as error:\n" + " traceback = error.__traceback__\n", + filename or self.filename, 'exec'), namespace) + self.addCleanup(namespace.pop, 'frame', None) + self.addCleanup(namespace.pop, 'traceback', None) + return namespace['frame'], namespace['traceback'] + + def test_registered_owner_without_registry_scan(self): + frame, traceback = self.capture() + class NoScanDict(dict): + def copy(self): + raise AssertionError('registered frame scanned sys.modules') + with unittest.mock.patch.object(sys, 'modules', NoScanDict(sys.modules)): + for object in (frame, traceback): + with self.subTest(object=type(object)): + self.assertIs(inspect.getmodule(object), self.module) + self.assertIs(inspect.getmodule(object, self.filename), + self.module) + + def test_filename_collision_uses_globals_owner(self): + frame, traceback = self.capture() + other = types.ModuleType('_inspect_frame_collision') + other.__file__ = self.filename + with unittest.mock.patch.dict(sys.modules, {other.__name__: other}): + for object in (frame, traceback): + self.assertIs(inspect.getmodule(object), self.module) + + def test_unregistered_namespace_uses_filename_fallback(self): + namespace = {'__name__': self.module.__name__} + frame, traceback = self.capture(namespace) + for object in (frame, traceback): + self.assertIs(inspect.getmodule(object), self.module) + + def test_existing_filename_cache_retains_precedence(self): + frame, traceback = self.capture() + other = types.ModuleType('_inspect_frame_cached') + other.__file__ = self.filename + with unittest.mock.patch.dict(sys.modules, {other.__name__: other}): + self.assertIs(inspect.getmodule(None, self.filename), other) + for object in (frame, traceback): + self.assertIs(inspect.getmodule(object), other) + + def test_changed_module_filename_is_rechecked(self): + frame, traceback = self.capture() + self.assertIs(inspect.getmodule(frame), self.module) + self.module.__file__ = self.filename + '.changed' + inspect.modulesbyfile.clear() + for object in (frame, traceback): + self.assertIsNone(inspect.getmodule(object)) + + def test_different_origin_uses_filename_fallback(self): + other = types.ModuleType('_inspect_frame_source') + other.__file__ = self.filename + '.other' + with unittest.mock.patch.dict(sys.modules, {other.__name__: other}): + frame, traceback = self.capture(filename=other.__file__) + for object in (frame, traceback): + self.assertIs(inspect.getmodule(object), other) + + def test_explicit_filename_override(self): + frame, traceback = self.capture() + other = types.ModuleType('_inspect_frame_override') + other.__file__ = self.filename + '.other' + with unittest.mock.patch.dict(sys.modules, {other.__name__: other}): + for object in (frame, traceback): + self.assertIs(inspect.getmodule(object, other.__file__), other) + + def test_fileless_module_falls_back(self): + del self.module.__file__ + frame, traceback = self.capture() + for object in (frame, traceback): + self.assertIsNone(inspect.getmodule(object)) + + def test_invalid_globals_name_falls_back(self): + frame, traceback = self.capture() + self.module.__name__ = [] + del self.module.__file__ + for object in (frame, traceback): + self.assertIsNone(inspect.getmodule(object)) + + def test_same_name_replacement_does_not_claim_old_frame(self): + frame, traceback = self.capture() + replacement = types.ModuleType(self.module.__name__) + replacement.__file__ = self.filename + '.replacement' + sys.modules[replacement.__name__] = replacement + for object in (frame, traceback): + self.assertIsNone(inspect.getmodule(object)) + + def test_dynamic_module_metadata_falls_back(self): + class DynamicModule(types.ModuleType): + @property + def __file__(self): + return self.source + self.module.__class__ = DynamicModule + self.module.source = self.filename + '.dynamic' + frame, traceback = self.capture(filename=self.module.source) + for object in (frame, traceback): + self.assertIs(inspect.getmodule(object), self.module) + + def test_sourceless_import(self): + import py_compile + source = os.path.join(self.directory, 'sourceless.py') + bytecode = source + 'c' + with open(source, 'w') as file: + file.write('import sys\ndef capture(): return sys._getframe()\n') + py_compile.compile(source, cfile=bytecode, doraise=True) + os.remove(source) + spec = importlib.util.spec_from_file_location('_inspect_sourceless', bytecode) + module = importlib.util.module_from_spec(spec) + with unittest.mock.patch.dict(sys.modules, {spec.name: module}): + spec.loader.exec_module(module) + frame = module.capture() + try: + self.assertIs(inspect.getmodule(frame), module) + self.assertIs(inspect.getmodule(module.capture.__code__), module) + finally: + del frame + + def test_bytecode_filename_with_available_source(self): + with open(self.filename, 'w'): + pass + self.module.__file__ = self.filename + 'c' + frame, traceback = self.capture(filename=self.module.__file__) + for object in (frame, traceback, frame.f_code): + self.assertIs(inspect.getmodule(object), self.module) + # An explicit bytecode filename bypasses source normalization + # on the query side in the existing implementation. + self.assertIsNone(inspect.getmodule(object, self.module.__file__)) + + class TestGetsourceStdlib(unittest.TestCase): # Test Python implementations of the stdlib modules diff --git a/Misc/NEWS.d/next/Library/2026-10-22-00-00-00.gh-issue-92041.frame.rst b/Misc/NEWS.d/next/Library/2026-10-22-00-00-00.gh-issue-92041.frame.rst new file mode 100644 index 00000000000000..4c9f894d788ee0 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-22-00-00-00.gh-issue-92041.frame.rst @@ -0,0 +1,3 @@ +Speed up :func:`inspect.getmodule` for frames and tracebacks whose globals +belong to a registered module with a matching filename. The globals owner +takes precedence over unrelated modules sharing the same filename. From 6c72434f0e04c950a3908ebf40b1b5d27f64c72a Mon Sep 17 00:00:00 2001 From: Mike Decker Date: Thu, 22 Oct 2026 09:50:29 -0400 Subject: [PATCH 2/4] Exercise cold frame and traceback resolution independently --- Lib/test/test_inspect/test_inspect.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Lib/test/test_inspect/test_inspect.py b/Lib/test/test_inspect/test_inspect.py index 251f01549b1907..d0855e3812b6a7 100644 --- a/Lib/test/test_inspect/test_inspect.py +++ b/Lib/test/test_inspect/test_inspect.py @@ -1076,6 +1076,8 @@ def copy(self): with unittest.mock.patch.object(sys, 'modules', NoScanDict(sys.modules)): for object in (frame, traceback): with self.subTest(object=type(object)): + inspect.modulesbyfile.clear() + inspect._filesbymodname.clear() self.assertIs(inspect.getmodule(object), self.module) self.assertIs(inspect.getmodule(object, self.filename), self.module) From bda170b70b98398fa0d14abc0a190beb8efaee13 Mon Sep 17 00:00:00 2001 From: Mike Decker Date: Thu, 22 Oct 2026 11:58:24 -0400 Subject: [PATCH 3/4] Avoid shadowed module filename in dynamic metadata fixture The fixture left a stale __file__ value beneath a module subclass property. LOAD_ATTR_MODULE specialization can read that value, making the assertion depend on warmup state in the existing filename fallback, including main. Remove the shadow value and exercise warmed getfile access, two changing origins, and independent cold frame/traceback lookups. Keep production code unchanged. Verified the corrected test 50 times on each of main and the candidate; all six focused suites pass (1,135 tests reported, four skipped). --- Lib/test/test_inspect/test_inspect.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/Lib/test/test_inspect/test_inspect.py b/Lib/test/test_inspect/test_inspect.py index d0855e3812b6a7..2e3a2eec0d78af 100644 --- a/Lib/test/test_inspect/test_inspect.py +++ b/Lib/test/test_inspect/test_inspect.py @@ -1155,11 +1155,22 @@ class DynamicModule(types.ModuleType): @property def __file__(self): return self.source + # Do not leave a shadow value that LOAD_ATTR_MODULE can read instead + # of the property when attribute access specializes. + del self.module.__dict__['__file__'] self.module.__class__ = DynamicModule - self.module.source = self.filename + '.dynamic' - frame, traceback = self.capture(filename=self.module.source) - for object in (frame, traceback): - self.assertIs(inspect.getmodule(object), self.module) + for suffix in ('.dynamic', '.changed'): + with self.subTest(suffix=suffix): + self.module.source = self.filename + suffix + frame, traceback = self.capture(filename=self.module.source) + # Exercise warmed attribute access as well as cold caches. + for _ in range(100): + self.assertEqual(inspect.getfile(self.module), + self.module.source) + for object in (frame, traceback): + inspect.modulesbyfile.clear() + inspect._filesbymodname.clear() + self.assertIs(inspect.getmodule(object), self.module) def test_sourceless_import(self): import py_compile From ef27738a6fea5551f4176e370ba5b3c0a63dd5da Mon Sep 17 00:00:00 2001 From: Mike Decker Date: Thu, 22 Oct 2026 17:33:24 -0400 Subject: [PATCH 4/4] gh-92041: Address frame lookup review and document cache precedence --- Doc/library/inspect.rst | 10 +++ Lib/inspect.py | 19 +++-- Lib/test/test_inspect/test_inspect.py | 70 +++++++++++++++++++ ...26-10-22-00-00-00.gh-issue-92041.frame.rst | 7 +- 4 files changed, 96 insertions(+), 10 deletions(-) diff --git a/Doc/library/inspect.rst b/Doc/library/inspect.rst index 29bdae28b1fe26..cfcd29a93f6950 100644 --- a/Doc/library/inspect.rst +++ b/Doc/library/inspect.rst @@ -771,6 +771,16 @@ Retrieving source code Try to guess which module an object was defined in. Return ``None`` if the module cannot be determined. + .. versionchanged:: 3.16 + On a filename-cache miss, frames and tracebacks may be resolved from + a registered module whose global namespace and canonical filename match + the frame. Existing filename-cache entries retain precedence. If multiple + modules share a filename, the result may differ from earlier Python + versions and can depend on previous lookups: the frame's globals owner + is not guaranteed to take precedence. Code objects continue to use + filename-based lookup and can also be affected by earlier frame or + traceback lookups. + .. function:: getsourcefile(object) diff --git a/Lib/inspect.py b/Lib/inspect.py index 1793675bb47c37..7f9055ccabe123 100644 --- a/Lib/inspect.py +++ b/Lib/inspect.py @@ -936,16 +936,17 @@ def _getframemodule(frame, filename): if type(name) is not str: return None module = sys.modules.get(name) - # Leave dynamic module metadata to the existing filename fallback. - if (type(module) is not types.ModuleType or - module.__dict__ is not namespace or '__getattr__' in namespace or - type(namespace.get('__file__')) is not str): + # Leave module subclasses and dynamically supplied filenames to the fallback. + if (type(module) is not types.ModuleType + or module.__dict__ is not namespace + or type(namespace.get('__file__')) is not str): return None try: if filename == getabsfile(module): return module - except (TypeError, FileNotFoundError): + except FileNotFoundError, TypeError: pass + return None def getmodule(object, _filename=None): @@ -965,8 +966,12 @@ def getmodule(object, _filename=None): return None if file in modulesbyfile: return sys.modules.get(modulesbyfile[file]) - frame = object if isframe(object) else ( - object.tb_frame if istraceback(object) else None) + if isframe(object): + frame = object + elif istraceback(object): + frame = object.tb_frame + else: + frame = None if frame is not None and ( _filename is None or _filename == frame.f_code.co_filename): module = _getframemodule(frame, file) diff --git a/Lib/test/test_inspect/test_inspect.py b/Lib/test/test_inspect/test_inspect.py index 2e3a2eec0d78af..7bd81d05fc51f9 100644 --- a/Lib/test/test_inspect/test_inspect.py +++ b/Lib/test/test_inspect/test_inspect.py @@ -1090,6 +1090,76 @@ def test_filename_collision_uses_globals_owner(self): for object in (frame, traceback): self.assertIs(inspect.getmodule(object), self.module) + # An unrelated miss scans the registry and can replace that entry. + self.assertIsNone(inspect.getmodule( + compile('pass', '', 'exec'))) + for object in (frame, traceback): + self.assertIs(inspect.getmodule(object), other) + + # Bare code uses the filename map, including entries populated + # by an earlier frame lookup. + inspect.modulesbyfile.clear() + inspect._filesbymodname.clear() + self.assertIs(inspect.getmodule(frame.f_code), other) + inspect.modulesbyfile.clear() + inspect._filesbymodname.clear() + self.assertIs(inspect.getmodule(frame), self.module) + self.assertIs(inspect.getmodule(frame.f_code), self.module) + + def test_static_filename_with_module_getattr_avoids_scan(self): + def module_getattr(name): + self.fail(f'unexpected module attribute lookup: {name}') + + self.module.__getattr__ = module_getattr + frame, traceback = self.capture() + + class NoScanDict(dict): + def copy(self): + raise AssertionError('registered frame scanned sys.modules') + + with unittest.mock.patch.object(sys, 'modules', NoScanDict(sys.modules)): + for object in (frame, traceback): + inspect.modulesbyfile.clear() + inspect._filesbymodname.clear() + self.assertIs(inspect.getmodule(object), self.module) + + def test_filename_supplied_by_module_getattr_falls_back(self): + del self.module.__file__ + + def module_getattr(name): + if name == '__file__': + return self.module.source + raise AttributeError(name) + + self.module.__getattr__ = module_getattr + for suffix in ('.dynamic', '.changed'): + self.module.source = self.filename + suffix + frame, traceback = self.capture(filename=self.module.source) + for object in (frame, traceback): + inspect.modulesbyfile.clear() + inspect._filesbymodname.clear() + self.assertIs(inspect.getmodule(object), self.module) + + def test_loader_supplied_by_module_getattr(self): + self.module.__file__ = self.filename + 'c' + del self.module.__loader__ + loader = object() + requested = [] + + def module_getattr(name): + requested.append(name) + if name == '__loader__': + return loader + raise AttributeError(name) + + self.module.__getattr__ = module_getattr + frame, traceback = self.capture() + for obj in (frame, traceback): + inspect.modulesbyfile.clear() + inspect._filesbymodname.clear() + self.assertIs(inspect.getmodule(obj), self.module) + self.assertIn('__loader__', requested) + def test_unregistered_namespace_uses_filename_fallback(self): namespace = {'__name__': self.module.__name__} frame, traceback = self.capture(namespace) diff --git a/Misc/NEWS.d/next/Library/2026-10-22-00-00-00.gh-issue-92041.frame.rst b/Misc/NEWS.d/next/Library/2026-10-22-00-00-00.gh-issue-92041.frame.rst index 4c9f894d788ee0..b9c359014acbf6 100644 --- a/Misc/NEWS.d/next/Library/2026-10-22-00-00-00.gh-issue-92041.frame.rst +++ b/Misc/NEWS.d/next/Library/2026-10-22-00-00-00.gh-issue-92041.frame.rst @@ -1,3 +1,4 @@ -Speed up :func:`inspect.getmodule` for frames and tracebacks whose globals -belong to a registered module with a matching filename. The globals owner -takes precedence over unrelated modules sharing the same filename. +Avoid scanning :data:`sys.modules` in :func:`inspect.getmodule` for an uncached +frame or traceback whose globals identify a registered module with a matching +canonical filename. Existing filename-cache entries retain precedence. When +modules share a filename, results can depend on earlier lookups.