diff --git a/Lib/pathlib/_abc.py b/Lib/pathlib/_abc.py index 800d1b4503d78d..a9de8d6eb3c4c7 100644 --- a/Lib/pathlib/_abc.py +++ b/Lib/pathlib/_abc.py @@ -12,8 +12,9 @@ """ import functools -import posixpath +from abc import ABC, abstractmethod from glob import _PathGlobber, _no_recurse_symlinks +from pathlib import PurePath, Path from pathlib._os import magic_open, CopyReader, CopyWriter @@ -39,17 +40,24 @@ def _explode_path(path): return path, names -class JoinablePath: - """Base class for pure path objects. +class JoinablePath(ABC): + """Abstract base class for pure path objects. This class *does not* provide several magic methods that are defined in - its subclass PurePath. They are: __init__, __fspath__, __bytes__, + its implementation PurePath. They are: __init__, __fspath__, __bytes__, __reduce__, __hash__, __eq__, __lt__, __le__, __gt__, __ge__. """ - __slots__ = () - parser = posixpath + @property + @abstractmethod + def parser(self): + """Implementation of pathlib._types.Parser used for low-level path + parsing and manipulation. + """ + raise NotImplementedError + + @abstractmethod def with_segments(self, *pathsegments): """Construct a new path object from any number of path-like objects. Subclasses may override this method to customize how new path objects @@ -57,6 +65,7 @@ def with_segments(self, *pathsegments): """ raise NotImplementedError + @abstractmethod def __str__(self): """Return the string representation of the path, suitable for passing to system calls.""" @@ -198,23 +207,17 @@ def full_match(self, pattern, *, case_sensitive=None): return match(str(self)) is not None - class ReadablePath(JoinablePath): - """Base class for concrete path objects. + """Abstract base class for readable path objects. - This class provides dummy implementations for many methods that derived - classes can override selectively; the default implementations raise - NotImplementedError. The most basic methods, such as stat() and open(), - directly raise NotImplementedError; these basic methods are called by - other methods such as is_dir() and read_text(). - - The Path class derives this class to implement local filesystem paths. - Users may derive their own classes to implement virtual filesystem paths, - such as paths in archive files or on remote storage systems. + The Path class implements this ABC for local filesystem paths. Users may + create subclasses to implement readable virtual filesystem paths, such as + paths in archive files or on remote storage systems. """ __slots__ = () @property + @abstractmethod def info(self): """ A PathInfo object that exposes the file type and other file attributes @@ -254,6 +257,7 @@ def is_symlink(self): info = self.joinpath().info return info.is_symlink() + @abstractmethod def __open_rb__(self, buffering=-1): """ Open the file pointed to by this path for reading in binary mode and @@ -275,6 +279,7 @@ def read_text(self, encoding=None, errors=None, newline=None): with magic_open(self, mode='r', encoding=encoding, errors=errors, newline=newline) as f: return f.read() + @abstractmethod def iterdir(self): """Yield path objects of the directory contents. @@ -348,6 +353,7 @@ def walk(self, top_down=True, on_error=None, follow_symlinks=False): yield path, dirnames, filenames paths += [path.joinpath(d) for d in reversed(dirnames)] + @abstractmethod def readlink(self): """ Return the path to which the symbolic link points. @@ -389,8 +395,15 @@ def copy_into(self, target_dir, *, follow_symlinks=True, class WritablePath(JoinablePath): + """Abstract base class for writable path objects. + + The Path class implements this ABC for local filesystem paths. Users may + create subclasses to implement writable virtual filesystem paths, such as + paths in archive files or on remote storage systems. + """ __slots__ = () + @abstractmethod def symlink_to(self, target, target_is_directory=False): """ Make this path a symlink pointing to the target path. @@ -398,12 +411,14 @@ def symlink_to(self, target, target_is_directory=False): """ raise NotImplementedError + @abstractmethod def mkdir(self, mode=0o777, parents=False, exist_ok=False): """ Create a new directory at this given path. """ raise NotImplementedError + @abstractmethod def __open_wb__(self, buffering=-1): """ Open the file pointed to by this path for writing in binary mode and @@ -431,3 +446,8 @@ def write_text(self, data, encoding=None, errors=None, newline=None): return f.write(data) _copy_writer = property(CopyWriter) + + +JoinablePath.register(PurePath) +ReadablePath.register(Path) +WritablePath.register(Path) diff --git a/Lib/pathlib/_local.py b/Lib/pathlib/_local.py index 956c1920bf6d78..2f0c87fd27b624 100644 --- a/Lib/pathlib/_local.py +++ b/Lib/pathlib/_local.py @@ -20,7 +20,6 @@ grp = None from pathlib._os import LocalCopyReader, LocalCopyWriter, PathInfo, DirEntryInfo -from pathlib._abc import JoinablePath, ReadablePath, WritablePath __all__ = [ @@ -65,7 +64,7 @@ def __repr__(self): return "<{}.parents>".format(type(self._path).__name__) -class PurePath(JoinablePath): +class PurePath: """Base class for manipulating paths without I/O. PurePath represents a filesystem path and offers operations which @@ -409,6 +408,31 @@ def with_name(self, name): tail[-1] = name return self._from_parsed_parts(self.drive, self.root, tail) + def with_stem(self, stem): + """Return a new path with the stem changed.""" + suffix = self.suffix + if not suffix: + return self.with_name(stem) + elif not stem: + # If the suffix is non-empty, we can't make the stem empty. + raise ValueError(f"{self!r} has a non-empty suffix") + else: + return self.with_name(stem + suffix) + + def with_suffix(self, suffix): + """Return a new path with the file suffix changed. If the path + has no suffix, add given suffix. If the given suffix is an empty + string, remove the suffix from the path. + """ + stem = self.stem + if not stem: + # If the stem is empty, we can't make the suffix non-empty. + raise ValueError(f"{self!r} has an empty name") + elif suffix and not suffix.startswith('.'): + raise ValueError(f"Invalid suffix {suffix!r}") + else: + return self.with_name(stem + suffix) + @property def stem(self): """The final path component, minus its last suffix.""" @@ -584,7 +608,7 @@ class PureWindowsPath(PurePath): __slots__ = () -class Path(WritablePath, ReadablePath, PurePath): +class Path(PurePath): """PurePath subclass that can make system calls. Path represents a filesystem path but unlike PurePath, also offers @@ -1058,6 +1082,37 @@ def replace(self, target): _copy_reader = property(LocalCopyReader) _copy_writer = property(LocalCopyWriter) + def copy(self, target, follow_symlinks=True, dirs_exist_ok=False, + preserve_metadata=False): + """ + Recursively copy this file or directory tree to the given destination. + """ + if not hasattr(target, '_copy_writer'): + target = self.with_segments(target) + + # Delegate to the target path's CopyWriter object. + try: + create = target._copy_writer._create + except AttributeError: + raise TypeError(f"Target is not writable: {target}") from None + return create(self, follow_symlinks, dirs_exist_ok, preserve_metadata) + + def copy_into(self, target_dir, *, follow_symlinks=True, + dirs_exist_ok=False, preserve_metadata=False): + """ + Copy this file or directory tree into the given existing directory. + """ + name = self.name + if not name: + raise ValueError(f"{self!r} has an empty name") + elif hasattr(target_dir, '_copy_writer'): + target = target_dir / name + else: + target = self.with_segments(target_dir, name) + return self.copy(target, follow_symlinks=follow_symlinks, + dirs_exist_ok=dirs_exist_ok, + preserve_metadata=preserve_metadata) + def move(self, target): """ Recursively move this file or directory tree to the given destination. diff --git a/Lib/test/test_pathlib/test_pathlib.py b/Lib/test/test_pathlib/test_pathlib.py index 31e5306ae60538..7f61f3d6223198 100644 --- a/Lib/test/test_pathlib/test_pathlib.py +++ b/Lib/test/test_pathlib/test_pathlib.py @@ -75,7 +75,7 @@ def test_is_notimplemented(self): # Tests for the pure classes. # -class PurePathTest(test_pathlib_abc.DummyJoinablePathTest): +class PurePathTest(test_pathlib_abc.JoinablePathTest): cls = pathlib.PurePath # Make sure any symbolic links in the base test path are resolved. @@ -1002,7 +1002,7 @@ class cls(pathlib.PurePath): # Tests for the concrete classes. # -class PathTest(test_pathlib_abc.DummyRWPathTest, PurePathTest): +class PathTest(test_pathlib_abc.RWPathTest, PurePathTest): """Tests for the FS-accessing functionalities of the Path classes.""" cls = pathlib.Path can_symlink = os_helper.can_symlink() @@ -3119,7 +3119,7 @@ def test_group_windows(self): P('c:/').group() -class PathWalkTest(test_pathlib_abc.DummyReadablePathWalkTest): +class PathWalkTest(test_pathlib_abc.ReadablePathWalkTest): cls = pathlib.Path base = PathTest.base can_symlink = PathTest.can_symlink diff --git a/Lib/test/test_pathlib/test_pathlib_abc.py b/Lib/test/test_pathlib/test_pathlib_abc.py index 836d8387bdc433..c1bdcd03ca88d0 100644 --- a/Lib/test/test_pathlib/test_pathlib_abc.py +++ b/Lib/test/test_pathlib/test_pathlib_abc.py @@ -31,29 +31,11 @@ def needs_windows(fn): # -class JoinablePathTest(unittest.TestCase): - cls = JoinablePath - - def test_magic_methods(self): - P = self.cls - self.assertFalse(hasattr(P, '__fspath__')) - self.assertFalse(hasattr(P, '__bytes__')) - self.assertIs(P.__reduce__, object.__reduce__) - self.assertIs(P.__repr__, object.__repr__) - self.assertIs(P.__hash__, object.__hash__) - self.assertIs(P.__eq__, object.__eq__) - self.assertIs(P.__lt__, object.__lt__) - self.assertIs(P.__le__, object.__le__) - self.assertIs(P.__gt__, object.__gt__) - self.assertIs(P.__ge__, object.__ge__) - - def test_parser(self): - self.assertIs(self.cls.parser, posixpath) - - class DummyJoinablePath(JoinablePath): __slots__ = ('_segments',) + parser = posixpath + def __init__(self, *segments): self._segments = segments @@ -77,7 +59,7 @@ def with_segments(self, *pathsegments): return type(self)(*pathsegments) -class DummyJoinablePathTest(unittest.TestCase): +class JoinablePathTest(unittest.TestCase): cls = DummyJoinablePath # Use a base path that's unrelated to any real filesystem path. @@ -94,6 +76,10 @@ def setUp(self): self.sep = self.parser.sep self.altsep = self.parser.altsep + def test_is_joinable(self): + p = self.cls(self.base) + self.assertIsInstance(p, JoinablePath) + def test_parser(self): self.assertIsInstance(self.cls.parser, _PathParser) @@ -878,6 +864,7 @@ class DummyReadablePath(ReadablePath, DummyJoinablePath): _files = {} _directories = {} + parser = posixpath def __init__(self, *segments): super().__init__(*segments) @@ -909,6 +896,9 @@ def iterdir(self): else: raise FileNotFoundError(errno.ENOENT, "File not found", path) + def readlink(self): + raise NotImplementedError + class DummyWritablePath(WritablePath, DummyJoinablePath): __slots__ = () @@ -942,8 +932,11 @@ def mkdir(self, mode=0o777, parents=False, exist_ok=False): self.parent.mkdir(parents=True, exist_ok=True) self.mkdir(mode, parents=False, exist_ok=exist_ok) + def symlink_to(self, target, target_is_directory=False): + raise NotImplementedError + -class DummyReadablePathTest(DummyJoinablePathTest): +class ReadablePathTest(JoinablePathTest): """Tests for ReadablePathTest methods that use stat(), open() and iterdir().""" cls = DummyReadablePath @@ -1010,6 +1003,10 @@ def assertEqualNormCase(self, path_a, path_b): normcase = self.parser.normcase self.assertEqual(normcase(path_a), normcase(path_b)) + def test_is_readable(self): + p = self.cls(self.base) + self.assertIsInstance(p, ReadablePath) + def test_exists(self): P = self.cls p = P(self.base) @@ -1378,15 +1375,19 @@ def test_is_symlink(self): self.assertIs((P / 'linkA\x00').is_file(), False) -class DummyWritablePathTest(DummyJoinablePathTest): +class WritablePathTest(JoinablePathTest): cls = DummyWritablePath + def test_is_writable(self): + p = self.cls(self.base) + self.assertIsInstance(p, WritablePath) + class DummyRWPath(DummyWritablePath, DummyReadablePath): __slots__ = () -class DummyRWPathTest(DummyWritablePathTest, DummyReadablePathTest): +class RWPathTest(WritablePathTest, ReadablePathTest): cls = DummyRWPath can_symlink = False @@ -1598,9 +1599,9 @@ def test_copy_into_empty_name(self): self.assertRaises(ValueError, source.copy_into, target_dir) -class DummyReadablePathWalkTest(unittest.TestCase): +class ReadablePathWalkTest(unittest.TestCase): cls = DummyReadablePath - base = DummyReadablePathTest.base + base = ReadablePathTest.base can_symlink = False def setUp(self):