]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-146228: Better fork support in cached FastPath (#146231)
authorJason R. Coombs <jaraco@jaraco.com>
Fri, 20 Mar 2026 20:10:50 +0000 (16:10 -0400)
committerGitHub <noreply@github.com>
Fri, 20 Mar 2026 20:10:50 +0000 (20:10 +0000)
* Apply changes from importlib_metadata 8.9.0
* Suppress deprecation warning in fork.

Lib/importlib/metadata/__init__.py
Lib/importlib/metadata/_adapters.py
Lib/importlib/metadata/_functools.py
Lib/test/test_importlib/metadata/fixtures.py
Lib/test/test_importlib/metadata/test_api.py
Lib/test/test_importlib/metadata/test_main.py
Lib/test/test_importlib/metadata/test_zip.py
Misc/NEWS.d/next/Library/2026-03-20-14-53-00.gh-issue-146228.OJVEDL.rst [new file with mode: 0644]

index e91acc065ba9ae672afb9ee15ef3323614f5391b..cde697e3dc7ab061b645302e9b219393e611c603 100644 (file)
@@ -31,7 +31,7 @@ from typing import Any
 
 from . import _meta
 from ._collections import FreezableDefaultDict, Pair
-from ._functools import method_cache, pass_none
+from ._functools import method_cache, noop, pass_none, passthrough
 from ._itertools import always_iterable, bucket, unique_everseen
 from ._meta import PackageMetadata, SimplePath
 from ._typing import md_none
@@ -783,6 +783,20 @@ class DistributionFinder(MetaPathFinder):
         """
 
 
+@passthrough
+def _clear_after_fork(cached):
+    """Ensure ``func`` clears cached state after ``fork`` when supported.
+
+    ``FastPath`` caches zip-backed ``pathlib.Path`` objects that retain a
+    reference to the parent's open ``ZipFile`` handle. Re-using a cached
+    instance in a forked child can therefore resurrect invalid file pointers
+    and trigger ``BadZipFile``/``OSError`` failures (python/importlib_metadata#520).
+    Registering ``cache_clear`` with ``os.register_at_fork`` keeps each process
+    on its own cache.
+    """
+    getattr(os, 'register_at_fork', noop)(after_in_child=cached.cache_clear)
+
+
 class FastPath:
     """
     Micro-optimized class for searching a root for children.
@@ -799,7 +813,8 @@ class FastPath:
     True
     """
 
-    @functools.lru_cache()  # type: ignore[misc]
+    @_clear_after_fork  # type: ignore[misc]
+    @functools.lru_cache()
     def __new__(cls, root):
         return super().__new__(cls)
 
@@ -925,10 +940,12 @@ class Prepared:
     def normalize(name):
         """
         PEP 503 normalization plus dashes as underscores.
+
+        Specifically avoids ``re.sub`` as prescribed for performance
+        benefits (see python/cpython#143658).
         """
-        # Much faster than re.sub, and even faster than str.translate
         value = name.lower().replace("-", "_").replace(".", "_")
-        # Condense repeats (faster than regex)
+        # Condense repeats
         while "__" in value:
             value = value.replace("__", "_")
         return value
index f5b30dd92cde69ab7c4a4c6485c36d4dc38a551a..dede395d79a38bab322d56a66d916703af84f77b 100644 (file)
@@ -9,7 +9,8 @@ from ._text import FoldedCase
 class RawPolicy(email.policy.EmailPolicy):
     def fold(self, name, value):
         folded = self.linesep.join(
-            textwrap.indent(value, prefix=' ' * 8, predicate=lambda line: True)
+            textwrap
+            .indent(value, prefix=' ' * 8, predicate=lambda line: True)
             .lstrip()
             .splitlines()
         )
index 5dda6a2199ad0be79351899a583b98c48eda4938..c159b46e48959cdaeb8635c09cdd48302dbfb44f 100644 (file)
@@ -1,5 +1,7 @@
 import functools
 import types
+from collections.abc import Callable
+from typing import TypeVar
 
 
 # from jaraco.functools 3.3
@@ -102,3 +104,33 @@ def pass_none(func):
             return func(param, *args, **kwargs)
 
     return wrapper
+
+
+# From jaraco.functools 4.4
+def noop(*args, **kwargs):
+    """
+    A no-operation function that does nothing.
+
+    >>> noop(1, 2, three=3)
+    """
+
+
+_T = TypeVar('_T')
+
+
+# From jaraco.functools 4.4
+def passthrough(func: Callable[..., object]) -> Callable[[_T], _T]:
+    """
+    Wrap the function to always return the first parameter.
+
+    >>> passthrough(print)('3')
+    3
+    '3'
+    """
+
+    @functools.wraps(func)
+    def wrapper(first: _T, *args, **kwargs) -> _T:
+        func(first, *args, **kwargs)
+        return first
+
+    return wrapper  # type: ignore[return-value]
index ad0ab42e089a9d3603178f0b79578f2019431f95..3283697d418188eb46e5f0a34ade1cf114319ed4 100644 (file)
@@ -6,6 +6,7 @@ import pathlib
 import shutil
 import sys
 import textwrap
+from importlib import resources
 
 from test.support import import_helper
 from test.support import os_helper
@@ -14,11 +15,6 @@ from test.support import requires_zlib
 from . import _path
 from ._path import FilesSpec
 
-if sys.version_info >= (3, 9):
-    from importlib import resources
-else:
-    import importlib_resources as resources
-
 
 @contextlib.contextmanager
 def tmp_path():
@@ -374,8 +370,6 @@ class ZipFixtures:
         # Add self.zip_name to the front of sys.path.
         self.resources = contextlib.ExitStack()
         self.addCleanup(self.resources.close)
-        # workaround for #138313
-        self.addCleanup(lambda: None)
 
 
 def parameterize(*args_set):
index 3c856a88b77bf64c6b99752a2c523103942c56a6..5449f0484492fbdc880444f5a8ed04f423669d24 100644 (file)
@@ -317,33 +317,31 @@ class InvalidateCache(unittest.TestCase):
 
 
 class PreparedTests(unittest.TestCase):
-    def test_normalize(self):
-        tests = [
-            # Simple
-            ("sample", "sample"),
-            # Mixed case
-            ("Sample", "sample"),
-            ("SAMPLE", "sample"),
-            ("SaMpLe", "sample"),
-            # Separator conversions
-            ("sample-pkg", "sample_pkg"),
-            ("sample.pkg", "sample_pkg"),
-            ("sample_pkg", "sample_pkg"),
-            # Multiple separators
-            ("sample---pkg", "sample_pkg"),
-            ("sample___pkg", "sample_pkg"),
-            ("sample...pkg", "sample_pkg"),
-            # Mixed separators
-            ("sample-._pkg", "sample_pkg"),
-            ("sample_.-pkg", "sample_pkg"),
-            # Complex
-            ("Sample__Pkg-name.foo", "sample_pkg_name_foo"),
-            ("Sample__Pkg.name__foo", "sample_pkg_name_foo"),
-            # Uppercase with separators
-            ("SAMPLE-PKG", "sample_pkg"),
-            ("Sample.Pkg", "sample_pkg"),
-            ("SAMPLE_PKG", "sample_pkg"),
-        ]
-        for name, expected in tests:
-            with self.subTest(name=name):
-                self.assertEqual(Prepared.normalize(name), expected)
+    @fixtures.parameterize(
+        # Simple
+        dict(input='sample', expected='sample'),
+        # Mixed case
+        dict(input='Sample', expected='sample'),
+        dict(input='SAMPLE', expected='sample'),
+        dict(input='SaMpLe', expected='sample'),
+        # Separator conversions
+        dict(input='sample-pkg', expected='sample_pkg'),
+        dict(input='sample.pkg', expected='sample_pkg'),
+        dict(input='sample_pkg', expected='sample_pkg'),
+        # Multiple separators
+        dict(input='sample---pkg', expected='sample_pkg'),
+        dict(input='sample___pkg', expected='sample_pkg'),
+        dict(input='sample...pkg', expected='sample_pkg'),
+        # Mixed separators
+        dict(input='sample-._pkg', expected='sample_pkg'),
+        dict(input='sample_.-pkg', expected='sample_pkg'),
+        # Complex
+        dict(input='Sample__Pkg-name.foo', expected='sample_pkg_name_foo'),
+        dict(input='Sample__Pkg.name__foo', expected='sample_pkg_name_foo'),
+        # Uppercase with separators
+        dict(input='SAMPLE-PKG', expected='sample_pkg'),
+        dict(input='Sample.Pkg', expected='sample_pkg'),
+        dict(input='SAMPLE_PKG', expected='sample_pkg'),
+    )
+    def test_normalize(self, input, expected):
+        self.assertEqual(Prepared.normalize(input), expected)
index 83b686babfdb7aebf6f83a35afed3fce560f95f0..f6c4ab2e78fe4733472aeef9b160918d9a404edd 100644 (file)
@@ -2,12 +2,12 @@ import importlib
 import pickle
 import re
 import unittest
-from test.support import os_helper
 
 try:
     import pyfakefs.fake_filesystem_unittest as ffs
 except ImportError:
     from .stubs import fake_filesystem_unittest as ffs
+from test.support import os_helper
 
 from importlib.metadata import (
     Distribution,
index fcb649f373607653dbc39ccff9cf64832a88e8bc..9daa04173b843e8f0ca3d2f955de272a22e321d9 100644 (file)
@@ -1,7 +1,12 @@
+import multiprocessing
+import os
 import sys
 import unittest
 
+from test.support import warnings_helper
+
 from importlib.metadata import (
+    FastPath,
     PackageNotFoundError,
     distribution,
     distributions,
@@ -47,6 +52,38 @@ class TestZip(fixtures.ZipFixtures, unittest.TestCase):
         dists = list(distributions(path=sys.path[:1]))
         assert len(dists) == 1
 
+    @warnings_helper.ignore_fork_in_thread_deprecation_warnings()
+    @unittest.skipUnless(
+        hasattr(os, 'register_at_fork')
+        and 'fork' in multiprocessing.get_all_start_methods(),
+        'requires fork-based multiprocessing support',
+    )
+    def test_fastpath_cache_cleared_in_forked_child(self):
+        zip_path = sys.path[0]
+
+        FastPath(zip_path)
+        assert FastPath.__new__.cache_info().currsize >= 1
+
+        ctx = multiprocessing.get_context('fork')
+        parent_conn, child_conn = ctx.Pipe()
+
+        def child(conn, root):
+            try:
+                before = FastPath.__new__.cache_info().currsize
+                FastPath(root)
+                after = FastPath.__new__.cache_info().currsize
+                conn.send((before, after))
+            finally:
+                conn.close()
+
+        proc = ctx.Process(target=child, args=(child_conn, zip_path))
+        proc.start()
+        child_conn.close()
+        cache_sizes = parent_conn.recv()
+        proc.join()
+
+        self.assertEqual(cache_sizes, (0, 1))
+
 
 class TestEgg(TestZip):
     def setUp(self):
diff --git a/Misc/NEWS.d/next/Library/2026-03-20-14-53-00.gh-issue-146228.OJVEDL.rst b/Misc/NEWS.d/next/Library/2026-03-20-14-53-00.gh-issue-146228.OJVEDL.rst
new file mode 100644 (file)
index 0000000..1356e2c
--- /dev/null
@@ -0,0 +1,2 @@
+Cached FastPath objects in importlib.metadata are now cleared on fork,
+avoiding broken references to zip files during fork.