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]

Reply via email to