mirror of
				https://github.com/python/cpython.git
				synced 2025-11-03 23:21:29 +00:00 
			
		
		
		
	[3.13] gh-123987: Fix NotADirectoryError in NamespaceReader when sentinel present (GH-124018) (#129319)
gh-123987: Fix NotADirectoryError in NamespaceReader when sentinel present (GH-124018)
(cherry picked from commit b543b32eff)
Co-authored-by: Jason R. Coombs <jaraco@jaraco.com>
			
			
This commit is contained in:
		
							parent
							
								
									53b7b0f782
								
							
						
					
					
						commit
						6e887411b6
					
				
					 7 changed files with 93 additions and 18 deletions
				
			
		| 
						 | 
				
			
			@ -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,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,3 +1,5 @@
 | 
			
		|||
from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
import collections
 | 
			
		||||
import contextlib
 | 
			
		||||
import itertools
 | 
			
		||||
| 
						 | 
				
			
			@ -6,6 +8,7 @@
 | 
			
		|||
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,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,15 +2,44 @@
 | 
			
		|||
import functools
 | 
			
		||||
 | 
			
		||||
from typing import Dict, Union
 | 
			
		||||
from typing import runtime_checkable
 | 
			
		||||
from typing import Protocol
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
####
 | 
			
		||||
# from jaraco.path 3.4.1
 | 
			
		||||
 | 
			
		||||
FilesSpec = Dict[str, Union[str, bytes, 'FilesSpec']]  # type: ignore
 | 
			
		||||
# from jaraco.path 3.7.1
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def build(spec: FilesSpec, prefix=pathlib.Path()):
 | 
			
		||||
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 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
 | 
			
		||||
####
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -55,6 +55,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'
 | 
			
		||||
| 
						 | 
				
			
			@ -81,7 +101,7 @@ def test_module_resources(self):
 | 
			
		|||
        """
 | 
			
		||||
        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']
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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.
 | 
			
		||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue