Skip to content

Commit c3a1ad5

Browse files
jaracoclaude
andauthored
gh-160104: Refresh zipfile.Path from zipp 4.1.2 (#160105)
`Path.iterdir` now raises `NotADirectoryError` rather than `ValueError`, glob character classes follow shell-style semantics, and the workaround for the `stacklevel` bug in PyPy < 7.3.19 is removed. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent f65e2a2 commit c3a1ad5

6 files changed

Lines changed: 98 additions & 33 deletions

File tree

‎Doc/library/zipfile.rst‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,10 @@ Path objects are traversable using the ``/`` operator or ``joinpath``.
722722

723723
Enumerate the children of the current directory.
724724

725+
.. versionchanged:: 3.16
726+
Raises :exc:`NotADirectoryError` when the current context does not
727+
reference a directory. Previously, it raised :exc:`ValueError`.
728+
725729
.. method:: Path.is_dir()
726730

727731
Return ``True`` if the current context references a directory.

‎Lib/test/test_zipfile/_path/test_complexity.py‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
class TestComplexity(unittest.TestCase):
1818
@pytest.mark.flaky
1919
def test_implied_dirs_performance(self):
20-
best, others = big_o.big_o(
20+
best, _others = big_o.big_o(
2121
compose(consume, zipfile._path.CompleteDirs._implied_dirs),
2222
lambda size: [
2323
'/'.join(string.ascii_lowercase + str(n)) for n in range(size)
@@ -66,7 +66,7 @@ def make_deep_path(cls, depth):
6666
return ''.join(('d/',) * depth)
6767

6868
def test_baseline_regex_complexity(self):
69-
best, others = big_o.big_o(
69+
best, _others = big_o.big_o(
7070
lambda path: re.fullmatch(r'[^/]*\\.txt', path),
7171
self.make_deep_path,
7272
max_n=100,
@@ -76,7 +76,7 @@ def test_baseline_regex_complexity(self):
7676

7777
@pytest.mark.flaky
7878
def test_glob_depth(self):
79-
best, others = big_o.big_o(
79+
best, _others = big_o.big_o(
8080
lambda path: consume(path.glob('*.txt')),
8181
self.make_zip_path,
8282
max_n=100,
@@ -86,7 +86,7 @@ def test_glob_depth(self):
8686

8787
@pytest.mark.flaky
8888
def test_glob_width(self):
89-
best, others = big_o.big_o(
89+
best, _others = big_o.big_o(
9090
lambda path: consume(path.glob('*.txt')),
9191
lambda size: self.make_zip_path(width=size),
9292
max_n=100,
@@ -96,7 +96,7 @@ def test_glob_width(self):
9696

9797
@pytest.mark.flaky
9898
def test_glob_width_and_depth(self):
99-
best, others = big_o.big_o(
99+
best, _others = big_o.big_o(
100100
lambda path: consume(path.glob('*.txt')),
101101
lambda size: self.make_zip_path(depth=size, width=size),
102102
max_n=10,

‎Lib/test/test_zipfile/_path/test_path.py‎

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ def zipfile_ondisk(self, alpharep):
9595
def test_iterdir_and_types(self, alpharep):
9696
root = zipfile.Path(alpharep)
9797
assert root.is_dir()
98-
a, n, b, g, j = root.iterdir()
98+
a, n, b, g, j = root.iterdir() # noqa: RUF059
9999
assert a.is_file()
100100
assert b.is_dir()
101101
assert g.is_dir()
@@ -115,8 +115,8 @@ def test_is_file_missing(self, alpharep):
115115
@pass_alpharep
116116
def test_iterdir_on_file(self, alpharep):
117117
root = zipfile.Path(alpharep)
118-
a, n, b, g, j = root.iterdir()
119-
with self.assertRaises(ValueError):
118+
a, n, b, g, j = root.iterdir() # noqa: RUF059
119+
with self.assertRaises(NotADirectoryError):
120120
a.iterdir()
121121

122122
@pass_alpharep
@@ -130,7 +130,7 @@ def test_subdir_is_dir(self, alpharep):
130130
@pass_alpharep
131131
def test_open(self, alpharep):
132132
root = zipfile.Path(alpharep)
133-
a, n, b, g, j = root.iterdir()
133+
a, n, b, g, j = root.iterdir() # noqa: RUF059
134134
with a.open(encoding="utf-8") as strm:
135135
data = strm.read()
136136
self.assertEqual(data, "content of a")
@@ -173,24 +173,30 @@ def test_open_encoding_errors(self):
173173
data = u16.read_text("utf-8", encoding="utf-8")
174174

175175
# both keyword arguments work.
176-
with u16.open("r", encoding="utf-8", errors="strict") as f:
177-
# error during decoding with wrong codec.
178-
with self.assertRaises(UnicodeDecodeError):
179-
f.read()
176+
# error during decoding with wrong codec.
177+
with (
178+
u16.open("r", encoding="utf-8", errors="strict") as f,
179+
self.assertRaises(UnicodeDecodeError),
180+
):
181+
f.read()
180182

181183
@unittest.skipIf(
182184
not getattr(sys.flags, 'warn_default_encoding', 0),
183185
"Requires warn_default_encoding",
184186
)
187+
@unittest.skipIf(
188+
sys.implementation.name == 'pypy' and sys.pypy_version_info < (7, 3, 18),
189+
"Older PyPy versions set the wrong stacklevel in text_encoding()",
190+
)
185191
@pass_alpharep
186192
def test_encoding_warnings(self, alpharep):
187193
"""EncodingWarning must blame the read_text and open calls."""
188194
assert sys.flags.warn_default_encoding
189195
root = zipfile.Path(alpharep)
190-
with self.assertWarns(EncodingWarning) as wc: # noqa: F821 (astral-sh/ruff#13296)
196+
with self.assertWarns(EncodingWarning) as wc:
191197
root.joinpath("a.txt").read_text()
192198
assert __file__ == wc.filename
193-
with self.assertWarns(EncodingWarning) as wc: # noqa: F821 (astral-sh/ruff#13296)
199+
with self.assertWarns(EncodingWarning) as wc:
194200
root.joinpath("a.txt").open("r").close()
195201
assert __file__ == wc.filename
196202

@@ -235,7 +241,7 @@ def test_open_missing_directory(self, alpharep):
235241
@pass_alpharep
236242
def test_read(self, alpharep):
237243
root = zipfile.Path(alpharep)
238-
a, n, b, g, j = root.iterdir()
244+
a, n, b, g, j = root.iterdir() # noqa: RUF059
239245
assert a.read_text(encoding="utf-8") == "content of a"
240246
# Also check positional encoding arg (gh-101144).
241247
assert a.read_text("utf-8") == "content of a"
@@ -302,7 +308,7 @@ def test_mutability(self, alpharep):
302308
reflect that change.
303309
"""
304310
root = zipfile.Path(alpharep)
305-
a, n, b, g, j = root.iterdir()
311+
a, n, b, g, j = root.iterdir() # noqa: RUF059
306312
alpharep.writestr('foo.txt', 'foo')
307313
alpharep.writestr('bar/baz.txt', 'baz')
308314
assert any(child.name == 'foo.txt' for child in root.iterdir())
@@ -540,6 +546,27 @@ def test_glob_chars(self, alpharep):
540546
zipfile.Path(alpharep, "j/l.baz"),
541547
]
542548

549+
@parameterize(
550+
['alpharep', 'pattern', 'expected'],
551+
[
552+
(alpharep, pattern, expected)
553+
for alpharep in alpharep_generators
554+
for pattern, expected in [
555+
('[!a].txt', {'n.txt'}),
556+
('[!an].txt', set()),
557+
('[!b]', {'g/', 'j/'}),
558+
('j/[!k].b*', {'j/l.baz', 'j/m.bar'}),
559+
('j/[^k].b*', {'j/k.bin'}),
560+
('j/[!k-l].b*', {'j/m.bar'}),
561+
('j/[k-l].b*', {'j/k.bin', 'j/l.baz'}),
562+
('b[!x]c.txt', set()),
563+
]
564+
],
565+
)
566+
def test_glob_character_class_syntax(self, alpharep, pattern, expected):
567+
root = zipfile.Path(alpharep)
568+
assert {path.at for path in root.glob(pattern)} == expected
569+
543570
def test_glob_empty(self):
544571
zip_file = self.enterContext(zipfile.ZipFile(io.BytesIO(), 'w'))
545572
root = zipfile.Path(zip_file)
@@ -592,7 +619,7 @@ def test_pickle(self, alpharep, path_type, subpath):
592619
saved_1 = pickle.dumps(root)
593620
root.root.close()
594621
restored_1 = pickle.loads(saved_1)
595-
first, *rest = restored_1.iterdir()
622+
first, *_rest = restored_1.iterdir()
596623
assert first.read_text(encoding='utf-8').startswith('content of ')
597624
restored_1.root.close()
598625

‎Lib/zipfile/_path/__init__.py‎

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
import posixpath
1515
import re
1616
import stat
17-
import sys
1817
import zipfile
1918

2019
from ._functools import none_as, save_method_args
@@ -66,7 +65,7 @@ def _ancestry(path):
6665
path = path.rstrip(posixpath.sep)
6766
while path.rstrip(posixpath.sep):
6867
yield path
69-
path, tail = posixpath.split(path)
68+
path, _tail = posixpath.split(path)
7069

7170

7271
_dedupe = dict.fromkeys
@@ -156,10 +155,7 @@ def make(cls, source):
156155
return cls(source)
157156

158157
# Only allow for FastLookup when supplied zipfile is read-only
159-
if 'r' not in source.mode:
160-
cls = CompleteDirs
161-
162-
source.__class__ = cls
158+
source.__class__ = cls if 'r' in source.mode else CompleteDirs
163159
return source
164160

165161
@classmethod
@@ -195,12 +191,7 @@ def _name_set_prop(self):
195191

196192

197193
def _extract_text_encoding(encoding=None, *args, **kwargs):
198-
# compute stack level so that the caller of the caller sees any warning.
199-
is_pypy = sys.implementation.name == 'pypy'
200-
# PyPy no longer special cased after 7.3.19 (or maybe 7.3.18)
201-
# See jaraco/zipp#143
202-
is_old_pypi = is_pypy and sys.pypy_version_info < (7, 3, 19)
203-
stack_level = 3 + is_old_pypi
194+
stack_level = 3
204195
return io.text_encoding(encoding, stack_level), args, kwargs
205196

206197

@@ -403,7 +394,7 @@ def exists(self):
403394

404395
def iterdir(self):
405396
if not self.is_dir():
406-
raise ValueError("Can't listdir a file")
397+
raise NotADirectoryError("Can't listdir a file")
407398
subs = map(self._next, self.root.namelist())
408399
return filter(self._is_child, subs)
409400

‎Lib/zipfile/_path/glob.py‎

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import fnmatch
12
import os
23
import re
34

@@ -67,8 +68,32 @@ def replace(self, match):
6768
"""
6869
Perform the replacements for a match from :func:`separate`.
6970
"""
70-
return match.group('set') or (
71-
re.escape(match.group(0))
71+
handler = self._replace_set if match.group('set') else self._replace_plain
72+
return handler(match.group(0))
73+
74+
def _replace_set(self, character_set):
75+
r"""
76+
Translate a character set using shell-style semantics.
77+
78+
Glob character sets treat a leading ``!`` as negation and ``^``
79+
as a literal (unlike regex), so defer to fnmatch, but guard
80+
against matching a separator.
81+
82+
>>> Translator('/')._replace_set('[!a]')
83+
'(?![/])[^a]'
84+
>>> Translator('/')._replace_set('[^a]')
85+
'(?![/])[\\^a]'
86+
"""
87+
translated = _strip_fnmatch(fnmatch.translate(character_set))
88+
return rf'(?![{re.escape(self.seps)}]){translated}'
89+
90+
def _replace_plain(self, text):
91+
"""
92+
Escape the text, translating any wildcards.
93+
"""
94+
return (
95+
re
96+
.escape(text)
7297
.replace('\\*\\*', r'.*')
7398
.replace('\\*', rf'[^{re.escape(self.seps)}]*')
7499
.replace('\\?', r'[^/]')
@@ -111,3 +136,15 @@ def separate(pattern):
111136
['a', '[?]', 'txt']
112137
"""
113138
return re.finditer(r'([^\[]+)|(?P<set>[\[].*?[\]])|([\[][^\]]*$)', pattern)
139+
140+
141+
def _strip_fnmatch(translated):
142+
r"""
143+
Strip fnmatch's outer group and end marker.
144+
145+
>>> _strip_fnmatch(r'(?s:[^a])\z')
146+
'[^a]'
147+
>>> _strip_fnmatch(r'(?s:[^a])\Z')
148+
'[^a]'
149+
"""
150+
return re.fullmatch(r'\(\?s:(.*)\)\\[zZ]', translated, re.DOTALL).group(1)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Synchronized :class:`zipfile.Path` with :pypi:`zipp` 4.1.2.
2+
:meth:`zipfile.Path.iterdir` now raises :exc:`NotADirectoryError` instead of
3+
:exc:`ValueError` when the path does not reference a directory. Character
4+
classes in :meth:`!zipfile.Path.glob` and :meth:`!zipfile.Path.rglob` patterns
5+
now follow shell-style semantics, treating a leading ``!`` as negation and
6+
``^`` as a literal, and no longer match path separators.

0 commit comments

Comments
 (0)