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):