mirror of
				https://github.com/python/cpython.git
				synced 2025-10-31 13:41:24 +00:00 
			
		
		
		
	gh-123987: Fix NotADirectoryError in NamespaceReader when sentinel present (#124018)
This commit is contained in:
		
							parent
							
								
									fccbfc40b5
								
							
						
					
					
						commit
						b543b32eff
					
				
					 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 ( | from ._common import ( | ||||||
|     as_file, |     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 |     # zipimport.zipimporter does not support weak references, resulting in a | ||||||
|     # TypeError.  That seems terrible. |     # TypeError.  That seems terrible. | ||||||
|     spec = package.__spec__ |     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: |     if reader is None: | ||||||
|         return None |         return None | ||||||
|     return reader(spec.name)  # type: ignore |     return reader(spec.name)  # type: ignore[union-attr] | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @functools.singledispatch | @functools.singledispatch | ||||||
|  |  | ||||||
|  | @ -1,3 +1,5 @@ | ||||||
|  | from __future__ import annotations | ||||||
|  | 
 | ||||||
| import collections | import collections | ||||||
| import contextlib | import contextlib | ||||||
| import itertools | import itertools | ||||||
|  | @ -6,6 +8,7 @@ | ||||||
| import re | import re | ||||||
| import warnings | import warnings | ||||||
| import zipfile | import zipfile | ||||||
|  | from collections.abc import Iterator | ||||||
| 
 | 
 | ||||||
| from . import abc | from . import abc | ||||||
| 
 | 
 | ||||||
|  | @ -135,27 +138,31 @@ class NamespaceReader(abc.TraversableResources): | ||||||
|     def __init__(self, namespace_path): |     def __init__(self, namespace_path): | ||||||
|         if 'NamespacePath' not in str(namespace_path): |         if 'NamespacePath' not in str(namespace_path): | ||||||
|             raise ValueError('Invalid path') |             raise ValueError('Invalid path') | ||||||
|         self.path = MultiplexedPath(*map(self._resolve, namespace_path)) |         self.path = MultiplexedPath(*filter(bool, map(self._resolve, namespace_path))) | ||||||
| 
 | 
 | ||||||
|     @classmethod |     @classmethod | ||||||
|     def _resolve(cls, path_str) -> abc.Traversable: |     def _resolve(cls, path_str) -> abc.Traversable | None: | ||||||
|         r""" |         r""" | ||||||
|         Given an item from a namespace path, resolve it to a Traversable. |         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 |         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 |         zipfile plus the path within the zipfile, e.g. ``/foo/bar`` or | ||||||
|         ``/foo/baz.zip/inner_dir`` or ``foo\baz.zip\inner_dir\sub``. |         ``/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()) |         dirs = (cand for cand in cls._candidate_paths(path_str) if cand.is_dir()) | ||||||
|         return dir |         return next(dirs, None) | ||||||
| 
 | 
 | ||||||
|     @classmethod |     @classmethod | ||||||
|     def _candidate_paths(cls, path_str): |     def _candidate_paths(cls, path_str: str) -> Iterator[abc.Traversable]: | ||||||
|         yield pathlib.Path(path_str) |         yield pathlib.Path(path_str) | ||||||
|         yield from cls._resolve_zip_path(path_str) |         yield from cls._resolve_zip_path(path_str) | ||||||
| 
 | 
 | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def _resolve_zip_path(path_str): |     def _resolve_zip_path(path_str: str): | ||||||
|         for match in reversed(list(re.finditer(r'[\\/]', path_str))): |         for match in reversed(list(re.finditer(r'[\\/]', path_str))): | ||||||
|             with contextlib.suppress( |             with contextlib.suppress( | ||||||
|                 FileNotFoundError, |                 FileNotFoundError, | ||||||
|  |  | ||||||
|  | @ -77,7 +77,7 @@ class ResourceHandle(Traversable): | ||||||
| 
 | 
 | ||||||
|     def __init__(self, parent: ResourceContainer, name: str): |     def __init__(self, parent: ResourceContainer, name: str): | ||||||
|         self.parent = parent |         self.parent = parent | ||||||
|         self.name = name  # type: ignore |         self.name = name  # type: ignore[misc] | ||||||
| 
 | 
 | ||||||
|     def is_file(self): |     def is_file(self): | ||||||
|         return True |         return True | ||||||
|  |  | ||||||
|  | @ -2,15 +2,44 @@ | ||||||
| import functools | import functools | ||||||
| 
 | 
 | ||||||
| from typing import Dict, Union | 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 |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| 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. |     Build a set of files/directories, as described by the spec. | ||||||
| 
 | 
 | ||||||
|  | @ -25,21 +54,25 @@ def build(spec: FilesSpec, prefix=pathlib.Path()): | ||||||
|     ...             "__init__.py": "", |     ...             "__init__.py": "", | ||||||
|     ...         }, |     ...         }, | ||||||
|     ...         "baz.py": "# Some code", |     ...         "baz.py": "# Some code", | ||||||
|     ...     } |     ...         "bar.py": Symlink("baz.py"), | ||||||
|  |     ...     }, | ||||||
|  |     ...     "bing": Symlink("foo"), | ||||||
|     ... } |     ... } | ||||||
|     >>> target = getfixture('tmp_path') |     >>> target = getfixture('tmp_path') | ||||||
|     >>> build(spec, target) |     >>> build(spec, target) | ||||||
|     >>> target.joinpath('foo/baz.py').read_text(encoding='utf-8') |     >>> target.joinpath('foo/baz.py').read_text(encoding='utf-8') | ||||||
|     '# Some code' |     '# Some code' | ||||||
|  |     >>> target.joinpath('bing/bar.py').read_text(encoding='utf-8') | ||||||
|  |     '# Some code' | ||||||
|     """ |     """ | ||||||
|     for name, contents in spec.items(): |     for name, contents in spec.items(): | ||||||
|         create(contents, pathlib.Path(prefix) / name) |         create(contents, _ensure_tree_maker(prefix) / name) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @functools.singledispatch | @functools.singledispatch | ||||||
| def create(content: Union[str, bytes, FilesSpec], path): | def create(content: Union[str, bytes, FilesSpec], path): | ||||||
|     path.mkdir(exist_ok=True) |     path.mkdir(exist_ok=True) | ||||||
|     build(content, prefix=path)  # type: ignore |     build(content, prefix=path)  # type: ignore[arg-type] | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @create.register | @create.register | ||||||
|  | @ -52,5 +85,10 @@ def _(content: str, path): | ||||||
|     path.write_text(content, encoding='utf-8') |     path.write_text(content, encoding='utf-8') | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @create.register | ||||||
|  | def _(content: Symlink, path): | ||||||
|  |     path.symlink_to(content) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| # end from jaraco.path | # end from jaraco.path | ||||||
| #### | #### | ||||||
|  |  | ||||||
|  | @ -60,6 +60,26 @@ class OpenZipTests(FilesTests, util.ZipSetup, unittest.TestCase): | ||||||
| class OpenNamespaceTests(FilesTests, util.DiskSetup, unittest.TestCase): | class OpenNamespaceTests(FilesTests, util.DiskSetup, unittest.TestCase): | ||||||
|     MODULE = 'namespacedata01' |     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): | class OpenNamespaceZipTests(FilesTests, util.ZipSetup, unittest.TestCase): | ||||||
|     ZIP_MODULE = 'namespacedata01' |     ZIP_MODULE = 'namespacedata01' | ||||||
|  | @ -86,7 +106,7 @@ def test_module_resources(self): | ||||||
|         """ |         """ | ||||||
|         A module can have resources found adjacent to the module. |         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') |         actual = resources.files(mod).joinpath('res.txt').read_text(encoding='utf-8') | ||||||
|         assert actual == self.spec['res.txt'] |         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
	
	 Jason R. Coombs
						Jason R. Coombs