https://github.com/python/cpython/commit/5dbd27db7d79af88fb3f9b47f8b80438a147d349
commit: 5dbd27db7d79af88fb3f9b47f8b80438a147d349
branch: main
author: Barney Gale <barney.g...@gmail.com>
committer: barneygale <barney.g...@gmail.com>
date: 2025-05-12T19:00:36+01:00
summary:

GH-128520: pathlib ABCs: add `JoinablePath.__vfspath__()` (#133437)

In the abstract interface of `JoinablePath`, replace `__str__()` with
`__vfspath__()`. This frees user implementations of `JoinablePath` to
implement `__str__()` however they like (or not at all.)

Also add `pathlib._os.vfspath()`, which calls `__fspath__()` or
`__vfspath__()`.

files:
M Lib/glob.py
M Lib/pathlib/__init__.py
M Lib/pathlib/_os.py
M Lib/pathlib/types.py
M Lib/test/test_pathlib/support/lexical_path.py
M Lib/test/test_pathlib/support/local_path.py
M Lib/test/test_pathlib/support/zip_path.py
M Lib/test/test_pathlib/test_join_windows.py

diff --git a/Lib/glob.py b/Lib/glob.py
index 341524282ba675..1e48fe43167200 100644
--- a/Lib/glob.py
+++ b/Lib/glob.py
@@ -358,6 +358,12 @@ def concat_path(path, text):
         """
         raise NotImplementedError
 
+    @staticmethod
+    def stringify_path(path):
+        """Converts the path to a string object
+        """
+        raise NotImplementedError
+
     # High-level methods
 
     def compile(self, pat, altsep=None):
@@ -466,8 +472,9 @@ def recursive_selector(self, part, parts):
         select_next = self.selector(parts)
 
         def select_recursive(path, exists=False):
-            match_pos = len(str(path))
-            if match is None or match(str(path), match_pos):
+            path_str = self.stringify_path(path)
+            match_pos = len(path_str)
+            if match is None or match(path_str, match_pos):
                 yield from select_next(path, exists)
             stack = [path]
             while stack:
@@ -489,7 +496,7 @@ def select_recursive_step(stack, match_pos):
                         pass
 
                     if is_dir or not dir_only:
-                        entry_path_str = str(entry_path)
+                        entry_path_str = self.stringify_path(entry_path)
                         if dir_only:
                             entry_path = self.concat_path(entry_path, self.sep)
                         if match is None or match(entry_path_str, match_pos):
@@ -529,19 +536,6 @@ def scandir(path):
             entries = list(scandir_it)
         return ((entry, entry.name, entry.path) for entry in entries)
 
-
-class _PathGlobber(_GlobberBase):
-    """Provides shell-style pattern matching and globbing for pathlib paths.
-    """
-
     @staticmethod
-    def lexists(path):
-        return path.info.exists(follow_symlinks=False)
-
-    @staticmethod
-    def scandir(path):
-        return ((child.info, child.name, child) for child in path.iterdir())
-
-    @staticmethod
-    def concat_path(path, text):
-        return path.with_segments(str(path) + text)
+    def stringify_path(path):
+        return path  # Already a string.
diff --git a/Lib/pathlib/__init__.py b/Lib/pathlib/__init__.py
index 12cf9f579cb32d..2dc1f7f7126063 100644
--- a/Lib/pathlib/__init__.py
+++ b/Lib/pathlib/__init__.py
@@ -28,8 +28,9 @@
 
 from pathlib._os import (
     PathInfo, DirEntryInfo,
+    magic_open, vfspath,
     ensure_different_files, ensure_distinct_paths,
-    copyfile2, copyfileobj, magic_open, copy_info,
+    copyfile2, copyfileobj, copy_info,
 )
 
 
@@ -1164,12 +1165,12 @@ def _copy_from_file(self, source, 
preserve_metadata=False):
         # os.symlink() incorrectly creates a file-symlink on Windows. Avoid
         # this by passing *target_is_dir* to os.symlink() on Windows.
         def _copy_from_symlink(self, source, preserve_metadata=False):
-            os.symlink(str(source.readlink()), self, source.info.is_dir())
+            os.symlink(vfspath(source.readlink()), self, source.info.is_dir())
             if preserve_metadata:
                 copy_info(source.info, self, follow_symlinks=False)
     else:
         def _copy_from_symlink(self, source, preserve_metadata=False):
-            os.symlink(str(source.readlink()), self)
+            os.symlink(vfspath(source.readlink()), self)
             if preserve_metadata:
                 copy_info(source.info, self, follow_symlinks=False)
 
diff --git a/Lib/pathlib/_os.py b/Lib/pathlib/_os.py
index 039836941dd456..62a4adb555ea89 100644
--- a/Lib/pathlib/_os.py
+++ b/Lib/pathlib/_os.py
@@ -210,6 +210,26 @@ def magic_open(path, mode='r', buffering=-1, 
encoding=None, errors=None,
     raise TypeError(f"{cls.__name__} can't be opened with mode {mode!r}")
 
 
+def vfspath(path):
+    """
+    Return the string representation of a virtual path object.
+    """
+    try:
+        return os.fsdecode(path)
+    except TypeError:
+        pass
+
+    path_type = type(path)
+    try:
+        return path_type.__vfspath__(path)
+    except AttributeError:
+        if hasattr(path_type, '__vfspath__'):
+            raise
+
+    raise TypeError("expected str, bytes, os.PathLike or JoinablePath "
+                    "object, not " + path_type.__name__)
+
+
 def ensure_distinct_paths(source, target):
     """
     Raise OSError(EINVAL) if the other path is within this path.
@@ -225,8 +245,8 @@ def ensure_distinct_paths(source, target):
         err = OSError(EINVAL, "Source path is a parent of target path")
     else:
         return
-    err.filename = str(source)
-    err.filename2 = str(target)
+    err.filename = vfspath(source)
+    err.filename2 = vfspath(target)
     raise err
 
 
@@ -247,8 +267,8 @@ def ensure_different_files(source, target):
         except (OSError, ValueError):
             return
     err = OSError(EINVAL, "Source and target are the same file")
-    err.filename = str(source)
-    err.filename2 = str(target)
+    err.filename = vfspath(source)
+    err.filename2 = vfspath(target)
     raise err
 
 
diff --git a/Lib/pathlib/types.py b/Lib/pathlib/types.py
index d8f5c34a1a7513..42b80221608bcc 100644
--- a/Lib/pathlib/types.py
+++ b/Lib/pathlib/types.py
@@ -11,9 +11,10 @@
 
 
 from abc import ABC, abstractmethod
-from glob import _PathGlobber
+from glob import _GlobberBase
 from io import text_encoding
-from pathlib._os import magic_open, ensure_distinct_paths, 
ensure_different_files, copyfileobj
+from pathlib._os import (magic_open, vfspath, ensure_distinct_paths,
+                         ensure_different_files, copyfileobj)
 from pathlib import PurePath, Path
 from typing import Optional, Protocol, runtime_checkable
 
@@ -60,6 +61,25 @@ def is_file(self, *, follow_symlinks: bool = True) -> bool: 
...
     def is_symlink(self) -> bool: ...
 
 
+class _PathGlobber(_GlobberBase):
+    """Provides shell-style pattern matching and globbing for ReadablePath.
+    """
+
+    @staticmethod
+    def lexists(path):
+        return path.info.exists(follow_symlinks=False)
+
+    @staticmethod
+    def scandir(path):
+        return ((child.info, child.name, child) for child in path.iterdir())
+
+    @staticmethod
+    def concat_path(path, text):
+        return path.with_segments(vfspath(path) + text)
+
+    stringify_path = staticmethod(vfspath)
+
+
 class _JoinablePath(ABC):
     """Abstract base class for pure path objects.
 
@@ -86,20 +106,19 @@ def with_segments(self, *pathsegments):
         raise NotImplementedError
 
     @abstractmethod
-    def __str__(self):
-        """Return the string representation of the path, suitable for
-        passing to system calls."""
+    def __vfspath__(self):
+        """Return the string representation of the path."""
         raise NotImplementedError
 
     @property
     def anchor(self):
         """The concatenation of the drive and root, or ''."""
-        return _explode_path(str(self), self.parser.split)[0]
+        return _explode_path(vfspath(self), self.parser.split)[0]
 
     @property
     def name(self):
         """The final path component, if any."""
-        return self.parser.split(str(self))[1]
+        return self.parser.split(vfspath(self))[1]
 
     @property
     def suffix(self):
@@ -135,7 +154,7 @@ def with_name(self, name):
         split = self.parser.split
         if split(name)[0]:
             raise ValueError(f"Invalid name {name!r}")
-        path = str(self)
+        path = vfspath(self)
         path = path.removesuffix(split(path)[1]) + name
         return self.with_segments(path)
 
@@ -168,7 +187,7 @@ def with_suffix(self, suffix):
     def parts(self):
         """An object providing sequence-like access to the
         components in the filesystem path."""
-        anchor, parts = _explode_path(str(self), self.parser.split)
+        anchor, parts = _explode_path(vfspath(self), self.parser.split)
         if anchor:
             parts.append(anchor)
         return tuple(reversed(parts))
@@ -179,24 +198,24 @@ def joinpath(self, *pathsegments):
         paths) or a totally different path (if one of the arguments is
         anchored).
         """
-        return self.with_segments(str(self), *pathsegments)
+        return self.with_segments(vfspath(self), *pathsegments)
 
     def __truediv__(self, key):
         try:
-            return self.with_segments(str(self), key)
+            return self.with_segments(vfspath(self), key)
         except TypeError:
             return NotImplemented
 
     def __rtruediv__(self, key):
         try:
-            return self.with_segments(key, str(self))
+            return self.with_segments(key, vfspath(self))
         except TypeError:
             return NotImplemented
 
     @property
     def parent(self):
         """The logical parent of the path."""
-        path = str(self)
+        path = vfspath(self)
         parent = self.parser.split(path)[0]
         if path != parent:
             return self.with_segments(parent)
@@ -206,7 +225,7 @@ def parent(self):
     def parents(self):
         """A sequence of this path's logical parents."""
         split = self.parser.split
-        path = str(self)
+        path = vfspath(self)
         parent = split(path)[0]
         parents = []
         while path != parent:
@@ -223,7 +242,7 @@ def full_match(self, pattern):
         case_sensitive = self.parser.normcase('Aa') == 'Aa'
         globber = _PathGlobber(self.parser.sep, case_sensitive, recursive=True)
         match = globber.compile(pattern, altsep=self.parser.altsep)
-        return match(str(self)) is not None
+        return match(vfspath(self)) is not None
 
 
 class _ReadablePath(_JoinablePath):
@@ -412,7 +431,7 @@ def _copy_from(self, source, follow_symlinks=True):
         while stack:
             src, dst = stack.pop()
             if not follow_symlinks and src.info.is_symlink():
-                dst.symlink_to(str(src.readlink()), src.info.is_dir())
+                dst.symlink_to(vfspath(src.readlink()), src.info.is_dir())
             elif src.info.is_dir():
                 children = src.iterdir()
                 dst.mkdir()
diff --git a/Lib/test/test_pathlib/support/lexical_path.py 
b/Lib/test/test_pathlib/support/lexical_path.py
index f29a521af9b013..fd7fbf283a6651 100644
--- a/Lib/test/test_pathlib/support/lexical_path.py
+++ b/Lib/test/test_pathlib/support/lexical_path.py
@@ -9,9 +9,10 @@
 from . import is_pypi
 
 if is_pypi:
-    from pathlib_abc import _JoinablePath
+    from pathlib_abc import vfspath, _JoinablePath
 else:
     from pathlib.types import _JoinablePath
+    from pathlib._os import vfspath
 
 
 class LexicalPath(_JoinablePath):
@@ -22,20 +23,20 @@ def __init__(self, *pathsegments):
         self._segments = pathsegments
 
     def __hash__(self):
-        return hash(str(self))
+        return hash(vfspath(self))
 
     def __eq__(self, other):
         if not isinstance(other, LexicalPath):
             return NotImplemented
-        return str(self) == str(other)
+        return vfspath(self) == vfspath(other)
 
-    def __str__(self):
+    def __vfspath__(self):
         if not self._segments:
             return ''
         return self.parser.join(*self._segments)
 
     def __repr__(self):
-        return f'{type(self).__name__}({str(self)!r})'
+        return f'{type(self).__name__}({vfspath(self)!r})'
 
     def with_segments(self, *pathsegments):
         return type(self)(*pathsegments)
diff --git a/Lib/test/test_pathlib/support/local_path.py 
b/Lib/test/test_pathlib/support/local_path.py
index d481fd45ead49f..c1423c545bfd00 100644
--- a/Lib/test/test_pathlib/support/local_path.py
+++ b/Lib/test/test_pathlib/support/local_path.py
@@ -97,7 +97,7 @@ class LocalPathInfo(PathInfo):
     __slots__ = ('_path', '_exists', '_is_dir', '_is_file', '_is_symlink')
 
     def __init__(self, path):
-        self._path = str(path)
+        self._path = os.fspath(path)
         self._exists = None
         self._is_dir = None
         self._is_file = None
@@ -139,14 +139,12 @@ class ReadableLocalPath(_ReadablePath, LexicalPath):
     Simple implementation of a ReadablePath class for local filesystem paths.
     """
     __slots__ = ('info',)
+    __fspath__ = LexicalPath.__vfspath__
 
     def __init__(self, *pathsegments):
         super().__init__(*pathsegments)
         self.info = LocalPathInfo(self)
 
-    def __fspath__(self):
-        return str(self)
-
     def __open_rb__(self, buffering=-1):
         return open(self, 'rb')
 
@@ -163,9 +161,7 @@ class WritableLocalPath(_WritablePath, LexicalPath):
     """
 
     __slots__ = ()
-
-    def __fspath__(self):
-        return str(self)
+    __fspath__ = LexicalPath.__vfspath__
 
     def __open_wb__(self, buffering=-1):
         return open(self, 'wb')
diff --git a/Lib/test/test_pathlib/support/zip_path.py 
b/Lib/test/test_pathlib/support/zip_path.py
index 2905260c9dfc95..21e1d07423aff5 100644
--- a/Lib/test/test_pathlib/support/zip_path.py
+++ b/Lib/test/test_pathlib/support/zip_path.py
@@ -16,9 +16,10 @@
 from . import is_pypi
 
 if is_pypi:
-    from pathlib_abc import PathInfo, _ReadablePath, _WritablePath
+    from pathlib_abc import vfspath, PathInfo, _ReadablePath, _WritablePath
 else:
     from pathlib.types import PathInfo, _ReadablePath, _WritablePath
+    from pathlib._os import vfspath
 
 
 class ZipPathGround:
@@ -34,16 +35,16 @@ def teardown(self, root):
         root.zip_file.close()
 
     def create_file(self, path, data=b''):
-        path.zip_file.writestr(str(path), data)
+        path.zip_file.writestr(vfspath(path), data)
 
     def create_dir(self, path):
-        zip_info = zipfile.ZipInfo(str(path) + '/')
+        zip_info = zipfile.ZipInfo(vfspath(path) + '/')
         zip_info.external_attr |= stat.S_IFDIR << 16
         zip_info.external_attr |= stat.FILE_ATTRIBUTE_DIRECTORY
         path.zip_file.writestr(zip_info, '')
 
     def create_symlink(self, path, target):
-        zip_info = zipfile.ZipInfo(str(path))
+        zip_info = zipfile.ZipInfo(vfspath(path))
         zip_info.external_attr = stat.S_IFLNK << 16
         path.zip_file.writestr(zip_info, target.encode())
 
@@ -62,28 +63,28 @@ def create_hierarchy(self, p):
         self.create_symlink(p.joinpath('brokenLinkLoop'), 'brokenLinkLoop')
 
     def readtext(self, p):
-        with p.zip_file.open(str(p), 'r') as f:
+        with p.zip_file.open(vfspath(p), 'r') as f:
             f = io.TextIOWrapper(f, encoding='utf-8')
             return f.read()
 
     def readbytes(self, p):
-        with p.zip_file.open(str(p), 'r') as f:
+        with p.zip_file.open(vfspath(p), 'r') as f:
             return f.read()
 
     readlink = readtext
 
     def isdir(self, p):
-        path_str = str(p) + "/"
+        path_str = vfspath(p) + "/"
         return path_str in p.zip_file.NameToInfo
 
     def isfile(self, p):
-        info = p.zip_file.NameToInfo.get(str(p))
+        info = p.zip_file.NameToInfo.get(vfspath(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))
+        info = p.zip_file.NameToInfo.get(vfspath(p))
         if info is None:
             return False
         return stat.S_ISLNK(info.external_attr >> 16)
@@ -240,20 +241,20 @@ def __init__(self, *pathsegments, zip_file):
             zip_file.filelist = ZipFileList(zip_file)
 
     def __hash__(self):
-        return hash((str(self), self.zip_file))
+        return hash((vfspath(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
+        return vfspath(self) == vfspath(other) and self.zip_file is 
other.zip_file
 
-    def __str__(self):
+    def __vfspath__(self):
         if not self._segments:
             return ''
         return self.parser.join(*self._segments)
 
     def __repr__(self):
-        return f'{type(self).__name__}({str(self)!r}, 
zip_file={self.zip_file!r})'
+        return f'{type(self).__name__}({vfspath(self)!r}, 
zip_file={self.zip_file!r})'
 
     def with_segments(self, *pathsegments):
         return type(self)(*pathsegments, zip_file=self.zip_file)
@@ -261,7 +262,7 @@ def with_segments(self, *pathsegments):
     @property
     def info(self):
         tree = self.zip_file.filelist.tree
-        return tree.resolve(str(self), follow_symlinks=False)
+        return tree.resolve(vfspath(self), follow_symlinks=False)
 
     def __open_rb__(self, buffering=-1):
         info = self.info.resolve()
@@ -301,36 +302,36 @@ def __init__(self, *pathsegments, zip_file):
         self.zip_file = zip_file
 
     def __hash__(self):
-        return hash((str(self), self.zip_file))
+        return hash((vfspath(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
+        return vfspath(self) == vfspath(other) and self.zip_file is 
other.zip_file
 
-    def __str__(self):
+    def __vfspath__(self):
         if not self._segments:
             return ''
         return self.parser.join(*self._segments)
 
     def __repr__(self):
-        return f'{type(self).__name__}({str(self)!r}, 
zip_file={self.zip_file!r})'
+        return f'{type(self).__name__}({vfspath(self)!r}, 
zip_file={self.zip_file!r})'
 
     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')
+        return self.zip_file.open(vfspath(self), 'w')
 
     def mkdir(self, mode=0o777):
-        zinfo = zipfile.ZipInfo(str(self) + '/')
+        zinfo = zipfile.ZipInfo(vfspath(self) + '/')
         zinfo.external_attr |= stat.S_IFDIR << 16
         zinfo.external_attr |= stat.FILE_ATTRIBUTE_DIRECTORY
         self.zip_file.writestr(zinfo, '')
 
     def symlink_to(self, target, target_is_directory=False):
-        zinfo = zipfile.ZipInfo(str(self))
+        zinfo = zipfile.ZipInfo(vfspath(self))
         zinfo.external_attr = stat.S_IFLNK << 16
         if target_is_directory:
             zinfo.external_attr |= 0x10
-        self.zip_file.writestr(zinfo, str(target))
+        self.zip_file.writestr(zinfo, vfspath(target))
diff --git a/Lib/test/test_pathlib/test_join_windows.py 
b/Lib/test/test_pathlib/test_join_windows.py
index 2cc634f25efc68..f30c80605f7f91 100644
--- a/Lib/test/test_pathlib/test_join_windows.py
+++ b/Lib/test/test_pathlib/test_join_windows.py
@@ -8,6 +8,11 @@
 from .support import is_pypi
 from .support.lexical_path import LexicalWindowsPath
 
+if is_pypi:
+    from pathlib_abc import vfspath
+else:
+    from pathlib._os import vfspath
+
 
 class JoinTestBase:
     def test_join(self):
@@ -70,17 +75,17 @@ def test_div(self):
         self.assertEqual(p / './dd:s', P(r'C:/a/b\./dd:s'))
         self.assertEqual(p / 'E:d:s', P('E:d:s'))
 
-    def test_str(self):
+    def test_vfspath(self):
         p = self.cls(r'a\b\c')
-        self.assertEqual(str(p), 'a\\b\\c')
+        self.assertEqual(vfspath(p), 'a\\b\\c')
         p = self.cls(r'c:\a\b\c')
-        self.assertEqual(str(p), 'c:\\a\\b\\c')
+        self.assertEqual(vfspath(p), 'c:\\a\\b\\c')
         p = self.cls('\\\\a\\b\\')
-        self.assertEqual(str(p), '\\\\a\\b\\')
+        self.assertEqual(vfspath(p), '\\\\a\\b\\')
         p = self.cls(r'\\a\b\c')
-        self.assertEqual(str(p), '\\\\a\\b\\c')
+        self.assertEqual(vfspath(p), '\\\\a\\b\\c')
         p = self.cls(r'\\a\b\c\d')
-        self.assertEqual(str(p), '\\\\a\\b\\c\\d')
+        self.assertEqual(vfspath(p), '\\\\a\\b\\c\\d')
 
     def test_parts(self):
         P = self.cls

_______________________________________________
Python-checkins mailing list -- python-checkins@python.org
To unsubscribe send an email to python-checkins-le...@python.org
https://mail.python.org/mailman3/lists/python-checkins.python.org/
Member address: arch...@mail-archive.com

Reply via email to