From 27909fe4dfeb649683d947a5800c339abcc8846b Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sat, 31 Oct 2026 15:27:09 -0400 Subject: [PATCH 1/4] Install the finder only after the module is initialized. `install` appended a `MetadataPathFinder` to `sys.meta_path` while the module body was still executing, 36 lines before `PathDistribution` is bound, and `PathDistribution` is the only global the installed finder has yet to see (`Prepared`, `FastPath` and `DistributionFinder` all precede it). Anything walking `sys.meta_path` in that window resolves it against incomplete globals and raises `NameError: name 'PathDistribution' is not defined`. Two conditions reach the window. Another thread calling `entry_points()` takes no module lock, as it imports nothing and only iterates `sys.meta_path`. And an import aborted in the window, as an async exception such as a signal handler or a soft time limit can do, leaves the half-built finder installed for the life of the process: the module is dropped from `sys.modules`, `install` has already deleted `PathFinder.find_distributions` so the orphan is the only provider, and even a later successful import does not displace it. Installing at the end of the module, rather than just after `PathDistribution`, states the invariant the finder depends on and so survives any later reordering. The stdlib copy is unaffected, where `install` is stripped and `PathFinder.find_distributions` imports `MetadataPathFinder` lazily. Closes #525. Co-Authored-By: Claude Opus 5 --- importlib_metadata/__init__.py | 8 +++++++- newsfragments/525.bugfix.rst | 6 ++++++ tests/test_main.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 newsfragments/525.bugfix.rst diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index ac0969d3..f01dfa5e 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -1030,7 +1030,6 @@ def __bool__(self): return bool(self.name) -@install class MetadataPathFinder(NullFinder, DistributionFinder): """A degenerate finder for distribution packages on the file system. @@ -1309,3 +1308,10 @@ def importable_name(name): return '.' not in name return filter(importable_name, opt_names) + + +# Install the finder only after the module is fully initialized. Installing it +# earlier would publish a finder whose globals are still incomplete, and +# anything walking sys.meta_path in that window (another thread, a finalizer) +# would fail to resolve PathDistribution (#525). +install(MetadataPathFinder) diff --git a/newsfragments/525.bugfix.rst b/newsfragments/525.bugfix.rst new file mode 100644 index 00000000..b8a8c85f --- /dev/null +++ b/newsfragments/525.bugfix.rst @@ -0,0 +1,6 @@ +``MetadataPathFinder`` is now installed on ``sys.meta_path`` only after the +module is fully initialized. Previously it was installed partway through, so +anything walking ``sys.meta_path`` during that window, such as another thread +calling ``entry_points()``, would raise ``NameError: name 'PathDistribution' +is not defined``. An import interrupted in that window left the half-built +finder installed for the life of the process, failing every subsequent lookup. diff --git a/tests/test_main.py b/tests/test_main.py index b03911a3..9c5b418f 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,9 +1,13 @@ import contextlib import importlib +import importlib.util import io +import os import pickle import re +import sys import unittest +from unittest import mock import pyfakefs.fake_filesystem_unittest as ffs from test.support import os_helper @@ -14,6 +18,7 @@ EntryPoint, MetadataNotFound, PackageNotFoundError, + _compat, _unique, diagnose, distributions, @@ -108,6 +113,33 @@ def test_resolve_without_attr(self): assert ep.load() is importlib_metadata +class FinderInstallTests(unittest.TestCase): + def test_installed_after_module_initialized(self): + """ + Install the finder only after the module can satisfy it. + + An installed finder resolves ``PathDistribution`` from the module + globals, so a finder installed before the module body completes is + one that anything walking ``sys.meta_path`` would trip over + (python/importlib_metadata#525). + """ + snapshots = [] + + def capture(cls): + snapshots.append(set(sys._getframe(1).f_globals)) + return cls + + spec = importlib.util.find_spec('importlib_metadata') + with contextlib.ExitStack() as stack: + stack.enter_context(mock.patch.object(_compat, 'install', capture)) + # Don't let the re-executed module leak a fork handler. + stack.enter_context(mock.patch.object(os, 'register_at_fork', create=True)) + spec.loader.exec_module(importlib.util.module_from_spec(spec)) + + (globals_at_install,) = snapshots + assert 'PathDistribution' in globals_at_install + + class NameNormalizationTests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase): @staticmethod def make_pkg(name): From c52e10807c0556cc445fc7a877c97bf1f04d8944 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sat, 7 Nov 2026 14:34:42 -0500 Subject: [PATCH 2/4] Carry the rationale in `install`, not at the call site. The comment explaining why the call sits at the end of the module was four more lines for the CPython port to strip. `install` is itself removed there, so its docstring carries the constraint for free. Co-Authored-By: Claude Opus 5 --- importlib_metadata/__init__.py | 4 ---- importlib_metadata/_compat/__init__.py | 8 +++++++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index f01dfa5e..ada58d43 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -1310,8 +1310,4 @@ def importable_name(name): return filter(importable_name, opt_names) -# Install the finder only after the module is fully initialized. Installing it -# earlier would publish a finder whose globals are still incomplete, and -# anything walking sys.meta_path in that window (another thread, a finalizer) -# would fail to resolve PathDistribution (#525). install(MetadataPathFinder) diff --git a/importlib_metadata/_compat/__init__.py b/importlib_metadata/_compat/__init__.py index fe9a721e..ee2a1e39 100644 --- a/importlib_metadata/_compat/__init__.py +++ b/importlib_metadata/_compat/__init__.py @@ -6,11 +6,17 @@ def install(cls): """ - Class decorator for installation on sys.meta_path. + Install cls on sys.meta_path. Adds the backport DistributionFinder to sys.meta_path and attempts to disable the finder functionality of the stdlib DistributionFinder. + + Call this only once the calling module is fully initialized, from + its end rather than as a decorator on the class. The installed + finder resolves PathDistribution from that module's globals, so + installing it any earlier publishes a finder that anything walking + sys.meta_path would trip over (#525). """ sys.meta_path.append(cls()) disable_stdlib_finder() From 579d2e950e7a0f5fe14991abad820239ab35020d Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sat, 7 Nov 2026 14:38:53 -0500 Subject: [PATCH 3/4] Move the install expectation to tests/compat. `install` has no counterpart in the stdlib, which reaches `MetadataPathFinder` through its own `PathFinder` and publishes no finder of its own, so the expectation belongs beside the other backport-specific tests rather than among those test_main holds for both codebases. Co-Authored-By: Claude Opus 5 --- tests/compat/test_install.py | 40 ++++++++++++++++++++++++++++++++++++ tests/test_main.py | 32 ----------------------------- 2 files changed, 40 insertions(+), 32 deletions(-) create mode 100644 tests/compat/test_install.py diff --git a/tests/compat/test_install.py b/tests/compat/test_install.py new file mode 100644 index 00000000..c4dc1a0c --- /dev/null +++ b/tests/compat/test_install.py @@ -0,0 +1,40 @@ +""" +The finder installed on ``sys.meta_path`` is particular to the backport; +the stdlib reaches ``MetadataPathFinder`` through its own ``PathFinder`` +and so never publishes a finder of its own (#525). +""" + +import contextlib +import importlib.util +import os +import sys +import unittest +from unittest import mock + +from importlib_metadata import _compat + + +class FinderInstallTests(unittest.TestCase): + def test_installed_after_module_initialized(self): + """ + Install the finder only after the module can satisfy it. + + An installed finder resolves ``PathDistribution`` from the module + globals, so a finder installed before the module body completes is + one that anything walking ``sys.meta_path`` would trip over. + """ + snapshots = [] + + def capture(cls): + snapshots.append(set(sys._getframe(1).f_globals)) + return cls + + spec = importlib.util.find_spec('importlib_metadata') + with contextlib.ExitStack() as stack: + stack.enter_context(mock.patch.object(_compat, 'install', capture)) + # Don't let the re-executed module leak a fork handler. + stack.enter_context(mock.patch.object(os, 'register_at_fork', create=True)) + spec.loader.exec_module(importlib.util.module_from_spec(spec)) + + (globals_at_install,) = snapshots + assert 'PathDistribution' in globals_at_install diff --git a/tests/test_main.py b/tests/test_main.py index 9c5b418f..b03911a3 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,13 +1,9 @@ import contextlib import importlib -import importlib.util import io -import os import pickle import re -import sys import unittest -from unittest import mock import pyfakefs.fake_filesystem_unittest as ffs from test.support import os_helper @@ -18,7 +14,6 @@ EntryPoint, MetadataNotFound, PackageNotFoundError, - _compat, _unique, diagnose, distributions, @@ -113,33 +108,6 @@ def test_resolve_without_attr(self): assert ep.load() is importlib_metadata -class FinderInstallTests(unittest.TestCase): - def test_installed_after_module_initialized(self): - """ - Install the finder only after the module can satisfy it. - - An installed finder resolves ``PathDistribution`` from the module - globals, so a finder installed before the module body completes is - one that anything walking ``sys.meta_path`` would trip over - (python/importlib_metadata#525). - """ - snapshots = [] - - def capture(cls): - snapshots.append(set(sys._getframe(1).f_globals)) - return cls - - spec = importlib.util.find_spec('importlib_metadata') - with contextlib.ExitStack() as stack: - stack.enter_context(mock.patch.object(_compat, 'install', capture)) - # Don't let the re-executed module leak a fork handler. - stack.enter_context(mock.patch.object(os, 'register_at_fork', create=True)) - spec.loader.exec_module(importlib.util.module_from_spec(spec)) - - (globals_at_install,) = snapshots - assert 'PathDistribution' in globals_at_install - - class NameNormalizationTests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase): @staticmethod def make_pkg(name): From fa2b269105432f98ec3cd607f228795e211f4bdd Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sun, 8 Nov 2026 10:22:50 -0500 Subject: [PATCH 4/4] Stop returning the class from `install`. Nothing consumes the return value now that the call is a statement, and withholding it makes the decorator form bind None rather than quietly restoring the window it was moved to close. Co-Authored-By: Claude Opus 5 --- importlib_metadata/_compat/__init__.py | 11 ++++++----- tests/compat/test_install.py | 1 - 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/importlib_metadata/_compat/__init__.py b/importlib_metadata/_compat/__init__.py index ee2a1e39..8eead9d1 100644 --- a/importlib_metadata/_compat/__init__.py +++ b/importlib_metadata/_compat/__init__.py @@ -13,14 +13,15 @@ def install(cls): DistributionFinder. Call this only once the calling module is fully initialized, from - its end rather than as a decorator on the class. The installed - finder resolves PathDistribution from that module's globals, so - installing it any earlier publishes a finder that anything walking - sys.meta_path would trip over (#525). + its end. The installed finder resolves PathDistribution from that + module's globals, so installing it any earlier publishes a finder + that anything walking sys.meta_path would trip over (#525). + + Returns nothing, so that use as a class decorator fails outright + rather than reopening that window. """ sys.meta_path.append(cls()) disable_stdlib_finder() - return cls def disable_stdlib_finder(): diff --git a/tests/compat/test_install.py b/tests/compat/test_install.py index c4dc1a0c..13457239 100644 --- a/tests/compat/test_install.py +++ b/tests/compat/test_install.py @@ -27,7 +27,6 @@ def test_installed_after_module_initialized(self): def capture(cls): snapshots.append(set(sys._getframe(1).f_globals)) - return cls spec = importlib.util.find_spec('importlib_metadata') with contextlib.ExitStack() as stack: