]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-123987: Fix NotADirectoryError in NamespaceReader when sentinel present (#124018)
authorJason R. Coombs <jaraco@jaraco.com>
Sun, 26 Jan 2025 16:23:54 +0000 (11:23 -0500)
committerGitHub <noreply@github.com>
Sun, 26 Jan 2025 16:23:54 +0000 (16:23 +0000)
Lib/importlib/resources/__init__.py
Lib/importlib/resources/_common.py
Lib/importlib/resources/readers.py
Lib/importlib/resources/simple.py
Lib/test/test_importlib/resources/_path.py
Lib/test/test_importlib/resources/test_files.py
Misc/NEWS.d/next/Library/2024-09-12-14-24-25.gh-issue-123987.7_OD1p.rst [new file with mode: 0644]

index ec4441c911611884cc31519cc15d812ceb28eb85..723c9f9eb33ce1e5f7fb5a70c5c9d62628a93521 100644 (file)
@@ -1,4 +1,11 @@
-"""Read resources contained within a package."""
+"""
+Read resources contained within a package.
+
+This codebase is shared between importlib.resources in the stdlib
+and importlib_resources in PyPI. See
+https://github.com/python/importlib_metadata/wiki/Development-Methodology
+for more detail.
+"""
 
 from ._common import (
     as_file,
index c2c92254370f719ea1b2ecbc46c0e5c10bb2eb46..4e9014c45a056e220eaa131839e69c9ba7d3023f 100644 (file)
@@ -66,10 +66,10 @@ def get_resource_reader(package: types.ModuleType) -> Optional[ResourceReader]:
     # zipimport.zipimporter does not support weak references, resulting in a
     # TypeError.  That seems terrible.
     spec = package.__spec__
-    reader = getattr(spec.loader, 'get_resource_reader', None)  # type: ignore
+    reader = getattr(spec.loader, 'get_resource_reader', None)  # type: ignore[union-attr]
     if reader is None:
         return None
-    return reader(spec.name)  # type: ignore
+    return reader(spec.name)  # type: ignore[union-attr]
 
 
 @functools.singledispatch
index ccc5abbeb4e56ed8d7969105ac449bcd3996a7fa..70fc7e2b9c0145b003e4be8d3723b01e31bd52db 100644 (file)
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 import collections
 import contextlib
 import itertools
@@ -6,6 +8,7 @@ import operator
 import re
 import warnings
 import zipfile
+from collections.abc import Iterator
 
 from . import abc
 
@@ -135,27 +138,31 @@ class NamespaceReader(abc.TraversableResources):
     def __init__(self, namespace_path):
         if 'NamespacePath' not in str(namespace_path):
             raise ValueError('Invalid path')
-        self.path = MultiplexedPath(*map(self._resolve, namespace_path))
+        self.path = MultiplexedPath(*filter(bool, map(self._resolve, namespace_path)))
 
     @classmethod
-    def _resolve(cls, path_str) -> abc.Traversable:
+    def _resolve(cls, path_str) -> abc.Traversable | None:
         r"""
         Given an item from a namespace path, resolve it to a Traversable.
 
         path_str might be a directory on the filesystem or a path to a
         zipfile plus the path within the zipfile, e.g. ``/foo/bar`` or
         ``/foo/baz.zip/inner_dir`` or ``foo\baz.zip\inner_dir\sub``.
+
+        path_str might also be a sentinel used by editable packages to
+        trigger other behaviors (see python/importlib_resources#311).
+        In that case, return None.
         """
-        (dir,) = (cand for cand in cls._candidate_paths(path_str) if cand.is_dir())
-        return dir
+        dirs = (cand for cand in cls._candidate_paths(path_str) if cand.is_dir())
+        return next(dirs, None)
 
     @classmethod
-    def _candidate_paths(cls, path_str):
+    def _candidate_paths(cls, path_str: str) -> Iterator[abc.Traversable]:
         yield pathlib.Path(path_str)
         yield from cls._resolve_zip_path(path_str)
 
     @staticmethod
-    def _resolve_zip_path(path_str):
+    def _resolve_zip_path(path_str: str):
         for match in reversed(list(re.finditer(r'[\\/]', path_str))):
             with contextlib.suppress(
                 FileNotFoundError,
index 96f117fec62c102b442c958181a4efc5688e053c..2e75299b13aabf3dd11aea5a23e6723d67854fc9 100644 (file)
@@ -77,7 +77,7 @@ class ResourceHandle(Traversable):
 
     def __init__(self, parent: ResourceContainer, name: str):
         self.parent = parent
-        self.name = name  # type: ignore
+        self.name = name  # type: ignore[misc]
 
     def is_file(self):
         return True
index 1f97c96146960d4256840340fe25f8da7be552c2..b144628cb73c77d7ee750dc32aab4d5a9d7b1f2f 100644 (file)
@@ -2,15 +2,44 @@ import pathlib
 import functools
 
 from typing import Dict, Union
+from typing import runtime_checkable
+from typing import Protocol
 
 
 ####
-# from jaraco.path 3.4.1
+# from jaraco.path 3.7.1
 
-FilesSpec = Dict[str, Union[str, bytes, 'FilesSpec']]  # type: ignore
 
+class Symlink(str):
+    """
+    A string indicating the target of a symlink.
+    """
+
+
+FilesSpec = Dict[str, Union[str, bytes, Symlink, 'FilesSpec']]
+
+
+@runtime_checkable
+class TreeMaker(Protocol):
+    def __truediv__(self, *args, **kwargs): ...  # pragma: no cover
+
+    def mkdir(self, **kwargs): ...  # pragma: no cover
+
+    def write_text(self, content, **kwargs): ...  # pragma: no cover
+
+    def write_bytes(self, content): ...  # pragma: no cover
 
-def build(spec: FilesSpec, prefix=pathlib.Path()):
+    def symlink_to(self, target): ...  # pragma: no cover
+
+
+def _ensure_tree_maker(obj: Union[str, TreeMaker]) -> TreeMaker:
+    return obj if isinstance(obj, TreeMaker) else pathlib.Path(obj)  # type: ignore[return-value]
+
+
+def build(
+    spec: FilesSpec,
+    prefix: Union[str, TreeMaker] = pathlib.Path(),  # type: ignore[assignment]
+):
     """
     Build a set of files/directories, as described by the spec.
 
@@ -25,21 +54,25 @@ def build(spec: FilesSpec, prefix=pathlib.Path()):
     ...             "__init__.py": "",
     ...         },
     ...         "baz.py": "# Some code",
-    ...     }
+    ...         "bar.py": Symlink("baz.py"),
+    ...     },
+    ...     "bing": Symlink("foo"),
     ... }
     >>> target = getfixture('tmp_path')
     >>> build(spec, target)
     >>> target.joinpath('foo/baz.py').read_text(encoding='utf-8')
     '# Some code'
+    >>> target.joinpath('bing/bar.py').read_text(encoding='utf-8')
+    '# Some code'
     """
     for name, contents in spec.items():
-        create(contents, pathlib.Path(prefix) / name)
+        create(contents, _ensure_tree_maker(prefix) / name)
 
 
 @functools.singledispatch
 def create(content: Union[str, bytes, FilesSpec], path):
     path.mkdir(exist_ok=True)
-    build(content, prefix=path)  # type: ignore
+    build(content, prefix=path)  # type: ignore[arg-type]
 
 
 @create.register
@@ -52,5 +85,10 @@ def _(content: str, path):
     path.write_text(content, encoding='utf-8')
 
 
+@create.register
+def _(content: Symlink, path):
+    path.symlink_to(content)
+
+
 # end from jaraco.path
 ####
index 933894dce2c045a01901ce5e51407b2a5a4c4eb1..db8a4e62a32dc690c76008da89f1c3bfd2bf8ddb 100644 (file)
@@ -60,6 +60,26 @@ class OpenZipTests(FilesTests, util.ZipSetup, unittest.TestCase):
 class OpenNamespaceTests(FilesTests, util.DiskSetup, unittest.TestCase):
     MODULE = 'namespacedata01'
 
+    def test_non_paths_in_dunder_path(self):
+        """
+        Non-path items in a namespace package's ``__path__`` are ignored.
+
+        As reported in python/importlib_resources#311, some tools
+        like Setuptools, when creating editable packages, will inject
+        non-paths into a namespace package's ``__path__``, a
+        sentinel like
+        ``__editable__.sample_namespace-1.0.finder.__path_hook__``
+        to cause the ``PathEntryFinder`` to be called when searching
+        for packages. In that case, resources should still be loadable.
+        """
+        import namespacedata01
+
+        namespacedata01.__path__.append(
+            '__editable__.sample_namespace-1.0.finder.__path_hook__'
+        )
+
+        resources.files(namespacedata01)
+
 
 class OpenNamespaceZipTests(FilesTests, util.ZipSetup, unittest.TestCase):
     ZIP_MODULE = 'namespacedata01'
@@ -86,7 +106,7 @@ class ModulesFiles:
         """
         A module can have resources found adjacent to the module.
         """
-        import mod
+        import mod  # type: ignore[import-not-found]
 
         actual = resources.files(mod).joinpath('res.txt').read_text(encoding='utf-8')
         assert actual == self.spec['res.txt']
diff --git a/Misc/NEWS.d/next/Library/2024-09-12-14-24-25.gh-issue-123987.7_OD1p.rst b/Misc/NEWS.d/next/Library/2024-09-12-14-24-25.gh-issue-123987.7_OD1p.rst
new file mode 100644 (file)
index 0000000..b110900
--- /dev/null
@@ -0,0 +1,3 @@
+Fixed issue in NamespaceReader where a non-path item in a namespace path,
+such as a sentinel added by an editable installer, would break resource
+loading.