Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
May 12, 2025
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
30 changes: 12 additions & 18 deletions Lib/glob.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand All @@ -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):
Expand Down Expand Up @@ -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.
7 changes: 4 additions & 3 deletions Lib/pathlib/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


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

Expand Down
28 changes: 24 additions & 4 deletions Lib/pathlib/_os.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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


Expand All @@ -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


Expand Down
51 changes: 35 additions & 16 deletions Lib/pathlib/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

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

Expand Down Expand Up @@ -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))
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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):
Expand Down Expand Up @@ -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()
Expand Down
11 changes: 6 additions & 5 deletions Lib/test/test_pathlib/support/lexical_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
Expand Down
10 changes: 3 additions & 7 deletions Lib/test/test_pathlib/support/local_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')

Expand All @@ -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')
Expand Down
Loading
Loading