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 ( | ||||
|     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 | ||||
| #### | ||||
|  |  | |||
|  | @ -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 @@ 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
	
	 Jason R. Coombs
						Jason R. Coombs