diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index ac0969d3..ada58d43 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,6 @@ def importable_name(name): return '.' not in name return filter(importable_name, opt_names) + + +install(MetadataPathFinder) diff --git a/importlib_metadata/_compat/__init__.py b/importlib_metadata/_compat/__init__.py index fe9a721e..8eead9d1 100644 --- a/importlib_metadata/_compat/__init__.py +++ b/importlib_metadata/_compat/__init__.py @@ -6,15 +6,22 @@ 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. 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/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/compat/test_install.py b/tests/compat/test_install.py new file mode 100644 index 00000000..13457239 --- /dev/null +++ b/tests/compat/test_install.py @@ -0,0 +1,39 @@ +""" +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)) + + 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