Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit b543b32

Browse filesBrowse files
authored
gh-123987: Fix NotADirectoryError in NamespaceReader when sentinel present (#124018)
1 parent fccbfc4 commit b543b32
Copy full SHA for b543b32

File tree

7 files changed

+92
-17
lines changed
Filter options

7 files changed

+92
-17
lines changed

‎Lib/importlib/resources/__init__.py

Copy file name to clipboardExpand all lines: Lib/importlib/resources/__init__.py
+8-1Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
"""Read resources contained within a package."""
1+
"""
2+
Read resources contained within a package.
3+
4+
This codebase is shared between importlib.resources in the stdlib
5+
and importlib_resources in PyPI. See
6+
https://github.com/python/importlib_metadata/wiki/Development-Methodology
7+
for more detail.
8+
"""
29

310
from ._common import (
411
as_file,

‎Lib/importlib/resources/_common.py

Copy file name to clipboardExpand all lines: Lib/importlib/resources/_common.py
+2-2Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,10 @@ def get_resource_reader(package: types.ModuleType) -> Optional[ResourceReader]:
6666
# zipimport.zipimporter does not support weak references, resulting in a
6767
# TypeError. That seems terrible.
6868
spec = package.__spec__
69-
reader = getattr(spec.loader, 'get_resource_reader', None) # type: ignore
69+
reader = getattr(spec.loader, 'get_resource_reader', None) # type: ignore[union-attr]
7070
if reader is None:
7171
return None
72-
return reader(spec.name) # type: ignore
72+
return reader(spec.name) # type: ignore[union-attr]
7373

7474

7575
@functools.singledispatch

‎Lib/importlib/resources/readers.py

Copy file name to clipboardExpand all lines: Lib/importlib/resources/readers.py
+13-6Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
import collections
24
import contextlib
35
import itertools
@@ -6,6 +8,7 @@
68
import re
79
import warnings
810
import zipfile
11+
from collections.abc import Iterator
912

1013
from . import abc
1114

@@ -135,27 +138,31 @@ class NamespaceReader(abc.TraversableResources):
135138
def __init__(self, namespace_path):
136139
if 'NamespacePath' not in str(namespace_path):
137140
raise ValueError('Invalid path')
138-
self.path = MultiplexedPath(*map(self._resolve, namespace_path))
141+
self.path = MultiplexedPath(*filter(bool, map(self._resolve, namespace_path)))
139142

140143
@classmethod
141-
def _resolve(cls, path_str) -> abc.Traversable:
144+
def _resolve(cls, path_str) -> abc.Traversable | None:
142145
r"""
143146
Given an item from a namespace path, resolve it to a Traversable.
144147
145148
path_str might be a directory on the filesystem or a path to a
146149
zipfile plus the path within the zipfile, e.g. ``/foo/bar`` or
147150
``/foo/baz.zip/inner_dir`` or ``foo\baz.zip\inner_dir\sub``.
151+
152+
path_str might also be a sentinel used by editable packages to
153+
trigger other behaviors (see python/importlib_resources#311).
154+
In that case, return None.
148155
"""
149-
(dir,) = (cand for cand in cls._candidate_paths(path_str) if cand.is_dir())
150-
return dir
156+
dirs = (cand for cand in cls._candidate_paths(path_str) if cand.is_dir())
157+
return next(dirs, None)
151158

152159
@classmethod
153-
def _candidate_paths(cls, path_str):
160+
def _candidate_paths(cls, path_str: str) -> Iterator[abc.Traversable]:
154161
yield pathlib.Path(path_str)
155162
yield from cls._resolve_zip_path(path_str)
156163

157164
@staticmethod
158-
def _resolve_zip_path(path_str):
165+
def _resolve_zip_path(path_str: str):
159166
for match in reversed(list(re.finditer(r'[\\/]', path_str))):
160167
with contextlib.suppress(
161168
FileNotFoundError,

‎Lib/importlib/resources/simple.py

Copy file name to clipboardExpand all lines: Lib/importlib/resources/simple.py
+1-1Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ class ResourceHandle(Traversable):
7777

7878
def __init__(self, parent: ResourceContainer, name: str):
7979
self.parent = parent
80-
self.name = name # type: ignore
80+
self.name = name # type: ignore[misc]
8181

8282
def is_file(self):
8383
return True

‎Lib/test/test_importlib/resources/_path.py

Copy file name to clipboardExpand all lines: Lib/test/test_importlib/resources/_path.py
+44-6Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,44 @@
22
import functools
33

44
from typing import Dict, Union
5+
from typing import runtime_checkable
6+
from typing import Protocol
57

68

79
####
8-
# from jaraco.path 3.4.1
10+
# from jaraco.path 3.7.1
911

10-
FilesSpec = Dict[str, Union[str, bytes, 'FilesSpec']] # type: ignore
1112

13+
class Symlink(str):
14+
"""
15+
A string indicating the target of a symlink.
16+
"""
17+
18+
19+
FilesSpec = Dict[str, Union[str, bytes, Symlink, 'FilesSpec']]
20+
21+
22+
@runtime_checkable
23+
class TreeMaker(Protocol):
24+
def __truediv__(self, *args, **kwargs): ... # pragma: no cover
25+
26+
def mkdir(self, **kwargs): ... # pragma: no cover
27+
28+
def write_text(self, content, **kwargs): ... # pragma: no cover
29+
30+
def write_bytes(self, content): ... # pragma: no cover
1231

13-
def build(spec: FilesSpec, prefix=pathlib.Path()):
32+
def symlink_to(self, target): ... # pragma: no cover
33+
34+
35+
def _ensure_tree_maker(obj: Union[str, TreeMaker]) -> TreeMaker:
36+
return obj if isinstance(obj, TreeMaker) else pathlib.Path(obj) # type: ignore[return-value]
37+
38+
39+
def build(
40+
spec: FilesSpec,
41+
prefix: Union[str, TreeMaker] = pathlib.Path(), # type: ignore[assignment]
42+
):
1443
"""
1544
Build a set of files/directories, as described by the spec.
1645
@@ -25,21 +54,25 @@ def build(spec: FilesSpec, prefix=pathlib.Path()):
2554
... "__init__.py": "",
2655
... },
2756
... "baz.py": "# Some code",
28-
... }
57+
... "bar.py": Symlink("baz.py"),
58+
... },
59+
... "bing": Symlink("foo"),
2960
... }
3061
>>> target = getfixture('tmp_path')
3162
>>> build(spec, target)
3263
>>> target.joinpath('foo/baz.py').read_text(encoding='utf-8')
3364
'# Some code'
65+
>>> target.joinpath('bing/bar.py').read_text(encoding='utf-8')
66+
'# Some code'
3467
"""
3568
for name, contents in spec.items():
36-
create(contents, pathlib.Path(prefix) / name)
69+
create(contents, _ensure_tree_maker(prefix) / name)
3770

3871

3972
@functools.singledispatch
4073
def create(content: Union[str, bytes, FilesSpec], path):
4174
path.mkdir(exist_ok=True)
42-
build(content, prefix=path) # type: ignore
75+
build(content, prefix=path) # type: ignore[arg-type]
4376

4477

4578
@create.register
@@ -52,5 +85,10 @@ def _(content: str, path):
5285
path.write_text(content, encoding='utf-8')
5386

5487

88+
@create.register
89+
def _(content: Symlink, path):
90+
path.symlink_to(content)
91+
92+
5593
# end from jaraco.path
5694
####

‎Lib/test/test_importlib/resources/test_files.py

Copy file name to clipboardExpand all lines: Lib/test/test_importlib/resources/test_files.py
+21-1Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,26 @@ class OpenZipTests(FilesTests, util.ZipSetup, unittest.TestCase):
6060
class OpenNamespaceTests(FilesTests, util.DiskSetup, unittest.TestCase):
6161
MODULE = 'namespacedata01'
6262

63+
def test_non_paths_in_dunder_path(self):
64+
"""
65+
Non-path items in a namespace package's ``__path__`` are ignored.
66+
67+
As reported in python/importlib_resources#311, some tools
68+
like Setuptools, when creating editable packages, will inject
69+
non-paths into a namespace package's ``__path__``, a
70+
sentinel like
71+
``__editable__.sample_namespace-1.0.finder.__path_hook__``
72+
to cause the ``PathEntryFinder`` to be called when searching
73+
for packages. In that case, resources should still be loadable.
74+
"""
75+
import namespacedata01
76+
77+
namespacedata01.__path__.append(
78+
'__editable__.sample_namespace-1.0.finder.__path_hook__'
79+
)
80+
81+
resources.files(namespacedata01)
82+
6383

6484
class OpenNamespaceZipTests(FilesTests, util.ZipSetup, unittest.TestCase):
6585
ZIP_MODULE = 'namespacedata01'
@@ -86,7 +106,7 @@ def test_module_resources(self):
86106
"""
87107
A module can have resources found adjacent to the module.
88108
"""
89-
import mod
109+
import mod # type: ignore[import-not-found]
90110

91111
actual = resources.files(mod).joinpath('res.txt').read_text(encoding='utf-8')
92112
assert actual == self.spec['res.txt']
+3Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fixed issue in NamespaceReader where a non-path item in a namespace path,
2+
such as a sentinel added by an editable installer, would break resource
3+
loading.

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.