Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Doc/library/zipfile.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 5 additions & 5 deletions Lib/test/test_zipfile/_path/test_complexity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
53 changes: 40 additions & 13 deletions Lib/test/test_zipfile/_path/test_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()

Expand Down
17 changes: 4 additions & 13 deletions Lib/zipfile/_path/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
import posixpath
import re
import stat
import sys
import zipfile

from ._functools import none_as, save_method_args
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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)

Expand Down
41 changes: 39 additions & 2 deletions Lib/zipfile/_path/glob.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import fnmatch
import os
import re

Expand Down Expand Up @@ -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'[^/]')
Expand Down Expand Up @@ -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)
Original file line number Diff line number Diff line change
@@ -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.
Loading