Skip to content

Commit cea910e

Browse files
authored
gh-97930: Merge with importlib_resources 5.9 (GH-97929)
* Merge with importlib_resources 5.9 * Update changelog
1 parent 5c9302d commit cea910e

File tree

7 files changed

+102
-29
lines changed

7 files changed

+102
-29
lines changed

Lib/importlib/resources/_common.py

+57-5
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,14 @@ def from_package(package):
6767

6868

6969
@contextlib.contextmanager
70-
def _tempfile(reader, suffix='',
71-
# gh-93353: Keep a reference to call os.remove() in late Python
72-
# finalization.
73-
*, _os_remove=os.remove):
70+
def _tempfile(
71+
reader,
72+
suffix='',
73+
# gh-93353: Keep a reference to call os.remove() in late Python
74+
# finalization.
75+
*,
76+
_os_remove=os.remove,
77+
):
7478
# Not using tempfile.NamedTemporaryFile as it leads to deeper 'try'
7579
# blocks due to the need to close the temporary file to work on Windows
7680
# properly.
@@ -89,13 +93,30 @@ def _tempfile(reader, suffix='',
8993
pass
9094

9195

96+
def _temp_file(path):
97+
return _tempfile(path.read_bytes, suffix=path.name)
98+
99+
100+
def _is_present_dir(path: Traversable) -> bool:
101+
"""
102+
Some Traversables implement ``is_dir()`` to raise an
103+
exception (i.e. ``FileNotFoundError``) when the
104+
directory doesn't exist. This function wraps that call
105+
to always return a boolean and only return True
106+
if there's a dir and it exists.
107+
"""
108+
with contextlib.suppress(FileNotFoundError):
109+
return path.is_dir()
110+
return False
111+
112+
92113
@functools.singledispatch
93114
def as_file(path):
94115
"""
95116
Given a Traversable object, return that object as a
96117
path on the local file system in a context manager.
97118
"""
98-
return _tempfile(path.read_bytes, suffix=path.name)
119+
return _temp_dir(path) if _is_present_dir(path) else _temp_file(path)
99120

100121

101122
@as_file.register(pathlib.Path)
@@ -105,3 +126,34 @@ def _(path):
105126
Degenerate behavior for pathlib.Path objects.
106127
"""
107128
yield path
129+
130+
131+
@contextlib.contextmanager
132+
def _temp_path(dir: tempfile.TemporaryDirectory):
133+
"""
134+
Wrap tempfile.TemporyDirectory to return a pathlib object.
135+
"""
136+
with dir as result:
137+
yield pathlib.Path(result)
138+
139+
140+
@contextlib.contextmanager
141+
def _temp_dir(path):
142+
"""
143+
Given a traversable dir, recursively replicate the whole tree
144+
to the file system in a context manager.
145+
"""
146+
assert path.is_dir()
147+
with _temp_path(tempfile.TemporaryDirectory()) as temp_dir:
148+
yield _write_contents(temp_dir, path)
149+
150+
151+
def _write_contents(target, source):
152+
child = target.joinpath(source.name)
153+
if source.is_dir():
154+
child.mkdir()
155+
for item in source.iterdir():
156+
_write_contents(child, item)
157+
else:
158+
child.open('wb').write(source.read_bytes())
159+
return child

Lib/importlib/resources/abc.py

+22-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import abc
22
import io
3+
import itertools
34
import os
5+
import pathlib
46
from typing import Any, BinaryIO, Iterable, Iterator, NoReturn, Text, Optional
57
from typing import runtime_checkable, Protocol
68
from typing import Union
@@ -53,6 +55,10 @@ def contents(self) -> Iterable[str]:
5355
raise FileNotFoundError
5456

5557

58+
class TraversalError(Exception):
59+
pass
60+
61+
5662
@runtime_checkable
5763
class Traversable(Protocol):
5864
"""
@@ -95,7 +101,6 @@ def is_file(self) -> bool:
95101
Return True if self is a file
96102
"""
97103

98-
@abc.abstractmethod
99104
def joinpath(self, *descendants: StrPath) -> "Traversable":
100105
"""
101106
Return Traversable resolved with any descendants applied.
@@ -104,6 +109,22 @@ def joinpath(self, *descendants: StrPath) -> "Traversable":
104109
and each may contain multiple levels separated by
105110
``posixpath.sep`` (``/``).
106111
"""
112+
if not descendants:
113+
return self
114+
names = itertools.chain.from_iterable(
115+
path.parts for path in map(pathlib.PurePosixPath, descendants)
116+
)
117+
target = next(names)
118+
matches = (
119+
traversable for traversable in self.iterdir() if traversable.name == target
120+
)
121+
try:
122+
match = next(matches)
123+
except StopIteration:
124+
raise TraversalError(
125+
"Target not found during traversal.", target, list(names)
126+
)
127+
return match.joinpath(*names)
107128

108129
def __truediv__(self, child: StrPath) -> "Traversable":
109130
"""

Lib/importlib/resources/readers.py

+7-9
Original file line numberDiff line numberDiff line change
@@ -82,15 +82,13 @@ def is_dir(self):
8282
def is_file(self):
8383
return False
8484

85-
def joinpath(self, child):
86-
# first try to find child in current paths
87-
for file in self.iterdir():
88-
if file.name == child:
89-
return file
90-
# if it does not exist, construct it with the first path
91-
return self._paths[0] / child
92-
93-
__truediv__ = joinpath
85+
def joinpath(self, *descendants):
86+
try:
87+
return super().joinpath(*descendants)
88+
except abc.TraversalError:
89+
# One of the paths did not resolve (a directory does not exist).
90+
# Just return something that will not exist.
91+
return self._paths[0].joinpath(*descendants)
9492

9593
def open(self, *args, **kwargs):
9694
raise FileNotFoundError(f'{self} is not a file')

Lib/importlib/resources/simple.py

-14
Original file line numberDiff line numberDiff line change
@@ -99,20 +99,6 @@ def iterdir(self):
9999
def open(self, *args, **kwargs):
100100
raise IsADirectoryError()
101101

102-
@staticmethod
103-
def _flatten(compound_names):
104-
for name in compound_names:
105-
yield from name.split('/')
106-
107-
def joinpath(self, *descendants):
108-
if not descendants:
109-
return self
110-
names = self._flatten(descendants)
111-
target = next(names)
112-
return next(
113-
traversable for traversable in self.iterdir() if traversable.name == target
114-
).joinpath(*names)
115-
116102

117103
class TraversableReader(TraversableResources, SimpleReader):
118104
"""

Lib/test/test_importlib/resources/test_reader.py

+5
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ def test_join_path(self):
7575
str(path.joinpath('imaginary'))[len(prefix) + 1 :],
7676
os.path.join('namespacedata01', 'imaginary'),
7777
)
78+
self.assertEqual(path.joinpath(), path)
79+
80+
def test_join_path_compound(self):
81+
path = MultiplexedPath(self.folder)
82+
assert not path.joinpath('imaginary/foo.py').exists()
7883

7984
def test_repr(self):
8085
self.assertEqual(

Lib/test/test_importlib/resources/test_resource.py

+8
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,14 @@ def test_submodule_contents_by_name(self):
111111
{'__init__.py', 'binary.file'},
112112
)
113113

114+
def test_as_file_directory(self):
115+
with resources.as_file(resources.files('ziptestdata')) as data:
116+
assert data.name == 'ziptestdata'
117+
assert data.is_dir()
118+
assert data.joinpath('subdirectory').is_dir()
119+
assert len(list(data.iterdir()))
120+
assert not data.parent.exists()
121+
114122

115123
class ResourceFromZipsTest02(util.ZipSetupBase, unittest.TestCase):
116124
ZIP_MODULE = zipdata02 # type: ignore
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Apply changes from importlib_resources 5.8 and 5.9: ``Traversable.joinpath``
2+
provides a concrete implementation. ``as_file`` now supports directories of
3+
resources.

0 commit comments

Comments
 (0)