https://github.com/python/cpython/commit/c3a1ad5f77968b7371b1f896fff93cc89232f555 commit: c3a1ad5f77968b7371b1f896fff93cc89232f555 branch: main author: Jason R. Coombs <[email protected]> committer: jaraco <[email protected]> date: 2026-10-22T11:53:41-07:00 summary:
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 <[email protected]> files: A Misc/NEWS.d/next/Library/2026-10-21-20-05-00.gh-issue-160104.BUAL5C.rst M Doc/library/zipfile.rst M Lib/test/test_zipfile/_path/test_complexity.py M Lib/test/test_zipfile/_path/test_path.py M Lib/zipfile/_path/__init__.py M Lib/zipfile/_path/glob.py diff --git a/Doc/library/zipfile.rst b/Doc/library/zipfile.rst index a3ae7aaba4bae9..7ade7fbc326617 100644 --- a/Doc/library/zipfile.rst +++ b/Doc/library/zipfile.rst @@ -722,6 +722,10 @@ Path objects are traversable using the ``/`` operator or ``joinpath``. Enumerate the children of the current directory. + .. versionchanged:: 3.16 + Raises :exc:`NotADirectoryError` when the current context does not + reference a directory. Previously, it raised :exc:`ValueError`. + .. method:: Path.is_dir() Return ``True`` if the current context references a directory. diff --git a/Lib/test/test_zipfile/_path/test_complexity.py b/Lib/test/test_zipfile/_path/test_complexity.py index 7c108fc6ab8191..f72b5b90c4510d 100644 --- a/Lib/test/test_zipfile/_path/test_complexity.py +++ b/Lib/test/test_zipfile/_path/test_complexity.py @@ -17,7 +17,7 @@ class TestComplexity(unittest.TestCase): @pytest.mark.flaky def test_implied_dirs_performance(self): - best, others = big_o.big_o( + best, _others = big_o.big_o( compose(consume, zipfile._path.CompleteDirs._implied_dirs), lambda size: [ '/'.join(string.ascii_lowercase + str(n)) for n in range(size) @@ -66,7 +66,7 @@ def make_deep_path(cls, depth): return ''.join(('d/',) * depth) def test_baseline_regex_complexity(self): - best, others = big_o.big_o( + best, _others = big_o.big_o( lambda path: re.fullmatch(r'[^/]*\\.txt', path), self.make_deep_path, max_n=100, @@ -76,7 +76,7 @@ def test_baseline_regex_complexity(self): @pytest.mark.flaky def test_glob_depth(self): - best, others = big_o.big_o( + best, _others = big_o.big_o( lambda path: consume(path.glob('*.txt')), self.make_zip_path, max_n=100, @@ -86,7 +86,7 @@ def test_glob_depth(self): @pytest.mark.flaky def test_glob_width(self): - best, others = big_o.big_o( + best, _others = big_o.big_o( lambda path: consume(path.glob('*.txt')), lambda size: self.make_zip_path(width=size), max_n=100, @@ -96,7 +96,7 @@ def test_glob_width(self): @pytest.mark.flaky def test_glob_width_and_depth(self): - best, others = big_o.big_o( + best, _others = big_o.big_o( lambda path: consume(path.glob('*.txt')), lambda size: self.make_zip_path(depth=size, width=size), max_n=10, diff --git a/Lib/test/test_zipfile/_path/test_path.py b/Lib/test/test_zipfile/_path/test_path.py index 663f2862bca902..eec79311947f6c 100644 --- a/Lib/test/test_zipfile/_path/test_path.py +++ b/Lib/test/test_zipfile/_path/test_path.py @@ -95,7 +95,7 @@ def zipfile_ondisk(self, alpharep): def test_iterdir_and_types(self, alpharep): root = zipfile.Path(alpharep) assert root.is_dir() - a, n, b, g, j = root.iterdir() + a, n, b, g, j = root.iterdir() # noqa: RUF059 assert a.is_file() assert b.is_dir() assert g.is_dir() @@ -115,8 +115,8 @@ def test_is_file_missing(self, alpharep): @pass_alpharep def test_iterdir_on_file(self, alpharep): root = zipfile.Path(alpharep) - a, n, b, g, j = root.iterdir() - with self.assertRaises(ValueError): + a, n, b, g, j = root.iterdir() # noqa: RUF059 + with self.assertRaises(NotADirectoryError): a.iterdir() @pass_alpharep @@ -130,7 +130,7 @@ def test_subdir_is_dir(self, alpharep): @pass_alpharep def test_open(self, alpharep): root = zipfile.Path(alpharep) - a, n, b, g, j = root.iterdir() + a, n, b, g, j = root.iterdir() # noqa: RUF059 with a.open(encoding="utf-8") as strm: data = strm.read() self.assertEqual(data, "content of a") @@ -173,24 +173,30 @@ def test_open_encoding_errors(self): data = u16.read_text("utf-8", encoding="utf-8") # both keyword arguments work. - with u16.open("r", encoding="utf-8", errors="strict") as f: - # error during decoding with wrong codec. - with self.assertRaises(UnicodeDecodeError): - f.read() + # error during decoding with wrong codec. + with ( + u16.open("r", encoding="utf-8", errors="strict") as f, + self.assertRaises(UnicodeDecodeError), + ): + f.read() @unittest.skipIf( not getattr(sys.flags, 'warn_default_encoding', 0), "Requires warn_default_encoding", ) + @unittest.skipIf( + sys.implementation.name == 'pypy' and sys.pypy_version_info < (7, 3, 18), + "Older PyPy versions set the wrong stacklevel in text_encoding()", + ) @pass_alpharep def test_encoding_warnings(self, alpharep): """EncodingWarning must blame the read_text and open calls.""" assert sys.flags.warn_default_encoding root = zipfile.Path(alpharep) - with self.assertWarns(EncodingWarning) as wc: # noqa: F821 (astral-sh/ruff#13296) + with self.assertWarns(EncodingWarning) as wc: root.joinpath("a.txt").read_text() assert __file__ == wc.filename - with self.assertWarns(EncodingWarning) as wc: # noqa: F821 (astral-sh/ruff#13296) + with self.assertWarns(EncodingWarning) as wc: root.joinpath("a.txt").open("r").close() assert __file__ == wc.filename @@ -235,7 +241,7 @@ def test_open_missing_directory(self, alpharep): @pass_alpharep def test_read(self, alpharep): root = zipfile.Path(alpharep) - a, n, b, g, j = root.iterdir() + a, n, b, g, j = root.iterdir() # noqa: RUF059 assert a.read_text(encoding="utf-8") == "content of a" # Also check positional encoding arg (gh-101144). assert a.read_text("utf-8") == "content of a" @@ -302,7 +308,7 @@ def test_mutability(self, alpharep): reflect that change. """ root = zipfile.Path(alpharep) - a, n, b, g, j = root.iterdir() + a, n, b, g, j = root.iterdir() # noqa: RUF059 alpharep.writestr('foo.txt', 'foo') alpharep.writestr('bar/baz.txt', 'baz') assert any(child.name == 'foo.txt' for child in root.iterdir()) @@ -540,6 +546,27 @@ def test_glob_chars(self, alpharep): zipfile.Path(alpharep, "j/l.baz"), ] + @parameterize( + ['alpharep', 'pattern', 'expected'], + [ + (alpharep, pattern, expected) + for alpharep in alpharep_generators + for pattern, expected in [ + ('[!a].txt', {'n.txt'}), + ('[!an].txt', set()), + ('[!b]', {'g/', 'j/'}), + ('j/[!k].b*', {'j/l.baz', 'j/m.bar'}), + ('j/[^k].b*', {'j/k.bin'}), + ('j/[!k-l].b*', {'j/m.bar'}), + ('j/[k-l].b*', {'j/k.bin', 'j/l.baz'}), + ('b[!x]c.txt', set()), + ] + ], + ) + def test_glob_character_class_syntax(self, alpharep, pattern, expected): + root = zipfile.Path(alpharep) + assert {path.at for path in root.glob(pattern)} == expected + def test_glob_empty(self): zip_file = self.enterContext(zipfile.ZipFile(io.BytesIO(), 'w')) root = zipfile.Path(zip_file) @@ -592,7 +619,7 @@ def test_pickle(self, alpharep, path_type, subpath): saved_1 = pickle.dumps(root) root.root.close() restored_1 = pickle.loads(saved_1) - first, *rest = restored_1.iterdir() + first, *_rest = restored_1.iterdir() assert first.read_text(encoding='utf-8').startswith('content of ') restored_1.root.close() diff --git a/Lib/zipfile/_path/__init__.py b/Lib/zipfile/_path/__init__.py index 010eeed95d2707..aba7bf91ccf47f 100644 --- a/Lib/zipfile/_path/__init__.py +++ b/Lib/zipfile/_path/__init__.py @@ -14,7 +14,6 @@ import posixpath import re import stat -import sys import zipfile from ._functools import none_as, save_method_args @@ -66,7 +65,7 @@ def _ancestry(path): path = path.rstrip(posixpath.sep) while path.rstrip(posixpath.sep): yield path - path, tail = posixpath.split(path) + path, _tail = posixpath.split(path) _dedupe = dict.fromkeys @@ -156,10 +155,7 @@ def make(cls, source): return cls(source) # Only allow for FastLookup when supplied zipfile is read-only - if 'r' not in source.mode: - cls = CompleteDirs - - source.__class__ = cls + source.__class__ = cls if 'r' in source.mode else CompleteDirs return source @classmethod @@ -195,12 +191,7 @@ def _name_set_prop(self): def _extract_text_encoding(encoding=None, *args, **kwargs): - # compute stack level so that the caller of the caller sees any warning. - is_pypy = sys.implementation.name == 'pypy' - # PyPy no longer special cased after 7.3.19 (or maybe 7.3.18) - # See jaraco/zipp#143 - is_old_pypi = is_pypy and sys.pypy_version_info < (7, 3, 19) - stack_level = 3 + is_old_pypi + stack_level = 3 return io.text_encoding(encoding, stack_level), args, kwargs @@ -403,7 +394,7 @@ def exists(self): def iterdir(self): if not self.is_dir(): - raise ValueError("Can't listdir a file") + raise NotADirectoryError("Can't listdir a file") subs = map(self._next, self.root.namelist()) return filter(self._is_child, subs) diff --git a/Lib/zipfile/_path/glob.py b/Lib/zipfile/_path/glob.py index bd2839304b7db2..5239772a600f8b 100644 --- a/Lib/zipfile/_path/glob.py +++ b/Lib/zipfile/_path/glob.py @@ -1,3 +1,4 @@ +import fnmatch import os import re @@ -67,8 +68,32 @@ def replace(self, match): """ Perform the replacements for a match from :func:`separate`. """ - return match.group('set') or ( - re.escape(match.group(0)) + handler = self._replace_set if match.group('set') else self._replace_plain + return handler(match.group(0)) + + def _replace_set(self, character_set): + r""" + Translate a character set using shell-style semantics. + + Glob character sets treat a leading ``!`` as negation and ``^`` + as a literal (unlike regex), so defer to fnmatch, but guard + against matching a separator. + + >>> Translator('/')._replace_set('[!a]') + '(?![/])[^a]' + >>> Translator('/')._replace_set('[^a]') + '(?![/])[\\^a]' + """ + translated = _strip_fnmatch(fnmatch.translate(character_set)) + return rf'(?![{re.escape(self.seps)}]){translated}' + + def _replace_plain(self, text): + """ + Escape the text, translating any wildcards. + """ + return ( + re + .escape(text) .replace('\\*\\*', r'.*') .replace('\\*', rf'[^{re.escape(self.seps)}]*') .replace('\\?', r'[^/]') @@ -111,3 +136,15 @@ def separate(pattern): ['a', '[?]', 'txt'] """ return re.finditer(r'([^\[]+)|(?P<set>[\[].*?[\]])|([\[][^\]]*$)', pattern) + + +def _strip_fnmatch(translated): + r""" + Strip fnmatch's outer group and end marker. + + >>> _strip_fnmatch(r'(?s:[^a])\z') + '[^a]' + >>> _strip_fnmatch(r'(?s:[^a])\Z') + '[^a]' + """ + return re.fullmatch(r'\(\?s:(.*)\)\\[zZ]', translated, re.DOTALL).group(1) diff --git a/Misc/NEWS.d/next/Library/2026-10-21-20-05-00.gh-issue-160104.BUAL5C.rst b/Misc/NEWS.d/next/Library/2026-10-21-20-05-00.gh-issue-160104.BUAL5C.rst new file mode 100644 index 00000000000000..bab414b247ff25 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-21-20-05-00.gh-issue-160104.BUAL5C.rst @@ -0,0 +1,6 @@ +Synchronized :class:`zipfile.Path` with :pypi:`zipp` 4.1.2. +:meth:`zipfile.Path.iterdir` now raises :exc:`NotADirectoryError` instead of +:exc:`ValueError` when the path does not reference a directory. Character +classes in :meth:`!zipfile.Path.glob` and :meth:`!zipfile.Path.rglob` patterns +now follow shell-style semantics, treating a leading ``!`` as negation and +``^`` as a literal, and no longer match path separators. _______________________________________________ Python-checkins mailing list -- [email protected] To unsubscribe send an email to [email protected] https://mail.python.org/mailman3//lists/python-checkins.python.org Member address: [email protected]
