From 02e8217e70d57759da8db5895ecd55a469b7ab74 Mon Sep 17 00:00:00 2001 From: barneygale Date: Wed, 26 Feb 2025 01:00:24 +0000 Subject: [PATCH 1/7] GH-130614: Add test suites for readable and writable `pathlib` paths Add `test.test_pathlib.test_read`, which tests `ReadablePath` subclasses. Tests are run against `ReadableZipPath`, `ReadableLocalPath`, and `Path`. This test suite is mostly incomplete. Add `test.test_pathlib.test_write`, which tests `WritablePath` subclasses. Tests are run against `WritableZipPath`, `WritableLocalPath`, and `Path`. This test suite is mostly complete. Add `test.test_pathlib.support.zip_path`, which provides the `*ZipPath` classes mentioned above. It also provides a `ZipPathGround` class that's used by tests to make assertions about the contents of the zip file. Add `test.test_pathlib.support.local_path`, which provides the `*LocalPath` classes mentioned above. They're stripped-back versions of `pathlib.Path`, basically. It also provides `LocalPathGround` class that's used by tests to access the local filesystem. --- Lib/test/test_pathlib/support/__init__.py | 0 Lib/test/test_pathlib/support/local_path.py | 181 +++++++++++++++ Lib/test/test_pathlib/support/zip_path.py | 245 ++++++++++++++++++++ Lib/test/test_pathlib/test_pathlib_abc.py | 52 ----- Lib/test/test_pathlib/test_read.py | 70 ++++++ Lib/test/test_pathlib/test_write.py | 105 +++++++++ 6 files changed, 601 insertions(+), 52 deletions(-) create mode 100644 Lib/test/test_pathlib/support/__init__.py create mode 100644 Lib/test/test_pathlib/support/local_path.py create mode 100644 Lib/test/test_pathlib/support/zip_path.py create mode 100644 Lib/test/test_pathlib/test_read.py create mode 100644 Lib/test/test_pathlib/test_write.py diff --git a/Lib/test/test_pathlib/support/__init__.py b/Lib/test/test_pathlib/support/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/Lib/test/test_pathlib/support/local_path.py b/Lib/test/test_pathlib/support/local_path.py new file mode 100644 index 00000000000000..2104d4e37abad6 --- /dev/null +++ b/Lib/test/test_pathlib/support/local_path.py @@ -0,0 +1,181 @@ +import os +import pathlib._abc +import pathlib.types +import posixpath + +from test.support import os_helper + + +class LocalPathInfo(pathlib.types.PathInfo): + """ + Simple implementation of PathInfo for a local path + """ + def __init__(self, path): + self._path = str(path) + self._exists = None + self._is_dir = None + self._is_file = None + self._is_symlink = None + + def exists(self, *, follow_symlinks=True): + """Whether this path exists.""" + if not follow_symlinks and self.is_symlink(): + return True + if self._exists is None: + self._exists = os.path.exists(self._path) + return self._exists + + def is_dir(self, *, follow_symlinks=True): + """Whether this path is a directory.""" + if not follow_symlinks and self.is_symlink(): + return False + if self._is_dir is None: + self._is_dir = os.path.isdir(self._path) + return self._is_dir + + def is_file(self, *, follow_symlinks=True): + """Whether this path is a regular file.""" + if not follow_symlinks and self.is_symlink(): + return False + if self._is_file is None: + self._is_file = os.path.isfile(self._path) + return self._is_file + + def is_symlink(self): + """Whether this path is a symbolic link.""" + if self._is_symlink is None: + self._is_symlink = os.path.islink(self._path) + return self._is_symlink + + +class ReadableLocalPath(pathlib._abc.ReadablePath): + """ + Simple implementation of a ReadablePath class for local filesystem paths. + """ + parser = os.path + + def __init__(self, *pathsegments): + self._segments = pathsegments + self._info = None + + def __str__(self): + if not self._segments: + return '' + return self.parser.join(*self._segments) + + def __fspath__(self): + return str(self) + + def with_segments(self, *pathsegments): + return type(self)(*pathsegments) + + def __open_rb__(self, buffering=-1): + return open(self, 'rb') + + def iterdir(self): + return (self / name for name in os.listdir(self)) + + def readlink(self): + return self.with_segments(os.readlink(self)) + + @property + def info(self): + if self._info is None: + self._info = LocalPathInfo(self) + return self._info + + +class WritableLocalPath(pathlib._abc.WritablePath): + """ + Simple implementation of a WritablePath class for local filesystem paths. + """ + + __slots__ = ('_segments',) + parser = posixpath + + def __init__(self, *pathsegments): + self._segments = pathsegments + + def __str__(self): + if not self._segments: + return '' + return self.parser.join(*self._segments) + + def __fspath__(self): + return str(self) + + def with_segments(self, *pathsegments): + return type(self)(*pathsegments) + + def __open_wb__(self, buffering=-1): + return open(self, 'wb') + + def mkdir(self, mode=0o777): + os.mkdir(self, mode) + + def symlink_to(self, target, target_is_directory=False): + os.symlink(target, self, target_is_directory) + + +class LocalPathGround: + can_symlink = os_helper.can_symlink() + + def __init__(self, path_cls): + self.path_cls = path_cls + + def setup(self): + root = self.path_cls(os_helper.TESTFN) + os.mkdir(root) + return root + + def teardown(self, root): + os_helper.rmtree(root) + + def create_file(self, p, data=b''): + with open(p, 'wb') as f: + f.write(data) + + def create_hierarchy(self, p): + os.mkdir(os.path.join(p, 'dirA')) + os.mkdir(os.path.join(p, 'dirB')) + os.mkdir(os.path.join(p, 'dirC')) + os.mkdir(os.path.join(p, 'dirC', 'dirD')) + with open(os.path.join(p, 'fileA'), 'wb') as f: + f.write(b"this is file A\n") + with open(os.path.join(p, 'dirB', 'fileB'), 'wb') as f: + f.write(b"this is file B\n") + with open(os.path.join(p, 'dirC', 'fileC'), 'wb') as f: + f.write(b"this is file C\n") + with open(os.path.join(p, 'dirC', 'novel.txt'), 'wb') as f: + f.write(b"this is a novel\n") + with open(os.path.join(p, 'dirC', 'dirD', 'fileD'), 'wb') as f: + f.write(b"this is file D\n") + if self.can_symlink: + # Relative symlinks. + os.symlink('fileA', os.path.join(p, 'linkA')) + os.symlink('non-existing', os.path.join(p, 'brokenLink')) + os.symlink('dirB', + os.path.join(p, 'linkB'), + target_is_directory=True) + os.symlink(os.path.join('..', 'dirB'), + os.path.join(p, 'dirA', 'linkC'), + target_is_directory=True) + # This one goes upwards, creating a loop. + os.symlink(os.path.join('..', 'dirB'), + os.path.join(p, 'dirB', 'linkD'), + target_is_directory=True) + # Broken symlink (pointing to itself). + os.symlink('brokenLinkLoop', os.path.join(p, 'brokenLinkLoop')) + + isdir = staticmethod(os.path.isdir) + isfile = staticmethod(os.path.isfile) + islink = staticmethod(os.path.islink) + readlink = staticmethod(os.readlink) + + def readtext(self, p): + with open(p, 'r') as f: + return f.read() + + def readbytes(self, p): + with open(p, 'rb') as f: + return f.read() diff --git a/Lib/test/test_pathlib/support/zip_path.py b/Lib/test/test_pathlib/support/zip_path.py new file mode 100644 index 00000000000000..59bc4cb27661f9 --- /dev/null +++ b/Lib/test/test_pathlib/support/zip_path.py @@ -0,0 +1,245 @@ +import errno +import io +import pathlib._abc +import posixpath +import stat +import zipfile + + +class MissingZipPathInfo: + """ + PathInfo implementation that is used when a zip file member is missing. + """ + + def exists(self, follow_symlinks=True): + return False + + def is_dir(self, follow_symlinks=True): + return False + + def is_file(self, follow_symlinks=True): + return False + + def is_symlink(self, follow_symlinks=True): + return False + + +class ZipPathInfo: + """ + PathInfo implementation for an existing zip file member. + """ + __slots__ = ('parent', 'children', 'zip_info') + + def __init__(self, parent=None): + self.parent = parent or self + self.children = {} + self.zip_info = None + + def exists(self, follow_symlinks=True): + return True + + def is_dir(self, follow_symlinks=True): + if self.zip_info is None: + # This directory is implied to exist by another member. + return True + return self.zip_info.filename.endswith('/') + + def is_file(self, follow_symlinks=True): + return not self.is_dir(follow_symlinks=follow_symlinks) + + def is_symlink(self): + return False + + +class ZipFileList: + """ + `list`-like object that we inject as `ZipFile.filelist`. We maintain a + tree of `ZipPathInfo` objects representing the zip file members. A new + `resolve()` method fetches an entry from the tree. + """ + + __slots__ = ('_root_info', '_items') + + def __init__(self, items): + self._root_info = ZipPathInfo() + self._items = [] + for item in items: + self.append(item) + + def __len__(self): + return len(self._items) + + def __iter__(self): + return iter(self._items) + + def append(self, item): + self._items.append(item) + self.resolve(item.filename, create=True).zip_info = item + + def resolve(self, path, create=False): + """ + Returns a PathInfo object for the given path by walking the tree. + """ + path_info = self._root_info + for name in path.split('/'): + if not name or name == '.': + pass + elif name == '..': + path_info = path_info.parent + elif name in path_info.children: + path_info = path_info.children[name] + elif create: + path_info.children[name] = path_info = ZipPathInfo(path_info) + else: + path_info = MissingZipPathInfo() + break + return path_info + + +class ReadableZipPath(pathlib._abc.ReadablePath): + """ + Simple implementation of a ReadablePath class for .zip files. + """ + + __slots__ = ('_segments', 'zip_file') + parser = posixpath + + def __init__(self, *pathsegments, zip_file): + self._segments = pathsegments + self.zip_file = zip_file + if not isinstance(zip_file.filelist, ZipFileList): + zip_file.filelist = ZipFileList(zip_file.filelist) + + def __hash__(self): + return hash((str(self), self.zip_file)) + + def __eq__(self, other): + if not isinstance(other, ReadableZipPath): + return NotImplemented + return str(self) == str(other) and self.zip_file is other.zip_file + + def __str__(self): + if not self._segments: + return '' + return self.parser.join(*self._segments) + + def with_segments(self, *pathsegments): + return type(self)(*pathsegments, zip_file=self.zip_file) + + def __open_rb__(self, buffering=-1): + return self.zip_file.open(str(self), 'r') + + def iterdir(self): + info = self.info + if not info.exists(): + raise FileNotFoundError(errno.ENOENT, "File not found", self) + elif not info.is_dir(): + raise NotADirectoryError(errno.ENOTDIR, "Not a directory", self) + return (self / name for name in info.children) + + def readlink(self): + raise OSError(errno.ENOTSUP, "Not supported", self) + + @property + def info(self): + return self.zip_file.filelist.resolve(str(self)) + + +class WritableZipPath(pathlib._abc.WritablePath): + """ + Simple implementation of a WritablePath class for .zip files. + """ + + __slots__ = ('_segments', 'zip_file') + parser = posixpath + + def __init__(self, *pathsegments, zip_file): + self._segments = pathsegments + self.zip_file = zip_file + + def __hash__(self): + return hash((str(self), self.zip_file)) + + def __eq__(self, other): + if not isinstance(other, WritableZipPath): + return NotImplemented + return str(self) == str(other) and self.zip_file is other.zip_file + + def __str__(self): + if not self._segments: + return '' + return self.parser.join(*self._segments) + + def with_segments(self, *pathsegments): + return type(self)(*pathsegments, zip_file=self.zip_file) + + def __open_wb__(self, buffering=-1): + return self.zip_file.open(str(self), 'w') + + def mkdir(self, mode=0o777): + self.zip_file.mkdir(str(self), mode) + + def symlink_to(self, target, target_is_directory=False): + zinfo = zipfile.ZipInfo(str(self))._for_archive(self.zip_file) + zinfo.external_attr = stat.S_IFLNK << 16 + if target_is_directory: + zinfo.external_attr |= 0x10 + self.zip_file.writestr(zinfo, str(target)) + + +class ZipPathGround: + can_symlink = True + + def __init__(self, path_cls): + self.path_cls = path_cls + + def setup(self): + return self.path_cls(zip_file=zipfile.ZipFile(io.BytesIO(), "w")) + + def teardown(self, root): + root.zip_file.close() + + def create_file(self, path, data=b''): + path.zip_file.writestr(str(path), data) + + def create_link(self, path, target): + zip_info = zipfile.ZipInfo(str(path)) + zip_info.external_attr = stat.S_IFLNK << 16 + path.zip_file.writestr(zip_info, target.encode()) + + def create_hierarchy(self, p): + self.create_file(p.joinpath('fileA'), b'this is file A\n') + self.create_link(p.joinpath('linkA'), 'fileA') + self.create_link(p.joinpath('linkB'), 'dirB') + self.create_link(p.joinpath('dirA/linkC'), '../dirB') + self.create_file(p.joinpath('dirB/fileB'), b'this is file B\n') + self.create_link(p.joinpath('dirB/linkD'), '../dirB') + self.create_file(p.joinpath('dirC/fileC'), b'this is file C\n') + self.create_file(p.joinpath('dirC/dirD/fileD'), b'this is file D\n') + self.create_file(p.joinpath('dirC/novel.txt'), b'this is a novel\n') + self.create_link(p.joinpath('brokenLink'), 'non-existing') + self.create_link(p.joinpath('brokenLinkLoop'), 'brokenLinkLoop') + + def readtext(self, p): + return p.zip_file.read(str(p)).decode() + + def readbytes(self, p): + return p.zip_file.read(str(p)) + + readlink = readtext + + def isdir(self, p): + path_str = str(p) + "/" + return path_str in p.zip_file.NameToInfo + + def isfile(self, p): + info = p.zip_file.NameToInfo.get(str(p)) + if info is None: + return False + return not stat.S_ISLNK(info.external_attr >> 16) + + def islink(self, p): + info = p.zip_file.NameToInfo.get(str(p)) + if info is None: + return False + return stat.S_ISLNK(info.external_attr >> 16) diff --git a/Lib/test/test_pathlib/test_pathlib_abc.py b/Lib/test/test_pathlib/test_pathlib_abc.py index a6e3e0709833a3..fdfa394c9a0774 100644 --- a/Lib/test/test_pathlib/test_pathlib_abc.py +++ b/Lib/test/test_pathlib/test_pathlib_abc.py @@ -1333,58 +1333,6 @@ class RWPathTest(WritablePathTest, ReadablePathTest): cls = DummyRWPath can_symlink = False - def test_read_write_bytes(self): - p = self.cls(self.base) - (p / 'fileA').write_bytes(b'abcdefg') - self.assertEqual((p / 'fileA').read_bytes(), b'abcdefg') - # Check that trying to write str does not truncate the file. - self.assertRaises(TypeError, (p / 'fileA').write_bytes, 'somestr') - self.assertEqual((p / 'fileA').read_bytes(), b'abcdefg') - - def test_read_write_text(self): - p = self.cls(self.base) - (p / 'fileA').write_text('äbcdefg', encoding='latin-1') - self.assertEqual((p / 'fileA').read_text( - encoding='utf-8', errors='ignore'), 'bcdefg') - # Check that trying to write bytes does not truncate the file. - self.assertRaises(TypeError, (p / 'fileA').write_text, b'somebytes') - self.assertEqual((p / 'fileA').read_text(encoding='latin-1'), 'äbcdefg') - - def test_read_text_with_newlines(self): - p = self.cls(self.base) - # Check that `\n` character change nothing - (p / 'fileA').write_bytes(b'abcde\r\nfghlk\n\rmnopq') - self.assertEqual((p / 'fileA').read_text(newline='\n'), - 'abcde\r\nfghlk\n\rmnopq') - # Check that `\r` character replaces `\n` - (p / 'fileA').write_bytes(b'abcde\r\nfghlk\n\rmnopq') - self.assertEqual((p / 'fileA').read_text(newline='\r'), - 'abcde\r\nfghlk\n\rmnopq') - # Check that `\r\n` character replaces `\n` - (p / 'fileA').write_bytes(b'abcde\r\nfghlk\n\rmnopq') - self.assertEqual((p / 'fileA').read_text(newline='\r\n'), - 'abcde\r\nfghlk\n\rmnopq') - - def test_write_text_with_newlines(self): - p = self.cls(self.base) - # Check that `\n` character change nothing - (p / 'fileA').write_text('abcde\r\nfghlk\n\rmnopq', newline='\n') - self.assertEqual((p / 'fileA').read_bytes(), - b'abcde\r\nfghlk\n\rmnopq') - # Check that `\r` character replaces `\n` - (p / 'fileA').write_text('abcde\r\nfghlk\n\rmnopq', newline='\r') - self.assertEqual((p / 'fileA').read_bytes(), - b'abcde\r\rfghlk\r\rmnopq') - # Check that `\r\n` character replaces `\n` - (p / 'fileA').write_text('abcde\r\nfghlk\n\rmnopq', newline='\r\n') - self.assertEqual((p / 'fileA').read_bytes(), - b'abcde\r\r\nfghlk\r\n\rmnopq') - # Check that no argument passed will change `\n` to `os.linesep` - os_linesep_byte = bytes(os.linesep, encoding='ascii') - (p / 'fileA').write_text('abcde\nfghlk\n\rmnopq') - self.assertEqual((p / 'fileA').read_bytes(), - b'abcde' + os_linesep_byte + b'fghlk' + os_linesep_byte + b'\rmnopq') - def test_copy_file(self): base = self.cls(self.base) source = base / 'fileA' diff --git a/Lib/test/test_pathlib/test_read.py b/Lib/test/test_pathlib/test_read.py new file mode 100644 index 00000000000000..0b4beb891c0f94 --- /dev/null +++ b/Lib/test/test_pathlib/test_read.py @@ -0,0 +1,70 @@ +import io +import unittest + +from pathlib import Path +from pathlib._abc import ReadablePath +from pathlib._os import magic_open + +from test.test_pathlib.support.local_path import ReadableLocalPath, LocalPathGround +from test.test_pathlib.support.zip_path import ReadableZipPath, ZipPathGround + + +class ReadablePathTest: + def setUp(self): + self.root = self.ground.setup() + self.ground.create_hierarchy(self.root) + + def tearDown(self): + self.ground.teardown(self.root) + + def test_is_readable(self): + self.assertIsInstance(self.root, ReadablePath) + + def test_open_w(self): + p = self.root / 'fileA' + with magic_open(p, 'r') as f: + self.assertIsInstance(f, io.TextIOBase) + self.assertEqual(f.read(), 'this is file A\n') + + def test_open_wb(self): + p = self.root / 'fileA' + with magic_open(p, 'rb') as f: + self.assertEqual(f.read(), b'this is file A\n') + + def test_read_bytes(self): + p = self.root / 'fileA' + self.assertEqual(p.read_bytes(), b'this is file A\n') + + def test_read_text(self): + p = self.root / 'fileA' + self.assertEqual(p.read_text(), 'this is file A\n') + q = self.root / 'abc' + self.ground.create_file(q, b'\xe4bcdefg') + self.assertEqual(q.read_text(encoding='latin-1'), 'äbcdefg') + self.assertEqual(q.read_text(encoding='utf-8', errors='ignore'), 'bcdefg') + + def test_read_text_with_newlines(self): + p = self.root / 'abc' + self.ground.create_file(p, b'abcde\r\nfghlk\n\rmnopq') + # Check that `\n` character change nothing + self.assertEqual(p.read_text(newline='\n'), 'abcde\r\nfghlk\n\rmnopq') + # Check that `\r` character replaces `\n` + self.assertEqual(p.read_text(newline='\r'), 'abcde\r\nfghlk\n\rmnopq') + # Check that `\r\n` character replaces `\n` + self.assertEqual(p.read_text(newline='\r\n'), 'abcde\r\nfghlk\n\rmnopq') + + +class ZipPathTest(ReadablePathTest, unittest.TestCase): + ground = ZipPathGround(ReadableZipPath) + + +class LocalPathTest(ReadablePathTest, unittest.TestCase): + ground = LocalPathGround(ReadableLocalPath) + + +class PathTest(ReadablePathTest, unittest.TestCase): + ground = LocalPathGround(Path) + + +if __name__ == "__main__": + unittest.main() diff --git a/Lib/test/test_pathlib/test_write.py b/Lib/test/test_pathlib/test_write.py new file mode 100644 index 00000000000000..532b9c5b6dc8f1 --- /dev/null +++ b/Lib/test/test_pathlib/test_write.py @@ -0,0 +1,105 @@ +import io +import os +import unittest + +from pathlib import Path +from pathlib._abc import WritablePath +from pathlib._os import magic_open + +from test.test_pathlib.support.local_path import WritableLocalPath, LocalPathGround +from test.test_pathlib.support.zip_path import WritableZipPath, ZipPathGround + + +class WritablePathTest: + def setUp(self): + self.root = self.ground.setup() + + def tearDown(self): + self.ground.teardown(self.root) + + def test_is_writable(self): + self.assertIsInstance(self.root, WritablePath) + + def test_open_w(self): + p = self.root / 'fileA' + with magic_open(p, 'w') as f: + self.assertIsInstance(f, io.TextIOBase) + f.write('this is file A\n') + self.assertEqual(self.ground.readtext(p), 'this is file A\n') + + def test_open_wb(self): + p = self.root / 'fileA' + with magic_open(p, 'wb') as f: + #self.assertIsInstance(f, io.BufferedWriter) + f.write(b'this is file A\n') + self.assertEqual(self.ground.readbytes(p), b'this is file A\n') + + def test_write_bytes(self): + p = self.root / 'fileA' + p.write_bytes(b'abcdefg') + self.assertEqual(self.ground.readbytes(p), b'abcdefg') + # Check that trying to write str does not truncate the file. + self.assertRaises(TypeError, p.write_bytes, 'somestr') + self.assertEqual(self.ground.readbytes(p), b'abcdefg') + + def test_write_text(self): + p = self.root / 'fileA' + p.write_text('äbcdefg', encoding='latin-1') + self.assertEqual(self.ground.readbytes(p), b'\xe4bcdefg') + # Check that trying to write bytes does not truncate the file. + self.assertRaises(TypeError, p.write_text, b'somebytes') + self.assertEqual(self.ground.readbytes(p), b'\xe4bcdefg') + + def test_write_text_with_newlines(self): + # Check that `\n` character change nothing + p = self.root / 'fileA' + p.write_text('abcde\r\nfghlk\n\rmnopq', newline='\n') + self.assertEqual(self.ground.readbytes(p), b'abcde\r\nfghlk\n\rmnopq') + + # Check that `\r` character replaces `\n` + p = self.root / 'fileB' + p.write_text('abcde\r\nfghlk\n\rmnopq', newline='\r') + self.assertEqual(self.ground.readbytes(p), b'abcde\r\rfghlk\r\rmnopq') + + # Check that `\r\n` character replaces `\n` + p = self.root / 'fileC' + p.write_text('abcde\r\nfghlk\n\rmnopq', newline='\r\n') + self.assertEqual(self.ground.readbytes(p), b'abcde\r\r\nfghlk\r\n\rmnopq') + + # Check that no argument passed will change `\n` to `os.linesep` + os_linesep_byte = bytes(os.linesep, encoding='ascii') + p = self.root / 'fileD' + p.write_text('abcde\nfghlk\n\rmnopq') + self.assertEqual(self.ground.readbytes(p), + b'abcde' + os_linesep_byte + + b'fghlk' + os_linesep_byte + b'\rmnopq') + + def test_mkdir(self): + p = self.root / 'newdirA' + self.assertFalse(self.ground.isdir(p)) + p.mkdir() + self.assertTrue(self.ground.isdir(p)) + + def test_symlink_to(self): + if not self.ground.can_symlink: + self.skipTest('needs symlinks') + link = self.root.joinpath('linkA') + link.symlink_to('fileA') + self.assertTrue(self.ground.islink(link)) + self.assertEqual(self.ground.readlink(link), 'fileA') + + +class ZipPathTest(WritablePathTest, unittest.TestCase): + ground = ZipPathGround(WritableZipPath) + + +class LocalPathTest(WritablePathTest, unittest.TestCase): + ground = LocalPathGround(WritableLocalPath) + + +class PathTest(WritablePathTest, unittest.TestCase): + ground = LocalPathGround(Path) + + +if __name__ == "__main__": + unittest.main() From f7afbec8388b3393ecb0110d12c2a52c6b9e996f Mon Sep 17 00:00:00 2001 From: barneygale Date: Thu, 27 Feb 2025 19:09:32 +0000 Subject: [PATCH 2/7] Update Makefile.pre.in --- Makefile.pre.in | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile.pre.in b/Makefile.pre.in index aafd13091e3555..e05a2ac336798a 100644 --- a/Makefile.pre.in +++ b/Makefile.pre.in @@ -2524,6 +2524,7 @@ TESTSUBDIRS= idlelib/idle_test \ test/test_multiprocessing_forkserver \ test/test_multiprocessing_spawn \ test/test_pathlib \ + test/test_pathlib/support \ test/test_peg_generator \ test/test_pydoc \ test/test_pyrepl \ From 0601addd366e09c67c1ce3105dfb3ed47b00bb9c Mon Sep 17 00:00:00 2001 From: barneygale Date: Thu, 27 Feb 2025 20:24:16 +0000 Subject: [PATCH 3/7] ReadableZipPath: handle leading slashes slightly more robustly --- Lib/test/test_pathlib/support/zip_path.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/Lib/test/test_pathlib/support/zip_path.py b/Lib/test/test_pathlib/support/zip_path.py index 59bc4cb27661f9..80606257ecf7f7 100644 --- a/Lib/test/test_pathlib/support/zip_path.py +++ b/Lib/test/test_pathlib/support/zip_path.py @@ -58,10 +58,14 @@ class ZipFileList: `resolve()` method fetches an entry from the tree. """ - __slots__ = ('_root_info', '_items') + __slots__ = ('_roots', '_items') def __init__(self, items): - self._root_info = ZipPathInfo() + self._roots = { + '': ZipPathInfo(), + '/': ZipPathInfo(), + '//': ZipPathInfo(), + } self._items = [] for item in items: self.append(item) @@ -80,7 +84,8 @@ def resolve(self, path, create=False): """ Returns a PathInfo object for the given path by walking the tree. """ - path_info = self._root_info + _drive, root, path = posixpath.splitroot(path) + path_info = self._roots[root] for name in path.split('/'): if not name or name == '.': pass @@ -127,7 +132,12 @@ def with_segments(self, *pathsegments): return type(self)(*pathsegments, zip_file=self.zip_file) def __open_rb__(self, buffering=-1): - return self.zip_file.open(str(self), 'r') + info = self.info + if not info.exists(): + raise FileNotFoundError(errno.ENOENT, "File not found", self) + elif info.is_dir(): + raise IsADirectoryError(errno.EISDIR, "Is a directory", self) + return self.zip_file.open(info.zip_info, 'r') def iterdir(self): info = self.info From 7024189c3cb331579cd72f025c491e1d65c4ff62 Mon Sep 17 00:00:00 2001 From: barneygale Date: Thu, 27 Feb 2025 20:31:40 +0000 Subject: [PATCH 4/7] Fix ZipPathGround.readtext --- Lib/test/test_pathlib/support/zip_path.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_pathlib/support/zip_path.py b/Lib/test/test_pathlib/support/zip_path.py index 80606257ecf7f7..02e0f73dfb62ea 100644 --- a/Lib/test/test_pathlib/support/zip_path.py +++ b/Lib/test/test_pathlib/support/zip_path.py @@ -231,10 +231,13 @@ def create_hierarchy(self, p): self.create_link(p.joinpath('brokenLinkLoop'), 'brokenLinkLoop') def readtext(self, p): - return p.zip_file.read(str(p)).decode() + with p.zip_file.open(str(p), 'r') as f: + f = io.TextIOWrapper(f) + return f.read() def readbytes(self, p): - return p.zip_file.read(str(p)) + with p.zip_file.open(str(p), 'r') as f: + return f.read() readlink = readtext From 97abda71654cc1a48f9bab95f76c16f662e7a3f9 Mon Sep 17 00:00:00 2001 From: barneygale Date: Thu, 27 Feb 2025 20:32:51 +0000 Subject: [PATCH 5/7] Fix test names --- Lib/test/test_pathlib/test_read.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_pathlib/test_read.py b/Lib/test/test_pathlib/test_read.py index 0b4beb891c0f94..191431a430d0f0 100644 --- a/Lib/test/test_pathlib/test_read.py +++ b/Lib/test/test_pathlib/test_read.py @@ -20,13 +20,13 @@ def tearDown(self): def test_is_readable(self): self.assertIsInstance(self.root, ReadablePath) - def test_open_w(self): + def test_open_r(self): p = self.root / 'fileA' with magic_open(p, 'r') as f: self.assertIsInstance(f, io.TextIOBase) self.assertEqual(f.read(), 'this is file A\n') - def test_open_wb(self): + def test_open_rb(self): p = self.root / 'fileA' with magic_open(p, 'rb') as f: self.assertEqual(f.read(), b'this is file A\n') From e08f358707e81aea520beb7343edf976683480a5 Mon Sep 17 00:00:00 2001 From: barneygale Date: Fri, 28 Feb 2025 16:28:31 +0000 Subject: [PATCH 6/7] Naming --- Lib/test/test_pathlib/test_read.py | 8 ++++---- Lib/test/test_pathlib/test_write.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Lib/test/test_pathlib/test_read.py b/Lib/test/test_pathlib/test_read.py index 191431a430d0f0..c7f7391cacb68e 100644 --- a/Lib/test/test_pathlib/test_read.py +++ b/Lib/test/test_pathlib/test_read.py @@ -9,7 +9,7 @@ from test.test_pathlib.support.zip_path import ReadableZipPath, ZipPathGround -class ReadablePathTest: +class ReadablePathTestBase: def setUp(self): self.root = self.ground.setup() self.ground.create_hierarchy(self.root) @@ -54,15 +54,15 @@ def test_read_text_with_newlines(self): self.assertEqual(p.read_text(newline='\r\n'), 'abcde\r\nfghlk\n\rmnopq') -class ZipPathTest(ReadablePathTest, unittest.TestCase): +class ZipPathTest(ReadablePathTestBase, unittest.TestCase): ground = ZipPathGround(ReadableZipPath) -class LocalPathTest(ReadablePathTest, unittest.TestCase): +class LocalPathTest(ReadablePathTestBase, unittest.TestCase): ground = LocalPathGround(ReadableLocalPath) -class PathTest(ReadablePathTest, unittest.TestCase): +class PathTest(ReadablePathTestBase, unittest.TestCase): ground = LocalPathGround(Path) diff --git a/Lib/test/test_pathlib/test_write.py b/Lib/test/test_pathlib/test_write.py index 532b9c5b6dc8f1..d702aeb7f95d15 100644 --- a/Lib/test/test_pathlib/test_write.py +++ b/Lib/test/test_pathlib/test_write.py @@ -10,7 +10,7 @@ from test.test_pathlib.support.zip_path import WritableZipPath, ZipPathGround -class WritablePathTest: +class WritablePathTestBase: def setUp(self): self.root = self.ground.setup() @@ -89,15 +89,15 @@ def test_symlink_to(self): self.assertEqual(self.ground.readlink(link), 'fileA') -class ZipPathTest(WritablePathTest, unittest.TestCase): +class ZipPathTest(WritablePathTestBase, unittest.TestCase): ground = ZipPathGround(WritableZipPath) -class LocalPathTest(WritablePathTest, unittest.TestCase): +class LocalPathTest(WritablePathTestBase, unittest.TestCase): ground = LocalPathGround(WritableLocalPath) -class PathTest(WritablePathTest, unittest.TestCase): +class PathTest(WritablePathTestBase, unittest.TestCase): ground = LocalPathGround(Path) From f0afaab059b82f289dfbca75aeee7526af7058a5 Mon Sep 17 00:00:00 2001 From: barneygale Date: Fri, 28 Feb 2025 16:29:10 +0000 Subject: [PATCH 7/7] Add `test.test_pathlib.test_copy` with a single test function. --- Lib/test/test_pathlib/support/local_path.py | 4 +- Lib/test/test_pathlib/support/zip_path.py | 2 +- Lib/test/test_pathlib/test_copy.py | 50 +++++++++++++++++++++ Lib/test/test_pathlib/test_pathlib_abc.py | 9 ---- 4 files changed, 53 insertions(+), 12 deletions(-) create mode 100644 Lib/test/test_pathlib/test_copy.py diff --git a/Lib/test/test_pathlib/support/local_path.py b/Lib/test/test_pathlib/support/local_path.py index 2104d4e37abad6..b7d2aefab17052 100644 --- a/Lib/test/test_pathlib/support/local_path.py +++ b/Lib/test/test_pathlib/support/local_path.py @@ -123,8 +123,8 @@ class LocalPathGround: def __init__(self, path_cls): self.path_cls = path_cls - def setup(self): - root = self.path_cls(os_helper.TESTFN) + def setup(self, local_suffix=""): + root = self.path_cls(os_helper.TESTFN + local_suffix) os.mkdir(root) return root diff --git a/Lib/test/test_pathlib/support/zip_path.py b/Lib/test/test_pathlib/support/zip_path.py index 02e0f73dfb62ea..765dfb2bb620b9 100644 --- a/Lib/test/test_pathlib/support/zip_path.py +++ b/Lib/test/test_pathlib/support/zip_path.py @@ -203,7 +203,7 @@ class ZipPathGround: def __init__(self, path_cls): self.path_cls = path_cls - def setup(self): + def setup(self, local_suffix=""): return self.path_cls(zip_file=zipfile.ZipFile(io.BytesIO(), "w")) def teardown(self, root): diff --git a/Lib/test/test_pathlib/test_copy.py b/Lib/test/test_pathlib/test_copy.py new file mode 100644 index 00000000000000..46217824a4c98b --- /dev/null +++ b/Lib/test/test_pathlib/test_copy.py @@ -0,0 +1,50 @@ +import unittest + +from pathlib import Path + +from test.test_pathlib.support.local_path import LocalPathGround +from test.test_pathlib.support.zip_path import ZipPathGround, ReadableZipPath, WritableZipPath + + +class CopyPathTestBase: + def setUp(self): + self.source_root = self.source_ground.setup() + self.source_ground.create_hierarchy(self.source_root) + self.target_root = self.target_ground.setup(local_suffix="_target") + + def tearDown(self): + self.source_ground.teardown(self.source_root) + self.target_ground.teardown(self.target_root) + + def test_copy_file(self): + source = self.source_root / 'fileA' + target = self.target_root / 'copyA' + result = source.copy(target) + self.assertEqual(result, target) + self.assertTrue(self.target_ground.isfile(target)) + self.assertEqual(self.source_ground.readbytes(source), + self.target_ground.readbytes(result)) + + +class CopyZipPathToZipPathTest(CopyPathTestBase, unittest.TestCase): + source_ground = ZipPathGround(ReadableZipPath) + target_ground = ZipPathGround(WritableZipPath) + + +class CopyZipPathToPathTest(CopyPathTestBase, unittest.TestCase): + source_ground = ZipPathGround(ReadableZipPath) + target_ground = LocalPathGround(Path) + + +class CopyPathToZipPathTest(CopyPathTestBase, unittest.TestCase): + source_ground = LocalPathGround(Path) + target_ground = ZipPathGround(WritableZipPath) + + +class CopyPathToPathTest(CopyPathTestBase, unittest.TestCase): + source_ground = LocalPathGround(Path) + target_ground = LocalPathGround(Path) + + +if __name__ == "__main__": + unittest.main() diff --git a/Lib/test/test_pathlib/test_pathlib_abc.py b/Lib/test/test_pathlib/test_pathlib_abc.py index fdfa394c9a0774..b15825b36e9685 100644 --- a/Lib/test/test_pathlib/test_pathlib_abc.py +++ b/Lib/test/test_pathlib/test_pathlib_abc.py @@ -1333,15 +1333,6 @@ class RWPathTest(WritablePathTest, ReadablePathTest): cls = DummyRWPath can_symlink = False - def test_copy_file(self): - base = self.cls(self.base) - source = base / 'fileA' - target = base / 'copyA' - result = source.copy(target) - self.assertEqual(result, target) - self.assertTrue(result.info.exists()) - self.assertEqual(source.read_text(), result.read_text()) - def test_copy_file_to_existing_file(self): base = self.cls(self.base) source = base / 'fileA'