diff --git a/Doc/library/pathlib.rst b/Doc/library/pathlib.rst index 338575404ff0ad5..0142649ae3e5bef 100644 --- a/Doc/library/pathlib.rst +++ b/Doc/library/pathlib.rst @@ -108,7 +108,7 @@ Pure path objects provide path-handling operations which don't actually access a filesystem. There are three ways to access these classes, which we also call *flavours*: -.. class:: PurePath(*pathsegments) +.. class:: PurePath(*pathsegments, flavour=os.path) A generic class that represents the system's path flavour (instantiating it creates either a :class:`PurePosixPath` or a :class:`PureWindowsPath`):: @@ -162,6 +162,15 @@ we also call *flavours*: to ``PurePosixPath('bar')``, which is wrong if ``foo`` is a symbolic link to another directory) + The *flavour* keyword-only argument is available only in user subclasses of + :class:`PurePath` and :class:`Path`. It specifies the implementation of + :mod:`os.path` to use for low-level path operations. It may be left unset + or set to :mod:`os.path` to use the current system's flavour, or set to + :mod:`posixpath` or :mod:`ntpath` to use POSIX or Windows path semantics. + + .. versionchanged:: 3.13 + The *flavour* parameter was added. + Pure path objects implement the :class:`os.PathLike` interface, allowing them to be used anywhere the interface is accepted. @@ -303,6 +312,13 @@ Methods and properties Pure paths provide the following methods and properties: +.. attribute:: PurePath.flavour + + The implementation of :mod:`os.path` used for low-level path operations; + either :mod:`posixpath` or :mod:`ntpath`. + + .. versionchanged:: 3.13 + .. attribute:: PurePath.drive A string representing the drive letter or name, if any:: @@ -742,7 +758,7 @@ Concrete paths are subclasses of the pure path classes. In addition to operations provided by the latter, they also provide methods to do system calls on path objects. There are three ways to instantiate concrete paths: -.. class:: Path(*pathsegments) +.. class:: Path(*pathsegments, flavour=os.path) A subclass of :class:`PurePath`, this class represents concrete paths of the system's path flavour (instantiating it creates either a @@ -751,7 +767,10 @@ calls on path objects. There are three ways to instantiate concrete paths: >>> Path('setup.py') PosixPath('setup.py') - *pathsegments* is specified similarly to :class:`PurePath`. + *pathsegments* and *flavour* are specified similarly to :class:`PurePath`. + + .. versionchanged:: 3.13 + The *flavour* parameter was added. .. class:: PosixPath(*pathsegments) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index f3813e04109904a..893eaab73b1f5e3 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -255,6 +255,10 @@ class PurePath: """ __slots__ = ( + # The 'flavour' slot stores the implementation of `os.path` used for + # low-level path operations. It can be set to `posixpath` or `ntpath`. + 'flavour', + # The `_raw_paths` slot stores unnormalized string paths. This is set # in the `__init__()` method. '_raw_paths', @@ -293,7 +297,6 @@ class PurePath: # path. It's set when `__hash__()` is called for the first time. '_hash', ) - _flavour = os.path def __new__(cls, *args, **kwargs): """Construct a PurePath from one or several strings and or existing @@ -310,11 +313,12 @@ def __reduce__(self): # when pickling related paths. return (self.__class__, self.parts) - def __init__(self, *args): + def __init__(self, *args, flavour=os.path): + self.flavour = flavour paths = [] for arg in args: if isinstance(arg, PurePath): - if arg._flavour is ntpath and self._flavour is posixpath: + if arg.flavour is ntpath and flavour is posixpath: # GH-103631: Convert separators for backwards compatibility. paths.extend(path.replace('\\', '/') for path in arg._raw_paths) else: @@ -337,17 +341,16 @@ def with_segments(self, *pathsegments): Subclasses may override this method to customize how new path objects are created from methods like `iterdir()`. """ - return type(self)(*pathsegments) + return type(self)(*pathsegments, flavour=self.flavour) - @classmethod - def _parse_path(cls, path): + def _parse_path(self, path): if not path: return '', '', [] - sep = cls._flavour.sep - altsep = cls._flavour.altsep + sep = self.flavour.sep + altsep = self.flavour.altsep if altsep: path = path.replace(altsep, sep) - drv, root, rel = cls._flavour.splitroot(path) + drv, root, rel = self.flavour.splitroot(path) if not root and drv.startswith(sep) and not drv.endswith(sep): drv_parts = drv.split(sep) if len(drv_parts) == 4 and drv_parts[2] not in '?.': @@ -366,7 +369,7 @@ def _load_parts(self): elif len(paths) == 1: path = paths[0] else: - path = self._flavour.join(*paths) + path = self.flavour.join(*paths) drv, root, tail = self._parse_path(path) self._drv = drv self._root = root @@ -381,13 +384,12 @@ def _from_parsed_parts(self, drv, root, tail): path._tail_cached = tail return path - @classmethod - def _format_parsed_parts(cls, drv, root, tail): + def _format_parsed_parts(self, drv, root, tail): if drv or root: - return drv + root + cls._flavour.sep.join(tail) - elif tail and cls._flavour.splitdrive(tail[0])[0]: + return drv + root + self.flavour.sep.join(tail) + elif tail and self.flavour.splitdrive(tail[0])[0]: tail = ['.'] + tail - return cls._flavour.sep.join(tail) + return self.flavour.sep.join(tail) def __str__(self): """Return the string representation of the path, suitable for @@ -405,7 +407,7 @@ def __fspath__(self): def as_posix(self): """Return the string representation of the path with forward (/) slashes.""" - f = self._flavour + f = self.flavour return str(self).replace(f.sep, '/') def __bytes__(self): @@ -442,7 +444,7 @@ def _str_normcase(self): try: return self._str_normcase_cached except AttributeError: - if _is_case_sensitive(self._flavour): + if _is_case_sensitive(self.flavour): self._str_normcase_cached = str(self) else: self._str_normcase_cached = str(self).lower() @@ -454,7 +456,7 @@ def _parts_normcase(self): try: return self._parts_normcase_cached except AttributeError: - self._parts_normcase_cached = self._str_normcase.split(self._flavour.sep) + self._parts_normcase_cached = self._str_normcase.split(self.flavour.sep) return self._parts_normcase_cached @property @@ -467,14 +469,14 @@ def _lines(self): if path_str == '.': self._lines_cached = '' else: - trans = _SWAP_SEP_AND_NEWLINE[self._flavour.sep] + trans = _SWAP_SEP_AND_NEWLINE[self.flavour.sep] self._lines_cached = path_str.translate(trans) return self._lines_cached def __eq__(self, other): if not isinstance(other, PurePath): return NotImplemented - return self._str_normcase == other._str_normcase and self._flavour is other._flavour + return self._str_normcase == other._str_normcase and self.flavour is other.flavour def __hash__(self): try: @@ -484,22 +486,22 @@ def __hash__(self): return self._hash def __lt__(self, other): - if not isinstance(other, PurePath) or self._flavour is not other._flavour: + if not isinstance(other, PurePath) or self.flavour is not other.flavour: return NotImplemented return self._parts_normcase < other._parts_normcase def __le__(self, other): - if not isinstance(other, PurePath) or self._flavour is not other._flavour: + if not isinstance(other, PurePath) or self.flavour is not other.flavour: return NotImplemented return self._parts_normcase <= other._parts_normcase def __gt__(self, other): - if not isinstance(other, PurePath) or self._flavour is not other._flavour: + if not isinstance(other, PurePath) or self.flavour is not other.flavour: return NotImplemented return self._parts_normcase > other._parts_normcase def __ge__(self, other): - if not isinstance(other, PurePath) or self._flavour is not other._flavour: + if not isinstance(other, PurePath) or self.flavour is not other.flavour: return NotImplemented return self._parts_normcase >= other._parts_normcase @@ -584,7 +586,7 @@ def with_name(self, name): """Return a new path with the file name changed.""" if not self.name: raise ValueError("%r has an empty name" % (self,)) - f = self._flavour + f = self.flavour drv, root, tail = f.splitroot(name) if drv or root or not tail or f.sep in tail or (f.altsep and f.altsep in tail): raise ValueError("Invalid name %r" % (name)) @@ -600,7 +602,7 @@ def with_suffix(self, suffix): has no suffix, add given suffix. If the given suffix is an empty string, remove the suffix from the path. """ - f = self._flavour + f = self.flavour if f.sep in suffix or f.altsep and f.altsep in suffix: raise ValueError("Invalid suffix %r" % (suffix,)) if suffix and not suffix.startswith('.') or suffix == '.': @@ -702,22 +704,22 @@ def parents(self): def is_absolute(self): """True if the path is absolute (has both a root and, if applicable, a drive).""" - if self._flavour is ntpath: + if self.flavour is ntpath: # ntpath.isabs() is defective - see GH-44626. return bool(self.drive and self.root) - elif self._flavour is posixpath: + elif self.flavour is posixpath: # Optimization: work with raw paths on POSIX. for path in self._raw_paths: if path.startswith('/'): return True return False else: - return self._flavour.isabs(str(self)) + return self.flavour.isabs(str(self)) def is_reserved(self): """Return True if the path contains one of the special names reserved by the system, if any.""" - if self._flavour is posixpath or not self._tail: + if self.flavour is posixpath or not self._tail: return False # NOTE: the rules for reserved names seem somewhat complicated @@ -737,7 +739,7 @@ def match(self, path_pattern, *, case_sensitive=None): if not isinstance(path_pattern, PurePath): path_pattern = self.with_segments(path_pattern) if case_sensitive is None: - case_sensitive = _is_case_sensitive(self._flavour) + case_sensitive = _is_case_sensitive(self.flavour) pattern = _compile_pattern_lines(path_pattern._lines, case_sensitive) if path_pattern.drive or path_pattern.root: return pattern.match(self._lines) is not None @@ -758,9 +760,14 @@ class PurePosixPath(PurePath): On a POSIX system, instantiating a PurePath should return this object. However, you can also instantiate it directly on any system. """ - _flavour = posixpath __slots__ = () + def __init__(self, *pathsegments, **kwargs): + super().__init__(*pathsegments, flavour=posixpath, **kwargs) + + def with_segments(self, *pathsegments): + return type(self)(*pathsegments) + class PureWindowsPath(PurePath): """PurePath subclass for Windows systems. @@ -768,9 +775,13 @@ class PureWindowsPath(PurePath): On a Windows system, instantiating a PurePath should return this object. However, you can also instantiate it directly on any system. """ - _flavour = ntpath __slots__ = () + def __init__(self, *pathsegments, **kwargs): + super().__init__(*pathsegments, flavour=ntpath, **kwargs) + + def with_segments(self, *pathsegments): + return type(self)(*pathsegments) # Filesystem-accessing classes @@ -858,7 +869,7 @@ def is_mount(self): """ Check if this path is a mount point """ - return self._flavour.ismount(self) + return self.flavour.ismount(self) def is_symlink(self): """ @@ -879,7 +890,7 @@ def is_junction(self): """ Whether this path is a junction. """ - return self._flavour.isjunction(self) + return self.flavour.isjunction(self) def is_block_device(self): """ @@ -954,7 +965,7 @@ def samefile(self, other_path): other_st = other_path.stat() except AttributeError: other_st = self.with_segments(other_path).stat() - return self._flavour.samestat(st, other_st) + return self.flavour.samestat(st, other_st) def open(self, mode='r', buffering=-1, encoding=None, errors=None, newline=None): @@ -1017,7 +1028,7 @@ def _scandir(self): return os.scandir(self) def _make_child_relpath(self, name): - sep = self._flavour.sep + sep = self.flavour.sep lines_name = name.replace('\n', sep) lines_str = self._lines path_str = str(self) @@ -1062,7 +1073,7 @@ def _glob(self, pattern, case_sensitive, follow_symlinks): raise ValueError("Unacceptable pattern: {!r}".format(pattern)) pattern_parts = list(path_pattern._tail) - if pattern[-1] in (self._flavour.sep, self._flavour.altsep): + if pattern[-1] in (self.flavour.sep, self.flavour.altsep): # GH-65238: pathlib doesn't preserve trailing slash. Add it back. pattern_parts.append('') if pattern_parts[-1] == '**': @@ -1071,7 +1082,7 @@ def _glob(self, pattern, case_sensitive, follow_symlinks): if case_sensitive is None: # TODO: evaluate case-sensitivity of each directory in _select_children(). - case_sensitive = _is_case_sensitive(self._flavour) + case_sensitive = _is_case_sensitive(self.flavour) # If symlinks are handled consistently, and the pattern does not # contain '..' components, then we can use a 'walk-and-match' strategy @@ -1166,12 +1177,15 @@ def walk(self, top_down=True, on_error=None, follow_symlinks=False): paths += [path._make_child_relpath(d) for d in reversed(dirnames)] - def __init__(self, *args, **kwargs): + def __init__(self, *pathsegments, flavour=os.path, **kwargs): + if flavour is not os.path: + raise UnsupportedOperation( + f"cannot instantiate {type(self).__name__!r} on your system") if kwargs: msg = ("support for supplying keyword arguments to pathlib.PurePath " "is deprecated and scheduled for removal in Python {remove}") warnings._deprecated("pathlib.PurePath(**kwargs)", msg, remove=(3, 14)) - super().__init__(*args) + super().__init__(*pathsegments, flavour=flavour) def __new__(cls, *args, **kwargs): if cls is Path: @@ -1204,7 +1218,7 @@ def absolute(self): return self elif self.drive: # There is a CWD on each drive-letter drive. - cwd = self._flavour.abspath(self.drive) + cwd = self.flavour.abspath(self.drive) else: cwd = os.getcwd() # Fast path for "empty" paths, e.g. Path("."), Path("") or Path(). @@ -1230,7 +1244,7 @@ def check_eloop(e): raise RuntimeError("Symlink loop from %r" % e.filename) try: - s = self._flavour.realpath(self, strict=strict) + s = self.flavour.realpath(self, strict=strict) except OSError as e: check_eloop(e) raise @@ -1394,7 +1408,7 @@ def expanduser(self): """ if (not (self.drive or self.root) and self._tail and self._tail[0][:1] == '~'): - homedir = self._flavour.expanduser(self._tail[0]) + homedir = self.flavour.expanduser(self._tail[0]) if homedir[:1] == "~": raise RuntimeError("Could not determine home directory.") drv, root, tail = self._parse_path(homedir) @@ -1403,26 +1417,17 @@ def expanduser(self): return self -class PosixPath(Path, PurePosixPath): +class PosixPath(PurePosixPath, Path): """Path subclass for non-Windows systems. On a POSIX system, instantiating a Path should return this object. """ __slots__ = () - if os.name == 'nt': - def __new__(cls, *args, **kwargs): - raise UnsupportedOperation( - f"cannot instantiate {cls.__name__!r} on your system") -class WindowsPath(Path, PureWindowsPath): +class WindowsPath(PureWindowsPath, Path): """Path subclass for Windows systems. On a Windows system, instantiating a Path should return this object. """ __slots__ = () - - if os.name != 'nt': - def __new__(cls, *args, **kwargs): - raise UnsupportedOperation( - f"cannot instantiate {cls.__name__!r} on your system") diff --git a/Lib/test/test_pathlib.py b/Lib/test/test_pathlib.py index eb2b0cfb26e85ff..d811dce852cfd6f 100644 --- a/Lib/test/test_pathlib.py +++ b/Lib/test/test_pathlib.py @@ -3,6 +3,7 @@ import os import sys import errno +import ntpath import pathlib import pickle import posixpath @@ -66,7 +67,7 @@ class PurePathTest(unittest.TestCase): def setUp(self): p = self.cls('a') - self.flavour = p._flavour + self.flavour = p.flavour self.sep = self.flavour.sep self.altsep = self.flavour.altsep @@ -93,9 +94,16 @@ def test_concrete_class(self): p = self.cls('a') self.assertIs(type(p), expected) + def test_flavour(self): + p = self.cls('a') + if isinstance(p, pathlib.PureWindowsPath): + self.assertIs(p.flavour, ntpath) + elif isinstance(p, pathlib.PurePosixPath): + self.assertIs(p.flavour, posixpath) + def test_different_flavours_unequal(self): p = self.cls('a') - if p._flavour is posixpath: + if p.flavour is posixpath: q = pathlib.PureWindowsPath('a') else: q = pathlib.PurePosixPath('a') @@ -103,7 +111,7 @@ def test_different_flavours_unequal(self): def test_different_flavours_unordered(self): p = self.cls('a') - if p._flavour is posixpath: + if p.flavour is posixpath: q = pathlib.PureWindowsPath('a') else: q = pathlib.PurePosixPath('a') @@ -1549,6 +1557,15 @@ class cls(pathlib.PurePath): # repr() roundtripping is not supported in custom subclass. test_repr_roundtrips = None + def test_set_flavour(self): + p = self.cls('a', 'b', flavour=posixpath) + self.assertEqual(p.flavour, posixpath) + self.assertEqual(str(p), 'a/b') + + q = self.cls('a', 'b', flavour=ntpath) + self.assertEqual(q.flavour, ntpath) + self.assertEqual(str(q), 'a\\b') + @only_posix class PosixPathAsPureTest(PurePosixPathTest): @@ -2393,7 +2410,7 @@ def test_concrete_class(self): self.assertIs(type(p), expected) def test_unsupported_flavour(self): - if self.cls._flavour is os.path: + if (os.name == 'nt') == issubclass(self.cls, pathlib.PureWindowsPath): self.skipTest("path flavour is supported") else: self.assertRaises(pathlib.UnsupportedOperation, self.cls) @@ -2848,9 +2865,9 @@ def test_symlink_to_unsupported(self): def test_is_junction(self): P = self.cls(BASE) - with mock.patch.object(P._flavour, 'isjunction'): - self.assertEqual(P.is_junction(), P._flavour.isjunction.return_value) - P._flavour.isjunction.assert_called_once_with(P) + with mock.patch.object(P.flavour, 'isjunction'): + self.assertEqual(P.is_junction(), P.flavour.isjunction.return_value) + P.flavour.isjunction.assert_called_once_with(P) @unittest.skipUnless(hasattr(os, "mkfifo"), "os.mkfifo() required") @unittest.skipIf(sys.platform == "vxworks", @@ -3450,6 +3467,12 @@ class cls(pathlib.Path): # repr() roundtripping is not supported in custom subclass. test_repr_roundtrips = None + def test_set_flavour_unsupported(self): + self.assertRaises( + pathlib.UnsupportedOperation, + self.cls, + flavour=posixpath if os.name == 'nt' else ntpath) + class CompatiblePathTest(unittest.TestCase): """ diff --git a/Misc/NEWS.d/next/Library/2023-07-07-21-15-17.gh-issue-100502.Iici1B.rst b/Misc/NEWS.d/next/Library/2023-07-07-21-15-17.gh-issue-100502.Iici1B.rst new file mode 100644 index 000000000000000..e4bc2d3137c051d --- /dev/null +++ b/Misc/NEWS.d/next/Library/2023-07-07-21-15-17.gh-issue-100502.Iici1B.rst @@ -0,0 +1,3 @@ +Add :attr:`pathlib.PurePath.flavour` attribute that stores the +implementation of :mod:`os.path` used for low-level path operations: either +:mod:`posixpath` or :mod:`ntpath`.