Skip to content

Commit 5dbd27d

Browse files
authored
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__()`.
1 parent 9f69a58 commit 5dbd27d

File tree

8 files changed

+118
-81
lines changed

8 files changed

+118
-81
lines changed

Lib/glob.py

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,12 @@ def concat_path(path, text):
358358
"""
359359
raise NotImplementedError
360360

361+
@staticmethod
362+
def stringify_path(path):
363+
"""Converts the path to a string object
364+
"""
365+
raise NotImplementedError
366+
361367
# High-level methods
362368

363369
def compile(self, pat, altsep=None):
@@ -466,8 +472,9 @@ def recursive_selector(self, part, parts):
466472
select_next = self.selector(parts)
467473

468474
def select_recursive(path, exists=False):
469-
match_pos = len(str(path))
470-
if match is None or match(str(path), match_pos):
475+
path_str = self.stringify_path(path)
476+
match_pos = len(path_str)
477+
if match is None or match(path_str, match_pos):
471478
yield from select_next(path, exists)
472479
stack = [path]
473480
while stack:
@@ -489,7 +496,7 @@ def select_recursive_step(stack, match_pos):
489496
pass
490497

491498
if is_dir or not dir_only:
492-
entry_path_str = str(entry_path)
499+
entry_path_str = self.stringify_path(entry_path)
493500
if dir_only:
494501
entry_path = self.concat_path(entry_path, self.sep)
495502
if match is None or match(entry_path_str, match_pos):
@@ -529,19 +536,6 @@ def scandir(path):
529536
entries = list(scandir_it)
530537
return ((entry, entry.name, entry.path) for entry in entries)
531538

532-
533-
class _PathGlobber(_GlobberBase):
534-
"""Provides shell-style pattern matching and globbing for pathlib paths.
535-
"""
536-
537539
@staticmethod
538-
def lexists(path):
539-
return path.info.exists(follow_symlinks=False)
540-
541-
@staticmethod
542-
def scandir(path):
543-
return ((child.info, child.name, child) for child in path.iterdir())
544-
545-
@staticmethod
546-
def concat_path(path, text):
547-
return path.with_segments(str(path) + text)
540+
def stringify_path(path):
541+
return path # Already a string.

Lib/pathlib/__init__.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@
2828

2929
from pathlib._os import (
3030
PathInfo, DirEntryInfo,
31+
magic_open, vfspath,
3132
ensure_different_files, ensure_distinct_paths,
32-
copyfile2, copyfileobj, magic_open, copy_info,
33+
copyfile2, copyfileobj, copy_info,
3334
)
3435

3536

@@ -1164,12 +1165,12 @@ def _copy_from_file(self, source, preserve_metadata=False):
11641165
# os.symlink() incorrectly creates a file-symlink on Windows. Avoid
11651166
# this by passing *target_is_dir* to os.symlink() on Windows.
11661167
def _copy_from_symlink(self, source, preserve_metadata=False):
1167-
os.symlink(str(source.readlink()), self, source.info.is_dir())
1168+
os.symlink(vfspath(source.readlink()), self, source.info.is_dir())
11681169
if preserve_metadata:
11691170
copy_info(source.info, self, follow_symlinks=False)
11701171
else:
11711172
def _copy_from_symlink(self, source, preserve_metadata=False):
1172-
os.symlink(str(source.readlink()), self)
1173+
os.symlink(vfspath(source.readlink()), self)
11731174
if preserve_metadata:
11741175
copy_info(source.info, self, follow_symlinks=False)
11751176

Lib/pathlib/_os.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,26 @@ def magic_open(path, mode='r', buffering=-1, encoding=None, errors=None,
210210
raise TypeError(f"{cls.__name__} can't be opened with mode {mode!r}")
211211

212212

213+
def vfspath(path):
214+
"""
215+
Return the string representation of a virtual path object.
216+
"""
217+
try:
218+
return os.fsdecode(path)
219+
except TypeError:
220+
pass
221+
222+
path_type = type(path)
223+
try:
224+
return path_type.__vfspath__(path)
225+
except AttributeError:
226+
if hasattr(path_type, '__vfspath__'):
227+
raise
228+
229+
raise TypeError("expected str, bytes, os.PathLike or JoinablePath "
230+
"object, not " + path_type.__name__)
231+
232+
213233
def ensure_distinct_paths(source, target):
214234
"""
215235
Raise OSError(EINVAL) if the other path is within this path.
@@ -225,8 +245,8 @@ def ensure_distinct_paths(source, target):
225245
err = OSError(EINVAL, "Source path is a parent of target path")
226246
else:
227247
return
228-
err.filename = str(source)
229-
err.filename2 = str(target)
248+
err.filename = vfspath(source)
249+
err.filename2 = vfspath(target)
230250
raise err
231251

232252

@@ -247,8 +267,8 @@ def ensure_different_files(source, target):
247267
except (OSError, ValueError):
248268
return
249269
err = OSError(EINVAL, "Source and target are the same file")
250-
err.filename = str(source)
251-
err.filename2 = str(target)
270+
err.filename = vfspath(source)
271+
err.filename2 = vfspath(target)
252272
raise err
253273

254274

Lib/pathlib/types.py

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@
1111

1212

1313
from abc import ABC, abstractmethod
14-
from glob import _PathGlobber
14+
from glob import _GlobberBase
1515
from io import text_encoding
16-
from pathlib._os import magic_open, ensure_distinct_paths, ensure_different_files, copyfileobj
16+
from pathlib._os import (magic_open, vfspath, ensure_distinct_paths,
17+
ensure_different_files, copyfileobj)
1718
from pathlib import PurePath, Path
1819
from typing import Optional, Protocol, runtime_checkable
1920

@@ -60,6 +61,25 @@ def is_file(self, *, follow_symlinks: bool = True) -> bool: ...
6061
def is_symlink(self) -> bool: ...
6162

6263

64+
class _PathGlobber(_GlobberBase):
65+
"""Provides shell-style pattern matching and globbing for ReadablePath.
66+
"""
67+
68+
@staticmethod
69+
def lexists(path):
70+
return path.info.exists(follow_symlinks=False)
71+
72+
@staticmethod
73+
def scandir(path):
74+
return ((child.info, child.name, child) for child in path.iterdir())
75+
76+
@staticmethod
77+
def concat_path(path, text):
78+
return path.with_segments(vfspath(path) + text)
79+
80+
stringify_path = staticmethod(vfspath)
81+
82+
6383
class _JoinablePath(ABC):
6484
"""Abstract base class for pure path objects.
6585
@@ -86,20 +106,19 @@ def with_segments(self, *pathsegments):
86106
raise NotImplementedError
87107

88108
@abstractmethod
89-
def __str__(self):
90-
"""Return the string representation of the path, suitable for
91-
passing to system calls."""
109+
def __vfspath__(self):
110+
"""Return the string representation of the path."""
92111
raise NotImplementedError
93112

94113
@property
95114
def anchor(self):
96115
"""The concatenation of the drive and root, or ''."""
97-
return _explode_path(str(self), self.parser.split)[0]
116+
return _explode_path(vfspath(self), self.parser.split)[0]
98117

99118
@property
100119
def name(self):
101120
"""The final path component, if any."""
102-
return self.parser.split(str(self))[1]
121+
return self.parser.split(vfspath(self))[1]
103122

104123
@property
105124
def suffix(self):
@@ -135,7 +154,7 @@ def with_name(self, name):
135154
split = self.parser.split
136155
if split(name)[0]:
137156
raise ValueError(f"Invalid name {name!r}")
138-
path = str(self)
157+
path = vfspath(self)
139158
path = path.removesuffix(split(path)[1]) + name
140159
return self.with_segments(path)
141160

@@ -168,7 +187,7 @@ def with_suffix(self, suffix):
168187
def parts(self):
169188
"""An object providing sequence-like access to the
170189
components in the filesystem path."""
171-
anchor, parts = _explode_path(str(self), self.parser.split)
190+
anchor, parts = _explode_path(vfspath(self), self.parser.split)
172191
if anchor:
173192
parts.append(anchor)
174193
return tuple(reversed(parts))
@@ -179,24 +198,24 @@ def joinpath(self, *pathsegments):
179198
paths) or a totally different path (if one of the arguments is
180199
anchored).
181200
"""
182-
return self.with_segments(str(self), *pathsegments)
201+
return self.with_segments(vfspath(self), *pathsegments)
183202

184203
def __truediv__(self, key):
185204
try:
186-
return self.with_segments(str(self), key)
205+
return self.with_segments(vfspath(self), key)
187206
except TypeError:
188207
return NotImplemented
189208

190209
def __rtruediv__(self, key):
191210
try:
192-
return self.with_segments(key, str(self))
211+
return self.with_segments(key, vfspath(self))
193212
except TypeError:
194213
return NotImplemented
195214

196215
@property
197216
def parent(self):
198217
"""The logical parent of the path."""
199-
path = str(self)
218+
path = vfspath(self)
200219
parent = self.parser.split(path)[0]
201220
if path != parent:
202221
return self.with_segments(parent)
@@ -206,7 +225,7 @@ def parent(self):
206225
def parents(self):
207226
"""A sequence of this path's logical parents."""
208227
split = self.parser.split
209-
path = str(self)
228+
path = vfspath(self)
210229
parent = split(path)[0]
211230
parents = []
212231
while path != parent:
@@ -223,7 +242,7 @@ def full_match(self, pattern):
223242
case_sensitive = self.parser.normcase('Aa') == 'Aa'
224243
globber = _PathGlobber(self.parser.sep, case_sensitive, recursive=True)
225244
match = globber.compile(pattern, altsep=self.parser.altsep)
226-
return match(str(self)) is not None
245+
return match(vfspath(self)) is not None
227246

228247

229248
class _ReadablePath(_JoinablePath):
@@ -412,7 +431,7 @@ def _copy_from(self, source, follow_symlinks=True):
412431
while stack:
413432
src, dst = stack.pop()
414433
if not follow_symlinks and src.info.is_symlink():
415-
dst.symlink_to(str(src.readlink()), src.info.is_dir())
434+
dst.symlink_to(vfspath(src.readlink()), src.info.is_dir())
416435
elif src.info.is_dir():
417436
children = src.iterdir()
418437
dst.mkdir()

Lib/test/test_pathlib/support/lexical_path.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@
99
from . import is_pypi
1010

1111
if is_pypi:
12-
from pathlib_abc import _JoinablePath
12+
from pathlib_abc import vfspath, _JoinablePath
1313
else:
1414
from pathlib.types import _JoinablePath
15+
from pathlib._os import vfspath
1516

1617

1718
class LexicalPath(_JoinablePath):
@@ -22,20 +23,20 @@ def __init__(self, *pathsegments):
2223
self._segments = pathsegments
2324

2425
def __hash__(self):
25-
return hash(str(self))
26+
return hash(vfspath(self))
2627

2728
def __eq__(self, other):
2829
if not isinstance(other, LexicalPath):
2930
return NotImplemented
30-
return str(self) == str(other)
31+
return vfspath(self) == vfspath(other)
3132

32-
def __str__(self):
33+
def __vfspath__(self):
3334
if not self._segments:
3435
return ''
3536
return self.parser.join(*self._segments)
3637

3738
def __repr__(self):
38-
return f'{type(self).__name__}({str(self)!r})'
39+
return f'{type(self).__name__}({vfspath(self)!r})'
3940

4041
def with_segments(self, *pathsegments):
4142
return type(self)(*pathsegments)

Lib/test/test_pathlib/support/local_path.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ class LocalPathInfo(PathInfo):
9797
__slots__ = ('_path', '_exists', '_is_dir', '_is_file', '_is_symlink')
9898

9999
def __init__(self, path):
100-
self._path = str(path)
100+
self._path = os.fspath(path)
101101
self._exists = None
102102
self._is_dir = None
103103
self._is_file = None
@@ -139,14 +139,12 @@ class ReadableLocalPath(_ReadablePath, LexicalPath):
139139
Simple implementation of a ReadablePath class for local filesystem paths.
140140
"""
141141
__slots__ = ('info',)
142+
__fspath__ = LexicalPath.__vfspath__
142143

143144
def __init__(self, *pathsegments):
144145
super().__init__(*pathsegments)
145146
self.info = LocalPathInfo(self)
146147

147-
def __fspath__(self):
148-
return str(self)
149-
150148
def __open_rb__(self, buffering=-1):
151149
return open(self, 'rb')
152150

@@ -163,9 +161,7 @@ class WritableLocalPath(_WritablePath, LexicalPath):
163161
"""
164162

165163
__slots__ = ()
166-
167-
def __fspath__(self):
168-
return str(self)
164+
__fspath__ = LexicalPath.__vfspath__
169165

170166
def __open_wb__(self, buffering=-1):
171167
return open(self, 'wb')

0 commit comments

Comments
 (0)