]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
GH-128520: Subclass `abc.ABC` in `pathlib._abc` (#128745)
authorBarney Gale <barney.gale@gmail.com>
Sun, 16 Feb 2025 00:37:26 +0000 (00:37 +0000)
committerGitHub <noreply@github.com>
Sun, 16 Feb 2025 00:37:26 +0000 (00:37 +0000)
Convert `JoinablePath`, `ReadablePath` and `WritablePath` to real ABCs
derived from `abc.ABC`.

Make `JoinablePath.parser` abstract, rather than defaulting to `posixpath`.

Register `PurePath` and `Path` as virtual subclasses of the ABCs rather
than deriving. This avoids a hit to path object instantiation performance.

No change of behaviour in the public (non-abstract) classes.

Lib/pathlib/_abc.py
Lib/pathlib/_local.py
Lib/test/test_pathlib/test_pathlib.py
Lib/test/test_pathlib/test_pathlib_abc.py

index 800d1b4503d78d7bc1383ad4e7dece7ea09b0bff..a9de8d6eb3c4c71806b9f644575c0fbe5fec61f3 100644 (file)
@@ -12,8 +12,9 @@ WritablePath.
 """
 
 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 @@ class JoinablePath:
         """
         raise NotImplementedError
 
+    @abstractmethod
     def __str__(self):
         """Return the string representation of the path, suitable for
         passing to system calls."""
@@ -198,23 +207,17 @@ class JoinablePath:
         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 @@ class ReadablePath(JoinablePath):
         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 @@ class ReadablePath(JoinablePath):
         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 @@ class ReadablePath(JoinablePath):
                 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 @@ class ReadablePath(JoinablePath):
 
 
 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 @@ class WritablePath(JoinablePath):
         """
         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 @@ class WritablePath(JoinablePath):
             return f.write(data)
 
     _copy_writer = property(CopyWriter)
+
+
+JoinablePath.register(PurePath)
+ReadablePath.register(Path)
+WritablePath.register(Path)
index 956c1920bf6d78a804aaeed2a106241206e22450..2f0c87fd27b62418115b83ad80774e7462771597 100644 (file)
@@ -20,7 +20,6 @@ except ImportError:
     grp = None
 
 from pathlib._os import LocalCopyReader, LocalCopyWriter, PathInfo, DirEntryInfo
-from pathlib._abc import JoinablePath, ReadablePath, WritablePath
 
 
 __all__ = [
@@ -65,7 +64,7 @@ class _PathParents(Sequence):
         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 @@ class PurePath(JoinablePath):
         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 @@ class Path(WritablePath, ReadablePath, PurePath):
     _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.
index 31e5306ae60538c546ee93a7680d46994fc22b03..7f61f3d62231980396f8b83240250f5c1cedbb98 100644 (file)
@@ -75,7 +75,7 @@ class UnsupportedOperationTest(unittest.TestCase):
 # 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 PurePathSubclassTest(PurePathTest):
 # 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 @@ class PathTest(test_pathlib_abc.DummyRWPathTest, PurePathTest):
             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
index 836d8387bdc43374497f68bba9aa411c4d4f9476..c1bdcd03ca88d0cd10facfa9bb58f3d4b587564d 100644 (file)
@@ -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 @@ class DummyJoinablePath(JoinablePath):
         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 @@ class DummyJoinablePathTest(unittest.TestCase):
         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 @@ class DummyReadablePath(ReadablePath, DummyJoinablePath):
         else:
             raise FileNotFoundError(errno.ENOENT, "File not found", path)
 
+    def readlink(self):
+        raise NotImplementedError
+
 
 class DummyWritablePath(WritablePath, DummyJoinablePath):
     __slots__ = ()
@@ -942,8 +932,11 @@ class DummyWritablePath(WritablePath, DummyJoinablePath):
             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 @@ class DummyReadablePathTest(DummyJoinablePathTest):
         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 @@ class DummyReadablePathTest(DummyJoinablePathTest):
             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 @@ class DummyRWPathTest(DummyWritablePathTest, DummyReadablePathTest):
         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):