From 7371220586c8367d80867f340381740347d7aea1 Mon Sep 17 00:00:00 2001 From: barneygale Date: Sat, 5 Mar 2022 02:03:12 +0000 Subject: [PATCH 01/44] bpo-24132, bpo-44136 - Fix `pathlib.Path` subclassing. Users may wish to define subclasses of `pathlib.Path` to add or modify existing methods. Before this change, attempting to instantiate a subclass raised an exception like: AttributeError: type object 'PPath' has no attribute '_flavour' Previously the `_flavour` attribute was assigned as follows: PurePath._flavour = xxx not set!! xxx PurePosixPath._flavour = _PosixFlavour() PureWindowsPath._flavour = _WindowsFlavour() This commit replaces it with a `_pathmod` attribute, set as follows: PurePath._pathmod = os.path PurePosixPath._pathmod = posixpath PureWindowsPath._pathmod = ntpath Functionality from `_PosixFlavour` and `_WindowsFlavour` is moved into `PurePath` as underscored-prefixed classmethods. Flavours are removed. --- Lib/pathlib.py | 500 ++++++++---------- Lib/test/test_pathlib.py | 255 +++++---- .../2022-03-05-02-14-09.bpo-24132.W6iORO.rst | 2 + 3 files changed, 345 insertions(+), 412 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2022-03-05-02-14-09.bpo-24132.W6iORO.rst diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 4763ab54f6ba81..0be2ee9169e9ea 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -23,6 +23,14 @@ # Internals # +_WIN_EXT_NAMESPACE_PREFIX = '\\\\?\\' +_WIN_DRIVE_LETTERS = set('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ') +_WIN_RESERVED_NAMES = ( + {'CON', 'PRN', 'AUX', 'NUL', 'CONIN$', 'CONOUT$'} | + {'COM%s' % c for c in '123456789\xb9\xb2\xb3'} | + {'LPT%s' % c for c in '123456789\xb9\xb2\xb3'} +) + _WINERROR_NOT_READY = 21 # drive exists but is not accessible _WINERROR_INVALID_NAME = 123 # fix for bpo-35306 _WINERROR_CANT_RESOLVE_FILENAME = 1921 # broken symlink pointing to itself @@ -45,240 +53,11 @@ def _is_wildcard_pattern(pat): # be looked up directly as a file. return "*" in pat or "?" in pat or "[" in pat - -class _Flavour(object): - """A flavour implements a particular (platform-specific) set of path - semantics.""" - - def __init__(self): - self.join = self.sep.join - - def parse_parts(self, parts): - parsed = [] - sep = self.sep - altsep = self.altsep - drv = root = '' - it = reversed(parts) - for part in it: - if not part: - continue - if altsep: - part = part.replace(altsep, sep) - drv, root, rel = self.splitroot(part) - if sep in rel: - for x in reversed(rel.split(sep)): - if x and x != '.': - parsed.append(sys.intern(x)) - else: - if rel and rel != '.': - parsed.append(sys.intern(rel)) - if drv or root: - if not drv: - # If no drive is present, try to find one in the previous - # parts. This makes the result of parsing e.g. - # ("C:", "/", "a") reasonably intuitive. - for part in it: - if not part: - continue - if altsep: - part = part.replace(altsep, sep) - drv = self.splitroot(part)[0] - if drv: - break - break - if drv or root: - parsed.append(drv + root) - parsed.reverse() - return drv, root, parsed - - def join_parsed_parts(self, drv, root, parts, drv2, root2, parts2): - """ - Join the two paths represented by the respective - (drive, root, parts) tuples. Return a new (drive, root, parts) tuple. - """ - if root2: - if not drv2 and drv: - return drv, root2, [drv + root2] + parts2[1:] - elif drv2: - if drv2 == drv or self.casefold(drv2) == self.casefold(drv): - # Same drive => second path is relative to the first - return drv, root, parts + parts2[1:] - else: - # Second path is non-anchored (common case) - return drv, root, parts + parts2 - return drv2, root2, parts2 - - -class _WindowsFlavour(_Flavour): - # Reference for Windows paths can be found at - # http://msdn.microsoft.com/en-us/library/aa365247%28v=vs.85%29.aspx - - sep = '\\' - altsep = '/' - has_drv = True - pathmod = ntpath - - is_supported = (os.name == 'nt') - - drive_letters = set('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ') - ext_namespace_prefix = '\\\\?\\' - - reserved_names = ( - {'CON', 'PRN', 'AUX', 'NUL', 'CONIN$', 'CONOUT$'} | - {'COM%s' % c for c in '123456789\xb9\xb2\xb3'} | - {'LPT%s' % c for c in '123456789\xb9\xb2\xb3'} - ) - - # Interesting findings about extended paths: - # * '\\?\c:\a' is an extended path, which bypasses normal Windows API - # path processing. Thus relative paths are not resolved and slash is not - # translated to backslash. It has the native NT path limit of 32767 - # characters, but a bit less after resolving device symbolic links, - # such as '\??\C:' => '\Device\HarddiskVolume2'. - # * '\\?\c:/a' looks for a device named 'C:/a' because slash is a - # regular name character in the object namespace. - # * '\\?\c:\foo/bar' is invalid because '/' is illegal in NT filesystems. - # The only path separator at the filesystem level is backslash. - # * '//?/c:\a' and '//?/c:/a' are effectively equivalent to '\\.\c:\a' and - # thus limited to MAX_PATH. - # * Prior to Windows 8, ANSI API bytes paths are limited to MAX_PATH, - # even with the '\\?\' prefix. - - def splitroot(self, part, sep=sep): - first = part[0:1] - second = part[1:2] - if (second == sep and first == sep): - # XXX extended paths should also disable the collapsing of "." - # components (according to MSDN docs). - prefix, part = self._split_extended_path(part) - first = part[0:1] - second = part[1:2] - else: - prefix = '' - third = part[2:3] - if (second == sep and first == sep and third != sep): - # is a UNC path: - # vvvvvvvvvvvvvvvvvvvvv root - # \\machine\mountpoint\directory\etc\... - # directory ^^^^^^^^^^^^^^ - index = part.find(sep, 2) - if index != -1: - index2 = part.find(sep, index + 1) - # a UNC path can't have two slashes in a row - # (after the initial two) - if index2 != index + 1: - if index2 == -1: - index2 = len(part) - if prefix: - return prefix + part[1:index2], sep, part[index2+1:] - else: - return part[:index2], sep, part[index2+1:] - drv = root = '' - if second == ':' and first in self.drive_letters: - drv = part[:2] - part = part[2:] - first = third - if first == sep: - root = first - part = part.lstrip(sep) - return prefix + drv, root, part - - def casefold(self, s): - return s.lower() - - def casefold_parts(self, parts): - return [p.lower() for p in parts] - - def compile_pattern(self, pattern): - return re.compile(fnmatch.translate(pattern), re.IGNORECASE).fullmatch - - def _split_extended_path(self, s, ext_prefix=ext_namespace_prefix): - prefix = '' - if s.startswith(ext_prefix): - prefix = s[:4] - s = s[4:] - if s.startswith('UNC\\'): - prefix += s[:3] - s = '\\' + s[3:] - return prefix, s - - def is_reserved(self, parts): - # NOTE: the rules for reserved names seem somewhat complicated - # (e.g. r"..\NUL" is reserved but not r"foo\NUL" if "foo" does not - # exist). We err on the side of caution and return True for paths - # which are not considered reserved by Windows. - if not parts: - return False - if parts[0].startswith('\\\\'): - # UNC paths are never reserved - return False - name = parts[-1].partition('.')[0].partition(':')[0].rstrip(' ') - return name.upper() in self.reserved_names - - def make_uri(self, path): - # Under Windows, file URIs use the UTF-8 encoding. - drive = path.drive - if len(drive) == 2 and drive[1] == ':': - # It's a path on a local drive => 'file:///c:/a/b' - rest = path.as_posix()[2:].lstrip('/') - return 'file:///%s/%s' % ( - drive, urlquote_from_bytes(rest.encode('utf-8'))) - else: - # It's a path on a network drive => 'file://host/share/a/b' - return 'file:' + urlquote_from_bytes(path.as_posix().encode('utf-8')) - - -class _PosixFlavour(_Flavour): - sep = '/' - altsep = '' - has_drv = False - pathmod = posixpath - - is_supported = (os.name != 'nt') - - def splitroot(self, part, sep=sep): - if part and part[0] == sep: - stripped_part = part.lstrip(sep) - # According to POSIX path resolution: - # http://pubs.opengroup.org/onlinepubs/009695399/basedefs/xbd_chap04.html#tag_04_11 - # "A pathname that begins with two successive slashes may be - # interpreted in an implementation-defined manner, although more - # than two leading slashes shall be treated as a single slash". - if len(part) - len(stripped_part) == 2: - return '', sep * 2, stripped_part - else: - return '', sep, stripped_part - else: - return '', '', part - - def casefold(self, s): - return s - - def casefold_parts(self, parts): - return parts - - def compile_pattern(self, pattern): - return re.compile(fnmatch.translate(pattern)).fullmatch - - def is_reserved(self, parts): - return False - - def make_uri(self, path): - # We represent the path using the local filesystem encoding, - # for portability to other applications. - bpath = bytes(path) - return 'file://' + urlquote_from_bytes(bpath) - - -_windows_flavour = _WindowsFlavour() -_posix_flavour = _PosixFlavour() - - # # Globbing helpers # -def _make_selector(pattern_parts, flavour): +def _make_selector(pattern_parts, path_cls): pat = pattern_parts[0] child_parts = pattern_parts[1:] if pat == '**': @@ -289,7 +68,7 @@ def _make_selector(pattern_parts, flavour): cls = _WildcardSelector else: cls = _PreciseSelector - return cls(pat, child_parts, flavour) + return cls(pat, child_parts, path_cls) if hasattr(functools, "lru_cache"): _make_selector = functools.lru_cache()(_make_selector) @@ -299,10 +78,10 @@ class _Selector: """A selector matches a specific glob pattern part against the children of a given path.""" - def __init__(self, child_parts, flavour): + def __init__(self, child_parts, path_cls): self.child_parts = child_parts if child_parts: - self.successor = _make_selector(child_parts, flavour) + self.successor = _make_selector(child_parts, path_cls) self.dironly = True else: self.successor = _TerminatingSelector() @@ -328,9 +107,9 @@ def _select_from(self, parent_path, is_dir, exists, scandir): class _PreciseSelector(_Selector): - def __init__(self, name, child_parts, flavour): + def __init__(self, name, child_parts, path_cls): self.name = name - _Selector.__init__(self, child_parts, flavour) + _Selector.__init__(self, child_parts, path_cls) def _select_from(self, parent_path, is_dir, exists, scandir): try: @@ -344,9 +123,9 @@ def _select_from(self, parent_path, is_dir, exists, scandir): class _WildcardSelector(_Selector): - def __init__(self, pat, child_parts, flavour): - self.match = flavour.compile_pattern(pat) - _Selector.__init__(self, child_parts, flavour) + def __init__(self, pat, child_parts, path_cls): + self.match = path_cls._compile_pattern(pat) + _Selector.__init__(self, child_parts, path_cls) def _select_from(self, parent_path, is_dir, exists, scandir): try: @@ -375,8 +154,8 @@ def _select_from(self, parent_path, is_dir, exists, scandir): class _RecursiveWildcardSelector(_Selector): - def __init__(self, pat, child_parts, flavour): - _Selector.__init__(self, child_parts, flavour) + def __init__(self, pat, child_parts, path_cls): + _Selector.__init__(self, child_parts, path_cls) def _iterate_directories(self, parent_path, is_dir, scandir): yield parent_path @@ -461,6 +240,7 @@ class PurePath(object): '_drv', '_root', '_parts', '_str', '_hash', '_pparts', '_cached_cparts', ) + _pathmod = os.path def __new__(cls, *args): """Construct a PurePath from one or several strings and or existing @@ -477,6 +257,126 @@ def __reduce__(self): # when pickling related paths. return (self.__class__, tuple(self._parts)) + @classmethod + def _casefold(cls, s): + return cls._pathmod.normcase(s) + + @classmethod + def _casefold_parts(cls, parts): + return [cls._pathmod.normcase(p) for p in parts] + + @classmethod + def _compile_pattern(cls, pattern): + flags = 0 if cls._pathmod is posixpath else re.IGNORECASE + return re.compile(fnmatch.translate(pattern), flags).fullmatch + + @classmethod + def _split_extended_path(cls, s): + prefix = '' + if s.startswith(_WIN_EXT_NAMESPACE_PREFIX): + prefix = s[:4] + s = s[4:] + if s.startswith('UNC\\'): + prefix += s[:3] + s = '\\' + s[3:] + return prefix, s + + @classmethod + def _splitroot(cls, part): + # FIXME: This method should use os.path.splitdrive() + sep = cls._pathmod.sep + if cls._pathmod is posixpath: + if part and part[0] == sep: + stripped_part = part.lstrip(sep) + # According to POSIX path resolution: + # http://pubs.opengroup.org/onlinepubs/009695399/basedefs/xbd_chap04.html#tag_04_11 + # "A pathname that begins with two successive slashes may be + # interpreted in an implementation-defined manner, although more + # than two leading slashes shall be treated as a single slash". + if len(part) - len(stripped_part) == 2: + return '', sep * 2, stripped_part + else: + return '', sep, stripped_part + else: + return '', '', part + + first = part[0:1] + second = part[1:2] + if (second == sep and first == sep): + # XXX extended paths should also disable the collapsing of "." + # components (according to MSDN docs). + prefix, part = cls._split_extended_path(part) + first = part[0:1] + second = part[1:2] + else: + prefix = '' + third = part[2:3] + if (second == sep and first == sep and third != sep): + # is a UNC path: + # vvvvvvvvvvvvvvvvvvvvv root + # \\machine\mountpoint\directory\etc\... + # directory ^^^^^^^^^^^^^^ + index = part.find(sep, 2) + if index != -1: + index2 = part.find(sep, index + 1) + # a UNC path can't have two slashes in a row + # (after the initial two) + if index2 != index + 1: + if index2 == -1: + index2 = len(part) + if prefix: + return prefix + part[1:index2], sep, part[index2 + 1:] + else: + return part[:index2], sep, part[index2 + 1:] + drv = root = '' + if second == ':' and first in _WIN_DRIVE_LETTERS: + drv = part[:2] + part = part[2:] + first = third + if first == sep: + root = first + part = part.lstrip(sep) + return prefix + drv, root, part + + @classmethod + def _parse_parts(cls, parts): + parsed = [] + sep = cls._pathmod.sep + altsep = cls._pathmod.altsep + drv = root = '' + it = reversed(parts) + for part in it: + if not part: + continue + if altsep: + part = part.replace(altsep, sep) + drv, root, rel = cls._splitroot(part) + if sep in rel: + for x in reversed(rel.split(sep)): + if x and x != '.': + parsed.append(sys.intern(x)) + else: + if rel and rel != '.': + parsed.append(sys.intern(rel)) + if drv or root: + if not drv: + # If no drive is present, try to find one in the previous + # parts. This makes the result of parsing e.g. + # ("C:", "/", "a") reasonably intuitive. + for part in it: + if not part: + continue + if altsep: + part = part.replace(altsep, sep) + drv = cls._splitroot(part)[0] + if drv: + break + break + if drv or root: + parsed.append(drv + root) + parsed.reverse() + return drv, root, parsed + @classmethod def _parse_args(cls, args): # This is useful when you don't want to create an instance, just @@ -495,7 +395,7 @@ def _parse_args(cls, args): "argument should be a str object or an os.PathLike " "object returning str, not %r" % type(a)) - return cls._flavour.parse_parts(parts) + return cls._parse_parts(parts) @classmethod def _from_parts(cls, args): @@ -519,14 +419,31 @@ def _from_parsed_parts(cls, drv, root, parts): @classmethod def _format_parsed_parts(cls, drv, root, parts): if drv or root: - return drv + root + cls._flavour.join(parts[1:]) + return drv + root + cls._pathmod.sep.join(parts[1:]) + else: + return cls._pathmod.sep.join(parts) + + def _join_parsed_parts(self, drv2, root2, parts2): + """ + Join the two paths represented by the respective + (drive, root, parts) tuples. Return a new (drive, root, parts) tuple. + """ + drv, root, parts = self._drv, self._root, self._parts + if root2: + if not drv2 and drv: + return drv, root2, [drv + root2] + parts2[1:] + elif drv2: + if drv2 == drv or self._casefold(drv2) == self._casefold(drv): + # Same drive => second path is relative to the first + return drv, root, parts + parts2[1:] else: - return cls._flavour.join(parts) + # Second path is non-anchored (common case) + return drv, root, parts + parts2 + return drv2, root2, parts2 def _make_child(self, args): drv, root, parts = self._parse_args(args) - drv, root, parts = self._flavour.join_parsed_parts( - self._drv, self._root, self._parts, drv, root, parts) + drv, root, parts = self._join_parsed_parts(drv, root, parts) return self._from_parsed_parts(drv, root, parts) def __str__(self): @@ -545,8 +462,8 @@ def __fspath__(self): def as_posix(self): """Return the string representation of the path with forward (/) slashes.""" - f = self._flavour - return str(self).replace(f.sep, '/') + m = self._pathmod + return str(self).replace(m.sep, '/') def __bytes__(self): """Return the bytes representation of the path. This is only @@ -560,7 +477,23 @@ def as_uri(self): """Return the path as a 'file' URI.""" if not self.is_absolute(): raise ValueError("relative path can't be expressed as a file URI") - return self._flavour.make_uri(self) + + # FIXME: move this implementation to os.path.fileuri() + if self._pathmod is posixpath: + # On POSIX we represent the path using the local filesystem encoding, + # for portability to other applications. + return 'file://' + urlquote_from_bytes(bytes(self)) + + # Under Windows, file URIs use the UTF-8 encoding. + drive = self._drv + if len(drive) == 2 and drive[1] == ':': + # It's a path on a local drive => 'file:///c:/a/b' + rest = self.as_posix()[2:].lstrip('/') + return 'file:///%s/%s' % ( + drive, urlquote_from_bytes(rest.encode('utf-8'))) + else: + # It's a path on a network drive => 'file://host/share/a/b' + return 'file:' + urlquote_from_bytes(self.as_posix().encode('utf-8')) @property def _cparts(self): @@ -568,13 +501,13 @@ def _cparts(self): try: return self._cached_cparts except AttributeError: - self._cached_cparts = self._flavour.casefold_parts(self._parts) + self._cached_cparts = self._casefold_parts(self._parts) return self._cached_cparts def __eq__(self, other): if not isinstance(other, PurePath): return NotImplemented - return self._cparts == other._cparts and self._flavour is other._flavour + return self._cparts == other._cparts and self._pathmod is other._pathmod def __hash__(self): try: @@ -584,22 +517,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._pathmod is not other._pathmod: return NotImplemented return self._cparts < other._cparts def __le__(self, other): - if not isinstance(other, PurePath) or self._flavour is not other._flavour: + if not isinstance(other, PurePath) or self._pathmod is not other._pathmod: return NotImplemented return self._cparts <= other._cparts def __gt__(self, other): - if not isinstance(other, PurePath) or self._flavour is not other._flavour: + if not isinstance(other, PurePath) or self._pathmod is not other._pathmod: return NotImplemented return self._cparts > other._cparts def __ge__(self, other): - if not isinstance(other, PurePath) or self._flavour is not other._flavour: + if not isinstance(other, PurePath) or self._pathmod is not other._pathmod: return NotImplemented return self._cparts >= other._cparts @@ -664,8 +597,8 @@ 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,)) - drv, root, parts = self._flavour.parse_parts((name,)) - if (not name or name[-1] in [self._flavour.sep, self._flavour.altsep] + drv, root, parts = self._parse_parts((name,)) + if (not name or name[-1] in [self._pathmod.sep, self._pathmod.altsep] or drv or root or len(parts) != 1): raise ValueError("Invalid name %r" % (name)) return self._from_parsed_parts(self._drv, self._root, @@ -680,8 +613,8 @@ 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 - if f.sep in suffix or f.altsep and f.altsep in suffix: + m = self._pathmod + if m.sep in suffix or m.altsep and m.altsep in suffix: raise ValueError("Invalid suffix %r" % (suffix,)) if suffix and not suffix.startswith('.') or suffix == '.': raise ValueError("Invalid suffix %r" % (suffix)) @@ -720,7 +653,7 @@ def relative_to(self, *other): else: to_abs_parts = to_parts n = len(to_abs_parts) - cf = self._flavour.casefold_parts + cf = self._casefold_parts if (root or drv) if n == 0 else cf(abs_parts[:n]) != cf(to_abs_parts): formatted = self._format_parsed_parts(to_drv, to_root, to_parts) raise ValueError("{!r} is not in the subpath of {!r}" @@ -790,20 +723,32 @@ def is_absolute(self): a drive).""" if not self._root: return False - return not self._flavour.has_drv or bool(self._drv) + return self._pathmod is posixpath or bool(self._drv) def is_reserved(self): """Return True if the path contains one of the special names reserved by the system, if any.""" - return self._flavour.is_reserved(self._parts) + # FIXME: move this implementation to os.path.isreserved() + if self._pathmod is posixpath or not self._parts: + return False + + # NOTE: the rules for reserved names seem somewhat complicated + # (e.g. r"..\NUL" is reserved but not r"foo\NUL" if "foo" does not + # exist). We err on the side of caution and return True for paths + # which are not considered reserved by Windows. + if self._parts[0].startswith('\\\\'): + # UNC paths are never reserved + return False + name = self._parts[-1].partition('.')[0].partition(':')[0].rstrip(' ') + return name.upper() in _WIN_RESERVED_NAMES def match(self, path_pattern): """ Return True if this path matches the given pattern. """ - cf = self._flavour.casefold + cf = self._casefold path_pattern = cf(path_pattern) - drv, root, pat_parts = self._flavour.parse_parts((path_pattern,)) + drv, root, pat_parts = self._parse_parts((path_pattern,)) if not pat_parts: raise ValueError("empty pattern") if drv and drv != cf(self._drv): @@ -833,7 +778,7 @@ 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 = _posix_flavour + _pathmod = posixpath __slots__ = () @@ -843,7 +788,7 @@ 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 = _windows_flavour + _pathmod = ntpath __slots__ = () @@ -864,11 +809,10 @@ class Path(PurePath): def __new__(cls, *args, **kwargs): if cls is Path: cls = WindowsPath if os.name == 'nt' else PosixPath - self = cls._from_parts(args) - if not self._flavour.is_supported: + elif cls._pathmod is not os.path: raise NotImplementedError("cannot instantiate %r on your system" % (cls.__name__,)) - return self + return cls._from_parts(args) def _make_child_relpath(self, part): # This is an optimization used for dir walking. `part` must be @@ -918,7 +862,7 @@ def samefile(self, other_path): other_st = other_path.stat() except AttributeError: other_st = self.__class__(other_path).stat() - return os.path.samestat(st, other_st) + return self._pathmod.samestat(st, other_st) def iterdir(self): """Iterate over the files in this directory. Does not yield any @@ -940,10 +884,10 @@ def glob(self, pattern): sys.audit("pathlib.Path.glob", self, pattern) if not pattern: raise ValueError("Unacceptable pattern: {!r}".format(pattern)) - drv, root, pattern_parts = self._flavour.parse_parts((pattern,)) + drv, root, pattern_parts = self._parse_parts((pattern,)) if drv or root: raise NotImplementedError("Non-relative patterns are unsupported") - selector = _make_selector(tuple(pattern_parts), self._flavour) + selector = _make_selector(tuple(pattern_parts), type(self)) for p in selector.select_from(self): yield p @@ -953,10 +897,10 @@ def rglob(self, pattern): this subtree. """ sys.audit("pathlib.Path.rglob", self, pattern) - drv, root, pattern_parts = self._flavour.parse_parts((pattern,)) + drv, root, pattern_parts = self._parse_parts((pattern,)) if drv or root: raise NotImplementedError("Non-relative patterns are unsupported") - selector = _make_selector(("**",) + tuple(pattern_parts), self._flavour) + selector = _make_selector(("**",) + tuple(pattern_parts), type(self)) for p in selector.select_from(self): yield p @@ -982,7 +926,7 @@ def check_eloop(e): raise RuntimeError("Symlink loop from %r" % e.filename) try: - s = os.path.realpath(self, strict=strict) + s = self._pathmod.realpath(self, strict=strict) except OSError as e: check_eloop(e) raise @@ -1271,6 +1215,9 @@ def is_mount(self): """ Check if this path is a POSIX mount point """ + if self._pathmod is not posixpath: + raise NotImplementedError("Path.is_mount() is unsupported on this system") + # Need to exist and be a dir if not self.exists() or not self.is_dir(): return False @@ -1372,7 +1319,7 @@ def expanduser(self): """ if (not (self._drv or self._root) and self._parts and self._parts[0][:1] == '~'): - homedir = os.path.expanduser(self._parts[0]) + homedir = self._pathmod.expanduser(self._parts[0]) if homedir[:1] == "~": raise RuntimeError("Could not determine home directory.") return self._from_parts([homedir] + self._parts[1:]) @@ -1393,6 +1340,3 @@ class WindowsPath(Path, PureWindowsPath): On a Windows system, instantiating a Path should return this object. """ __slots__ = () - - def is_mount(self): - raise NotImplementedError("Path.is_mount() is unsupported on this system") diff --git a/Lib/test/test_pathlib.py b/Lib/test/test_pathlib.py index f03fcbef1b0a08..9814df9a52282c 100644 --- a/Lib/test/test_pathlib.py +++ b/Lib/test/test_pathlib.py @@ -22,12 +22,37 @@ grp = pwd = None -class _BaseFlavourTest(object): +# +# Tests for the pure classes. +# + +class _BasePurePathTest(object): + + # Keys are canonical paths, values are list of tuples of arguments + # supposed to produce equal paths. + equivalences = { + 'a/b': [ + ('a', 'b'), ('a/', 'b'), ('a', 'b/'), ('a/', 'b/'), + ('a/b/',), ('a//b',), ('a//b//',), + # Empty components get removed. + ('', 'a', 'b'), ('a', '', 'b'), ('a', 'b', ''), + ], + '/b/c/d': [ + ('a', '/b/c', 'd'), ('a', '///b//c', 'd/'), + ('/a', '/b/c', 'd'), + # Empty components get removed. + ('/', 'b', '', 'c/d'), ('/', '', 'b/c/d'), ('', '/b/c/d'), + ], + } + + def setUp(self): + self.sep = self.cls._pathmod.sep + self.altsep = self.cls._pathmod.altsep def _check_parse_parts(self, arg, expected): - f = self.flavour.parse_parts - sep = self.flavour.sep - altsep = self.flavour.altsep + f = self.cls._parse_parts + sep = self.sep + altsep = self.altsep actual = f([x.replace('/', sep) for x in arg]) self.assertEqual(actual, expected) if altsep: @@ -36,7 +61,7 @@ def _check_parse_parts(self, arg, expected): def test_parse_parts_common(self): check = self._check_parse_parts - sep = self.flavour.sep + sep = self.sep # Unanchored parts. check([], ('', '', [])) check(['a'], ('', '', ['a'])) @@ -62,134 +87,6 @@ def test_parse_parts_common(self): check(['a', '/b', 'c'], ('', sep, [sep, 'b', 'c'])) check(['a', '/b', '/c'], ('', sep, [sep, 'c'])) - -class PosixFlavourTest(_BaseFlavourTest, unittest.TestCase): - flavour = pathlib._posix_flavour - - def test_parse_parts(self): - check = self._check_parse_parts - # Collapsing of excess leading slashes, except for the double-slash - # special case. - check(['//a', 'b'], ('', '//', ['//', 'a', 'b'])) - check(['///a', 'b'], ('', '/', ['/', 'a', 'b'])) - check(['////a', 'b'], ('', '/', ['/', 'a', 'b'])) - # Paths which look like NT paths aren't treated specially. - check(['c:a'], ('', '', ['c:a'])) - check(['c:\\a'], ('', '', ['c:\\a'])) - check(['\\a'], ('', '', ['\\a'])) - - def test_splitroot(self): - f = self.flavour.splitroot - self.assertEqual(f(''), ('', '', '')) - self.assertEqual(f('a'), ('', '', 'a')) - self.assertEqual(f('a/b'), ('', '', 'a/b')) - self.assertEqual(f('a/b/'), ('', '', 'a/b/')) - self.assertEqual(f('/a'), ('', '/', 'a')) - self.assertEqual(f('/a/b'), ('', '/', 'a/b')) - self.assertEqual(f('/a/b/'), ('', '/', 'a/b/')) - # The root is collapsed when there are redundant slashes - # except when there are exactly two leading slashes, which - # is a special case in POSIX. - self.assertEqual(f('//a'), ('', '//', 'a')) - self.assertEqual(f('///a'), ('', '/', 'a')) - self.assertEqual(f('///a/b'), ('', '/', 'a/b')) - # Paths which look like NT paths aren't treated specially. - self.assertEqual(f('c:/a/b'), ('', '', 'c:/a/b')) - self.assertEqual(f('\\/a/b'), ('', '', '\\/a/b')) - self.assertEqual(f('\\a\\b'), ('', '', '\\a\\b')) - - -class NTFlavourTest(_BaseFlavourTest, unittest.TestCase): - flavour = pathlib._windows_flavour - - def test_parse_parts(self): - check = self._check_parse_parts - # First part is anchored. - check(['c:'], ('c:', '', ['c:'])) - check(['c:/'], ('c:', '\\', ['c:\\'])) - check(['/'], ('', '\\', ['\\'])) - check(['c:a'], ('c:', '', ['c:', 'a'])) - check(['c:/a'], ('c:', '\\', ['c:\\', 'a'])) - check(['/a'], ('', '\\', ['\\', 'a'])) - # UNC paths. - check(['//a/b'], ('\\\\a\\b', '\\', ['\\\\a\\b\\'])) - check(['//a/b/'], ('\\\\a\\b', '\\', ['\\\\a\\b\\'])) - check(['//a/b/c'], ('\\\\a\\b', '\\', ['\\\\a\\b\\', 'c'])) - # Second part is anchored, so that the first part is ignored. - check(['a', 'Z:b', 'c'], ('Z:', '', ['Z:', 'b', 'c'])) - check(['a', 'Z:/b', 'c'], ('Z:', '\\', ['Z:\\', 'b', 'c'])) - # UNC paths. - check(['a', '//b/c', 'd'], ('\\\\b\\c', '\\', ['\\\\b\\c\\', 'd'])) - # Collapsing and stripping excess slashes. - check(['a', 'Z://b//c/', 'd/'], ('Z:', '\\', ['Z:\\', 'b', 'c', 'd'])) - # UNC paths. - check(['a', '//b/c//', 'd'], ('\\\\b\\c', '\\', ['\\\\b\\c\\', 'd'])) - # Extended paths. - check(['//?/c:/'], ('\\\\?\\c:', '\\', ['\\\\?\\c:\\'])) - check(['//?/c:/a'], ('\\\\?\\c:', '\\', ['\\\\?\\c:\\', 'a'])) - check(['//?/c:/a', '/b'], ('\\\\?\\c:', '\\', ['\\\\?\\c:\\', 'b'])) - # Extended UNC paths (format is "\\?\UNC\server\share"). - check(['//?/UNC/b/c'], ('\\\\?\\UNC\\b\\c', '\\', ['\\\\?\\UNC\\b\\c\\'])) - check(['//?/UNC/b/c/d'], ('\\\\?\\UNC\\b\\c', '\\', ['\\\\?\\UNC\\b\\c\\', 'd'])) - # Second part has a root but not drive. - check(['a', '/b', 'c'], ('', '\\', ['\\', 'b', 'c'])) - check(['Z:/a', '/b', 'c'], ('Z:', '\\', ['Z:\\', 'b', 'c'])) - check(['//?/Z:/a', '/b', 'c'], ('\\\\?\\Z:', '\\', ['\\\\?\\Z:\\', 'b', 'c'])) - - def test_splitroot(self): - f = self.flavour.splitroot - self.assertEqual(f(''), ('', '', '')) - self.assertEqual(f('a'), ('', '', 'a')) - self.assertEqual(f('a\\b'), ('', '', 'a\\b')) - self.assertEqual(f('\\a'), ('', '\\', 'a')) - self.assertEqual(f('\\a\\b'), ('', '\\', 'a\\b')) - self.assertEqual(f('c:a\\b'), ('c:', '', 'a\\b')) - self.assertEqual(f('c:\\a\\b'), ('c:', '\\', 'a\\b')) - # Redundant slashes in the root are collapsed. - self.assertEqual(f('\\\\a'), ('', '\\', 'a')) - self.assertEqual(f('\\\\\\a/b'), ('', '\\', 'a/b')) - self.assertEqual(f('c:\\\\a'), ('c:', '\\', 'a')) - self.assertEqual(f('c:\\\\\\a/b'), ('c:', '\\', 'a/b')) - # Valid UNC paths. - self.assertEqual(f('\\\\a\\b'), ('\\\\a\\b', '\\', '')) - self.assertEqual(f('\\\\a\\b\\'), ('\\\\a\\b', '\\', '')) - self.assertEqual(f('\\\\a\\b\\c\\d'), ('\\\\a\\b', '\\', 'c\\d')) - # These are non-UNC paths (according to ntpath.py and test_ntpath). - # However, command.com says such paths are invalid, so it's - # difficult to know what the right semantics are. - self.assertEqual(f('\\\\\\a\\b'), ('', '\\', 'a\\b')) - self.assertEqual(f('\\\\a'), ('', '\\', 'a')) - - -# -# Tests for the pure classes. -# - -class _BasePurePathTest(object): - - # Keys are canonical paths, values are list of tuples of arguments - # supposed to produce equal paths. - equivalences = { - 'a/b': [ - ('a', 'b'), ('a/', 'b'), ('a', 'b/'), ('a/', 'b/'), - ('a/b/',), ('a//b',), ('a//b//',), - # Empty components get removed. - ('', 'a', 'b'), ('a', '', 'b'), ('a', 'b', ''), - ], - '/b/c/d': [ - ('a', '/b/c', 'd'), ('a', '///b//c', 'd/'), - ('/a', '/b/c', 'd'), - # Empty components get removed. - ('/', 'b', '', 'c/d'), ('/', '', 'b/c/d'), ('', '/b/c/d'), - ], - } - - def setUp(self): - p = self.cls('a') - self.flavour = p._flavour - self.sep = self.flavour.sep - self.altsep = self.flavour.altsep - def test_constructor_common(self): P = self.cls p = P('a') @@ -616,7 +513,7 @@ def test_with_suffix_common(self): self.assertRaises(ValueError, P('a/b').with_suffix, './.d') self.assertRaises(ValueError, P('a/b').with_suffix, '.d/.') self.assertRaises(ValueError, P('a/b').with_suffix, - (self.flavour.sep, 'd')) + (self.sep, 'd')) def test_relative_to_common(self): P = self.cls @@ -702,6 +599,38 @@ def test_pickling_common(self): class PurePosixPathTest(_BasePurePathTest, unittest.TestCase): cls = pathlib.PurePosixPath + def test_parse_parts(self): + check = self._check_parse_parts + # Collapsing of excess leading slashes, except for the double-slash + # special case. + check(['//a', 'b'], ('', '//', ['//', 'a', 'b'])) + check(['///a', 'b'], ('', '/', ['/', 'a', 'b'])) + check(['////a', 'b'], ('', '/', ['/', 'a', 'b'])) + # Paths which look like NT paths aren't treated specially. + check(['c:a'], ('', '', ['c:a'])) + check(['c:\\a'], ('', '', ['c:\\a'])) + check(['\\a'], ('', '', ['\\a'])) + + def test_splitroot(self): + f = self.cls._splitroot + self.assertEqual(f(''), ('', '', '')) + self.assertEqual(f('a'), ('', '', 'a')) + self.assertEqual(f('a/b'), ('', '', 'a/b')) + self.assertEqual(f('a/b/'), ('', '', 'a/b/')) + self.assertEqual(f('/a'), ('', '/', 'a')) + self.assertEqual(f('/a/b'), ('', '/', 'a/b')) + self.assertEqual(f('/a/b/'), ('', '/', 'a/b/')) + # The root is collapsed when there are redundant slashes + # except when there are exactly two leading slashes, which + # is a special case in POSIX. + self.assertEqual(f('//a'), ('', '//', 'a')) + self.assertEqual(f('///a'), ('', '/', 'a')) + self.assertEqual(f('///a/b'), ('', '/', 'a/b')) + # Paths which look like NT paths aren't treated specially. + self.assertEqual(f('c:/a/b'), ('', '', 'c:/a/b')) + self.assertEqual(f('\\/a/b'), ('', '', '\\/a/b')) + self.assertEqual(f('\\a\\b'), ('', '', '\\a\\b')) + def test_root(self): P = self.cls self.assertEqual(P('/a/b').root, '/') @@ -791,6 +720,64 @@ class PureWindowsPathTest(_BasePurePathTest, unittest.TestCase): ], }) + def test_parse_parts(self): + check = self._check_parse_parts + # First part is anchored. + check(['c:'], ('c:', '', ['c:'])) + check(['c:/'], ('c:', '\\', ['c:\\'])) + check(['/'], ('', '\\', ['\\'])) + check(['c:a'], ('c:', '', ['c:', 'a'])) + check(['c:/a'], ('c:', '\\', ['c:\\', 'a'])) + check(['/a'], ('', '\\', ['\\', 'a'])) + # UNC paths. + check(['//a/b'], ('\\\\a\\b', '\\', ['\\\\a\\b\\'])) + check(['//a/b/'], ('\\\\a\\b', '\\', ['\\\\a\\b\\'])) + check(['//a/b/c'], ('\\\\a\\b', '\\', ['\\\\a\\b\\', 'c'])) + # Second part is anchored, so that the first part is ignored. + check(['a', 'Z:b', 'c'], ('Z:', '', ['Z:', 'b', 'c'])) + check(['a', 'Z:/b', 'c'], ('Z:', '\\', ['Z:\\', 'b', 'c'])) + # UNC paths. + check(['a', '//b/c', 'd'], ('\\\\b\\c', '\\', ['\\\\b\\c\\', 'd'])) + # Collapsing and stripping excess slashes. + check(['a', 'Z://b//c/', 'd/'], ('Z:', '\\', ['Z:\\', 'b', 'c', 'd'])) + # UNC paths. + check(['a', '//b/c//', 'd'], ('\\\\b\\c', '\\', ['\\\\b\\c\\', 'd'])) + # Extended paths. + check(['//?/c:/'], ('\\\\?\\c:', '\\', ['\\\\?\\c:\\'])) + check(['//?/c:/a'], ('\\\\?\\c:', '\\', ['\\\\?\\c:\\', 'a'])) + check(['//?/c:/a', '/b'], ('\\\\?\\c:', '\\', ['\\\\?\\c:\\', 'b'])) + # Extended UNC paths (format is "\\?\UNC\server\share"). + check(['//?/UNC/b/c'], ('\\\\?\\UNC\\b\\c', '\\', ['\\\\?\\UNC\\b\\c\\'])) + check(['//?/UNC/b/c/d'], ('\\\\?\\UNC\\b\\c', '\\', ['\\\\?\\UNC\\b\\c\\', 'd'])) + # Second part has a root but not drive. + check(['a', '/b', 'c'], ('', '\\', ['\\', 'b', 'c'])) + check(['Z:/a', '/b', 'c'], ('Z:', '\\', ['Z:\\', 'b', 'c'])) + check(['//?/Z:/a', '/b', 'c'], ('\\\\?\\Z:', '\\', ['\\\\?\\Z:\\', 'b', 'c'])) + + def test_splitroot(self): + f = self.cls._splitroot + self.assertEqual(f(''), ('', '', '')) + self.assertEqual(f('a'), ('', '', 'a')) + self.assertEqual(f('a\\b'), ('', '', 'a\\b')) + self.assertEqual(f('\\a'), ('', '\\', 'a')) + self.assertEqual(f('\\a\\b'), ('', '\\', 'a\\b')) + self.assertEqual(f('c:a\\b'), ('c:', '', 'a\\b')) + self.assertEqual(f('c:\\a\\b'), ('c:', '\\', 'a\\b')) + # Redundant slashes in the root are collapsed. + self.assertEqual(f('\\\\a'), ('', '\\', 'a')) + self.assertEqual(f('\\\\\\a/b'), ('', '\\', 'a/b')) + self.assertEqual(f('c:\\\\a'), ('c:', '\\', 'a')) + self.assertEqual(f('c:\\\\\\a/b'), ('c:', '\\', 'a/b')) + # Valid UNC paths. + self.assertEqual(f('\\\\a\\b'), ('\\\\a\\b', '\\', '')) + self.assertEqual(f('\\\\a\\b\\'), ('\\\\a\\b', '\\', '')) + self.assertEqual(f('\\\\a\\b\\c\\d'), ('\\\\a\\b', '\\', 'c\\d')) + # These are non-UNC paths (according to ntpath.py and test_ntpath). + # However, command.com says such paths are invalid, so it's + # difficult to know what the right semantics are. + self.assertEqual(f('\\\\\\a\\b'), ('', '\\', 'a\\b')) + self.assertEqual(f('\\\\a'), ('', '\\', 'a')) + def test_str(self): p = self.cls('a/b/c') self.assertEqual(str(p), 'a\\b\\c') diff --git a/Misc/NEWS.d/next/Library/2022-03-05-02-14-09.bpo-24132.W6iORO.rst b/Misc/NEWS.d/next/Library/2022-03-05-02-14-09.bpo-24132.W6iORO.rst new file mode 100644 index 00000000000000..6a3dc570137cdd --- /dev/null +++ b/Misc/NEWS.d/next/Library/2022-03-05-02-14-09.bpo-24132.W6iORO.rst @@ -0,0 +1,2 @@ +Instantiating a direct subclass of :class:`pathlib.Path` raised +:exc:`AttributeError`. From 8431d1214140a9a1ab02b178bdf84bf083dd2425 Mon Sep 17 00:00:00 2001 From: barneygale Date: Sat, 5 Mar 2022 02:25:54 +0000 Subject: [PATCH 02/44] Add tests --- Lib/test/test_pathlib.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Lib/test/test_pathlib.py b/Lib/test/test_pathlib.py index 9814df9a52282c..8d7fddb0110690 100644 --- a/Lib/test/test_pathlib.py +++ b/Lib/test/test_pathlib.py @@ -2734,6 +2734,12 @@ def check(): check() + +class PathSubclassTest(_BasePathTest, unittest.TestCase): + class cls(pathlib.Path): + pass + + class CompatiblePathTest(unittest.TestCase): """ Test that a type can be made compatible with PurePath From bf2ad3aead98c85b181c5cae2541135e0f110523 Mon Sep 17 00:00:00 2001 From: barneygale Date: Sat, 5 Mar 2022 02:48:57 +0000 Subject: [PATCH 03/44] Add tests for PurePath subclasses --- Lib/test/test_pathlib.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Lib/test/test_pathlib.py b/Lib/test/test_pathlib.py index 8d7fddb0110690..bcf79289692e33 100644 --- a/Lib/test/test_pathlib.py +++ b/Lib/test/test_pathlib.py @@ -191,6 +191,10 @@ def test_repr_common(self): self.assertTrue(r.endswith(')'), r) inner = r[len(clsname) + 1 : -1] self.assertEqual(eval(inner), p.as_posix()) + + if self.cls.__name__ == 'cls': + continue + # The repr() roundtrips. q = eval(r, pathlib.__dict__) self.assertIs(q.__class__, p.__class__) @@ -2734,6 +2738,10 @@ def check(): check() +class PurePathSubclassTest(_BasePurePathTest, unittest.TestCase): + class cls(pathlib.PurePath): + pass + class PathSubclassTest(_BasePathTest, unittest.TestCase): class cls(pathlib.Path): From 4036e2e955cc9720447f7b3700a2b9b755e32cf2 Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Sat, 5 Mar 2022 20:17:18 +0000 Subject: [PATCH 04/44] Update Misc/NEWS.d/next/Library/2022-03-05-02-14-09.bpo-24132.W6iORO.rst Co-authored-by: Alex Waygood --- .../next/Library/2022-03-05-02-14-09.bpo-24132.W6iORO.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Misc/NEWS.d/next/Library/2022-03-05-02-14-09.bpo-24132.W6iORO.rst b/Misc/NEWS.d/next/Library/2022-03-05-02-14-09.bpo-24132.W6iORO.rst index 6a3dc570137cdd..88f91a80c7e005 100644 --- a/Misc/NEWS.d/next/Library/2022-03-05-02-14-09.bpo-24132.W6iORO.rst +++ b/Misc/NEWS.d/next/Library/2022-03-05-02-14-09.bpo-24132.W6iORO.rst @@ -1,2 +1,2 @@ -Instantiating a direct subclass of :class:`pathlib.Path` raised -:exc:`AttributeError`. +Make :class:`pathlib.Path` subclassable. Previously, attempting to subclass ``Path`` +resulted in an :exc:`AttributeError`. Patch by Barney Gale. From 0d08533ca9c4938873ff826ea098cacef0d2374d Mon Sep 17 00:00:00 2001 From: barneygale Date: Sat, 5 Mar 2022 20:34:48 +0000 Subject: [PATCH 05/44] Rename `PurePath._pathmod` to `PurePath._flavour` to reduce the diff. --- Lib/pathlib.py | 60 ++++++++++++++++++++-------------------- Lib/test/test_pathlib.py | 4 +-- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 0be2ee9169e9ea..5007520bd1fda6 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -240,7 +240,7 @@ class PurePath(object): '_drv', '_root', '_parts', '_str', '_hash', '_pparts', '_cached_cparts', ) - _pathmod = os.path + _flavour = os.path def __new__(cls, *args): """Construct a PurePath from one or several strings and or existing @@ -259,15 +259,15 @@ def __reduce__(self): @classmethod def _casefold(cls, s): - return cls._pathmod.normcase(s) + return cls._flavour.normcase(s) @classmethod def _casefold_parts(cls, parts): - return [cls._pathmod.normcase(p) for p in parts] + return [cls._flavour.normcase(p) for p in parts] @classmethod def _compile_pattern(cls, pattern): - flags = 0 if cls._pathmod is posixpath else re.IGNORECASE + flags = 0 if cls._flavour is posixpath else re.IGNORECASE return re.compile(fnmatch.translate(pattern), flags).fullmatch @classmethod @@ -284,8 +284,8 @@ def _split_extended_path(cls, s): @classmethod def _splitroot(cls, part): # FIXME: This method should use os.path.splitdrive() - sep = cls._pathmod.sep - if cls._pathmod is posixpath: + sep = cls._flavour.sep + if cls._flavour is posixpath: if part and part[0] == sep: stripped_part = part.lstrip(sep) # According to POSIX path resolution: @@ -341,8 +341,8 @@ def _splitroot(cls, part): @classmethod def _parse_parts(cls, parts): parsed = [] - sep = cls._pathmod.sep - altsep = cls._pathmod.altsep + sep = cls._flavour.sep + altsep = cls._flavour.altsep drv = root = '' it = reversed(parts) for part in it: @@ -419,9 +419,9 @@ def _from_parsed_parts(cls, drv, root, parts): @classmethod def _format_parsed_parts(cls, drv, root, parts): if drv or root: - return drv + root + cls._pathmod.sep.join(parts[1:]) + return drv + root + cls._flavour.sep.join(parts[1:]) else: - return cls._pathmod.sep.join(parts) + return cls._flavour.sep.join(parts) def _join_parsed_parts(self, drv2, root2, parts2): """ @@ -462,8 +462,8 @@ def __fspath__(self): def as_posix(self): """Return the string representation of the path with forward (/) slashes.""" - m = self._pathmod - return str(self).replace(m.sep, '/') + f = self._flavour + return str(self).replace(f.sep, '/') def __bytes__(self): """Return the bytes representation of the path. This is only @@ -479,7 +479,7 @@ def as_uri(self): raise ValueError("relative path can't be expressed as a file URI") # FIXME: move this implementation to os.path.fileuri() - if self._pathmod is posixpath: + if self._flavour is posixpath: # On POSIX we represent the path using the local filesystem encoding, # for portability to other applications. return 'file://' + urlquote_from_bytes(bytes(self)) @@ -507,7 +507,7 @@ def _cparts(self): def __eq__(self, other): if not isinstance(other, PurePath): return NotImplemented - return self._cparts == other._cparts and self._pathmod is other._pathmod + return self._cparts == other._cparts and self._flavour is other._flavour def __hash__(self): try: @@ -517,22 +517,22 @@ def __hash__(self): return self._hash def __lt__(self, other): - if not isinstance(other, PurePath) or self._pathmod is not other._pathmod: + if not isinstance(other, PurePath) or self._flavour is not other._flavour: return NotImplemented return self._cparts < other._cparts def __le__(self, other): - if not isinstance(other, PurePath) or self._pathmod is not other._pathmod: + if not isinstance(other, PurePath) or self._flavour is not other._flavour: return NotImplemented return self._cparts <= other._cparts def __gt__(self, other): - if not isinstance(other, PurePath) or self._pathmod is not other._pathmod: + if not isinstance(other, PurePath) or self._flavour is not other._flavour: return NotImplemented return self._cparts > other._cparts def __ge__(self, other): - if not isinstance(other, PurePath) or self._pathmod is not other._pathmod: + if not isinstance(other, PurePath) or self._flavour is not other._flavour: return NotImplemented return self._cparts >= other._cparts @@ -598,7 +598,7 @@ def with_name(self, name): if not self.name: raise ValueError("%r has an empty name" % (self,)) drv, root, parts = self._parse_parts((name,)) - if (not name or name[-1] in [self._pathmod.sep, self._pathmod.altsep] + if (not name or name[-1] in [self._flavour.sep, self._flavour.altsep] or drv or root or len(parts) != 1): raise ValueError("Invalid name %r" % (name)) return self._from_parsed_parts(self._drv, self._root, @@ -613,8 +613,8 @@ 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. """ - m = self._pathmod - if m.sep in suffix or m.altsep and m.altsep in suffix: + 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 == '.': raise ValueError("Invalid suffix %r" % (suffix)) @@ -723,13 +723,13 @@ def is_absolute(self): a drive).""" if not self._root: return False - return self._pathmod is posixpath or bool(self._drv) + return self._flavour is posixpath or bool(self._drv) def is_reserved(self): """Return True if the path contains one of the special names reserved by the system, if any.""" # FIXME: move this implementation to os.path.isreserved() - if self._pathmod is posixpath or not self._parts: + if self._flavour is posixpath or not self._parts: return False # NOTE: the rules for reserved names seem somewhat complicated @@ -778,7 +778,7 @@ class PurePosixPath(PurePath): On a POSIX system, instantiating a PurePath should return this object. However, you can also instantiate it directly on any system. """ - _pathmod = posixpath + _flavour = posixpath __slots__ = () @@ -788,7 +788,7 @@ class PureWindowsPath(PurePath): On a Windows system, instantiating a PurePath should return this object. However, you can also instantiate it directly on any system. """ - _pathmod = ntpath + _flavour = ntpath __slots__ = () @@ -809,7 +809,7 @@ class Path(PurePath): def __new__(cls, *args, **kwargs): if cls is Path: cls = WindowsPath if os.name == 'nt' else PosixPath - elif cls._pathmod is not os.path: + elif cls._flavour is not os.path: raise NotImplementedError("cannot instantiate %r on your system" % (cls.__name__,)) return cls._from_parts(args) @@ -862,7 +862,7 @@ def samefile(self, other_path): other_st = other_path.stat() except AttributeError: other_st = self.__class__(other_path).stat() - return self._pathmod.samestat(st, other_st) + return self._flavour.samestat(st, other_st) def iterdir(self): """Iterate over the files in this directory. Does not yield any @@ -926,7 +926,7 @@ def check_eloop(e): raise RuntimeError("Symlink loop from %r" % e.filename) try: - s = self._pathmod.realpath(self, strict=strict) + s = self._flavour.realpath(self, strict=strict) except OSError as e: check_eloop(e) raise @@ -1215,7 +1215,7 @@ def is_mount(self): """ Check if this path is a POSIX mount point """ - if self._pathmod is not posixpath: + if self._flavour is not posixpath: raise NotImplementedError("Path.is_mount() is unsupported on this system") # Need to exist and be a dir @@ -1319,7 +1319,7 @@ def expanduser(self): """ if (not (self._drv or self._root) and self._parts and self._parts[0][:1] == '~'): - homedir = self._pathmod.expanduser(self._parts[0]) + homedir = self._flavour.expanduser(self._parts[0]) if homedir[:1] == "~": raise RuntimeError("Could not determine home directory.") return self._from_parts([homedir] + self._parts[1:]) diff --git a/Lib/test/test_pathlib.py b/Lib/test/test_pathlib.py index bcf79289692e33..70e789b193a957 100644 --- a/Lib/test/test_pathlib.py +++ b/Lib/test/test_pathlib.py @@ -46,8 +46,8 @@ class _BasePurePathTest(object): } def setUp(self): - self.sep = self.cls._pathmod.sep - self.altsep = self.cls._pathmod.altsep + self.sep = self.cls._flavour.sep + self.altsep = self.cls._flavour.altsep def _check_parse_parts(self, arg, expected): f = self.cls._parse_parts From 28992fc69d90afe67b99ba967ea28250d565af30 Mon Sep 17 00:00:00 2001 From: barneygale Date: Fri, 25 Mar 2022 04:39:31 +0000 Subject: [PATCH 06/44] Undo an unnecessary change to `Path.__new__()` --- Lib/pathlib.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 5007520bd1fda6..8e04368f8ccf80 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -809,10 +809,11 @@ class Path(PurePath): def __new__(cls, *args, **kwargs): if cls is Path: cls = WindowsPath if os.name == 'nt' else PosixPath - elif cls._flavour is not os.path: + self = cls._from_parts(args) + if self._flavour is not os.path: raise NotImplementedError("cannot instantiate %r on your system" % (cls.__name__,)) - return cls._from_parts(args) + return self def _make_child_relpath(self, part): # This is an optimization used for dir walking. `part` must be From dbec2300fe7010282b78a64284b08073d35ec6d4 Mon Sep 17 00:00:00 2001 From: barneygale Date: Fri, 25 Mar 2022 04:42:49 +0000 Subject: [PATCH 07/44] Remove `_casefold()` and `_casefold_parts()` methods. Makes the code a little more direct. --- Lib/pathlib.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 8e04368f8ccf80..3b18dec40974e6 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -257,14 +257,6 @@ def __reduce__(self): # when pickling related paths. return (self.__class__, tuple(self._parts)) - @classmethod - def _casefold(cls, s): - return cls._flavour.normcase(s) - - @classmethod - def _casefold_parts(cls, parts): - return [cls._flavour.normcase(p) for p in parts] - @classmethod def _compile_pattern(cls, pattern): flags = 0 if cls._flavour is posixpath else re.IGNORECASE @@ -433,7 +425,7 @@ def _join_parsed_parts(self, drv2, root2, parts2): if not drv2 and drv: return drv, root2, [drv + root2] + parts2[1:] elif drv2: - if drv2 == drv or self._casefold(drv2) == self._casefold(drv): + if drv2 == drv or self._flavour.normcase(drv2) == self._flavour.normcase(drv): # Same drive => second path is relative to the first return drv, root, parts + parts2[1:] else: @@ -501,7 +493,7 @@ def _cparts(self): try: return self._cached_cparts except AttributeError: - self._cached_cparts = self._casefold_parts(self._parts) + self._cached_cparts = [self._flavour.normcase(p) for p in self._parts] return self._cached_cparts def __eq__(self, other): @@ -653,7 +645,8 @@ def relative_to(self, *other): else: to_abs_parts = to_parts n = len(to_abs_parts) - cf = self._casefold_parts + def cf(parts): + return [self._flavour.normcase(p) for p in parts] if (root or drv) if n == 0 else cf(abs_parts[:n]) != cf(to_abs_parts): formatted = self._format_parsed_parts(to_drv, to_root, to_parts) raise ValueError("{!r} is not in the subpath of {!r}" @@ -746,7 +739,7 @@ def match(self, path_pattern): """ Return True if this path matches the given pattern. """ - cf = self._casefold + cf = self._flavour.normcase path_pattern = cf(path_pattern) drv, root, pat_parts = self._parse_parts((path_pattern,)) if not pat_parts: From 63f1d6834f7087881c3704ae2fa6a2842cdf3815 Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Fri, 29 Apr 2022 20:31:38 +0100 Subject: [PATCH 08/44] Further simplify overall diff by inlining code from `compile_pattern()` --- Lib/pathlib.py | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 7ae38f56e84934..91380ab9cf2b0d 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -57,7 +57,7 @@ def _is_wildcard_pattern(pat): # Globbing helpers # -def _make_selector(pattern_parts, path_cls): +def _make_selector(pattern_parts, flavour): pat = pattern_parts[0] child_parts = pattern_parts[1:] if not pat: @@ -70,7 +70,7 @@ def _make_selector(pattern_parts, path_cls): cls = _WildcardSelector else: cls = _PreciseSelector - return cls(pat, child_parts, path_cls) + return cls(pat, child_parts, flavour) if hasattr(functools, "lru_cache"): _make_selector = functools.lru_cache()(_make_selector) @@ -80,10 +80,10 @@ class _Selector: """A selector matches a specific glob pattern part against the children of a given path.""" - def __init__(self, child_parts, path_cls): + def __init__(self, child_parts, flavour): self.child_parts = child_parts if child_parts: - self.successor = _make_selector(child_parts, path_cls) + self.successor = _make_selector(child_parts, flavour) self.dironly = True else: self.successor = _TerminatingSelector() @@ -109,9 +109,9 @@ def _select_from(self, parent_path, is_dir, exists, scandir): class _PreciseSelector(_Selector): - def __init__(self, name, child_parts, path_cls): + def __init__(self, name, child_parts, flavour): self.name = name - _Selector.__init__(self, child_parts, path_cls) + _Selector.__init__(self, child_parts, flavour) def _select_from(self, parent_path, is_dir, exists, scandir): try: @@ -125,9 +125,10 @@ def _select_from(self, parent_path, is_dir, exists, scandir): class _WildcardSelector(_Selector): - def __init__(self, pat, child_parts, path_cls): - self.match = path_cls._compile_pattern(pat) - _Selector.__init__(self, child_parts, path_cls) + def __init__(self, pat, child_parts, flavour): + flags = 0 if flavour is posixpath else re.IGNORECASE + self.match = re.compile(fnmatch.translate(pat), flags).fullmatch + _Selector.__init__(self, child_parts, flavour) def _select_from(self, parent_path, is_dir, exists, scandir): try: @@ -156,8 +157,8 @@ def _select_from(self, parent_path, is_dir, exists, scandir): class _RecursiveWildcardSelector(_Selector): - def __init__(self, pat, child_parts, path_cls): - _Selector.__init__(self, child_parts, path_cls) + def __init__(self, pat, child_parts, flavour): + _Selector.__init__(self, child_parts, flavour) def _iterate_directories(self, parent_path, is_dir, scandir): yield parent_path @@ -259,11 +260,6 @@ def __reduce__(self): # when pickling related paths. return (self.__class__, tuple(self._parts)) - @classmethod - def _compile_pattern(cls, pattern): - flags = 0 if cls._flavour is posixpath else re.IGNORECASE - return re.compile(fnmatch.translate(pattern), flags).fullmatch - @classmethod def _split_extended_path(cls, s): prefix = '' @@ -885,7 +881,7 @@ def glob(self, pattern): raise NotImplementedError("Non-relative patterns are unsupported") if pattern[-1] in (self._flavour.sep, self._flavour.altsep): pattern_parts.append('') - selector = _make_selector(tuple(pattern_parts), type(self)) + selector = _make_selector(tuple(pattern_parts), self._flavour) for p in selector.select_from(self): yield p @@ -900,7 +896,7 @@ def rglob(self, pattern): raise NotImplementedError("Non-relative patterns are unsupported") if pattern[-1] in (self._flavour.sep, self._flavour.altsep): pattern_parts.append('') - selector = _make_selector(("**",) + tuple(pattern_parts), type(self)) + selector = _make_selector(("**",) + tuple(pattern_parts), self._flavour) for p in selector.select_from(self): yield p From 8dddfdf995791e1cc081078cb73b79f2f35488f9 Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Fri, 29 Apr 2022 23:35:44 +0100 Subject: [PATCH 09/44] Apply suggestions from code review Co-authored-by: Brett Cannon --- Lib/pathlib.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 91380ab9cf2b0d..b66c73616b0113 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -24,7 +24,7 @@ # _WIN_EXT_NAMESPACE_PREFIX = '\\\\?\\' -_WIN_DRIVE_LETTERS = set('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ') +_WIN_DRIVE_LETTERS = frozenset(string.ascii_letters) _WIN_RESERVED_NAMES = ( {'CON', 'PRN', 'AUX', 'NUL', 'CONIN$', 'CONOUT$'} | {'COM%s' % c for c in '123456789\xb9\xb2\xb3'} | @@ -301,7 +301,7 @@ def _splitroot(cls, part): else: prefix = '' third = part[2:3] - if (second == sep and first == sep and third != sep): + if second == sep and first == sep and third != sep: # is a UNC path: # vvvvvvvvvvvvvvvvvvvvv root # \\machine\mountpoint\directory\etc\... From b4da721300395af003f1237d071c7e14bfb1925a Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Fri, 29 Apr 2022 23:46:12 +0100 Subject: [PATCH 10/44] Address a couple bits of review feedback --- Lib/pathlib.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index b66c73616b0113..98597ba7366b69 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -23,7 +23,6 @@ # Internals # -_WIN_EXT_NAMESPACE_PREFIX = '\\\\?\\' _WIN_DRIVE_LETTERS = frozenset(string.ascii_letters) _WIN_RESERVED_NAMES = ( {'CON', 'PRN', 'AUX', 'NUL', 'CONIN$', 'CONOUT$'} | @@ -126,7 +125,7 @@ def _select_from(self, parent_path, is_dir, exists, scandir): class _WildcardSelector(_Selector): def __init__(self, pat, child_parts, flavour): - flags = 0 if flavour is posixpath else re.IGNORECASE + flags = re.NOFLAG if flavour is posixpath else re.IGNORECASE self.match = re.compile(fnmatch.translate(pat), flags).fullmatch _Selector.__init__(self, child_parts, flavour) @@ -263,7 +262,7 @@ def __reduce__(self): @classmethod def _split_extended_path(cls, s): prefix = '' - if s.startswith(_WIN_EXT_NAMESPACE_PREFIX): + if s.startswith('\\\\?\\'): prefix = s[:4] s = s[4:] if s.startswith('UNC\\'): From 7a166fdb493d59b20d3b323cfb9e1b93930c4261 Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Fri, 29 Apr 2022 23:57:42 +0100 Subject: [PATCH 11/44] Fix tests --- Lib/pathlib.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 98597ba7366b69..39ff8af3af382d 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -5,6 +5,7 @@ import os import posixpath import re +import string import sys import warnings from _collections_abc import Sequence From 1f4dff11b267ef0f94f0930a8ece44bccea0df6f Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Sat, 30 Apr 2022 00:04:00 +0100 Subject: [PATCH 12/44] Fix docstring formatting. --- Lib/pathlib.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 39ff8af3af382d..39dfe4eb209ad7 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -414,8 +414,7 @@ def _format_parsed_parts(cls, drv, root, parts): return cls._flavour.sep.join(parts) def _join_parsed_parts(self, drv2, root2, parts2): - """ - Join the two paths represented by the respective + """Join the two paths represented by the respective (drive, root, parts) tuples. Return a new (drive, root, parts) tuple. """ drv, root, parts = self._drv, self._root, self._parts From 51893c11c5e794f1072acbe57b0ae921a431c33d Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Sat, 30 Apr 2022 16:47:34 +0100 Subject: [PATCH 13/44] Remove FIXME comments --- Lib/pathlib.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 39dfe4eb209ad7..5c11b2f70a48e5 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -273,7 +273,6 @@ def _split_extended_path(cls, s): @classmethod def _splitroot(cls, part): - # FIXME: This method should use os.path.splitdrive() sep = cls._flavour.sep if cls._flavour is posixpath: if part and part[0] == sep: @@ -467,7 +466,6 @@ def as_uri(self): if not self.is_absolute(): raise ValueError("relative path can't be expressed as a file URI") - # FIXME: move this implementation to os.path.fileuri() if self._flavour is posixpath: # On POSIX we represent the path using the local filesystem encoding, # for portability to other applications. @@ -718,7 +716,6 @@ def is_absolute(self): def is_reserved(self): """Return True if the path contains one of the special names reserved by the system, if any.""" - # FIXME: move this implementation to os.path.isreserved() if self._flavour is posixpath or not self._parts: return False From 3624c974507457b00bc7a24531fcca2f0961ea55 Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Tue, 3 May 2022 18:30:36 +0100 Subject: [PATCH 14/44] Update Lib/pathlib.py Co-authored-by: Alex Waygood --- Lib/pathlib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 5c11b2f70a48e5..ba44b0382b990d 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -25,7 +25,7 @@ # _WIN_DRIVE_LETTERS = frozenset(string.ascii_letters) -_WIN_RESERVED_NAMES = ( +_WIN_RESERVED_NAMES = frozenset( {'CON', 'PRN', 'AUX', 'NUL', 'CONIN$', 'CONOUT$'} | {'COM%s' % c for c in '123456789\xb9\xb2\xb3'} | {'LPT%s' % c for c in '123456789\xb9\xb2\xb3'} From ab54c14661b0c98d79be140e2be4ac718b05568f Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Tue, 31 May 2022 16:14:25 +0100 Subject: [PATCH 15/44] Remove vestigal 'casefold' names. --- Lib/pathlib.py | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index ba44b0382b990d..9f82220f096cd7 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -241,7 +241,7 @@ class PurePath(object): """ __slots__ = ( '_drv', '_root', '_parts', - '_str', '_hash', '_pparts', '_cached_cparts', + '_str', '_hash', '_pparts', '_cached_ncparts', ) _flavour = os.path @@ -483,45 +483,45 @@ def as_uri(self): return 'file:' + urlquote_from_bytes(self.as_posix().encode('utf-8')) @property - def _cparts(self): - # Cached casefolded parts, for hashing and comparison + def _ncparts(self): + # Cached normcased parts, for hashing and comparison try: - return self._cached_cparts + return self._cached_ncparts except AttributeError: - self._cached_cparts = [self._flavour.normcase(p) for p in self._parts] - return self._cached_cparts + self._cached_ncparts = [self._flavour.normcase(p) for p in self._parts] + return self._cached_ncparts def __eq__(self, other): if not isinstance(other, PurePath): return NotImplemented - return self._cparts == other._cparts and self._flavour is other._flavour + return self._ncparts == other._ncparts and self._flavour is other._flavour def __hash__(self): try: return self._hash except AttributeError: - self._hash = hash(tuple(self._cparts)) + self._hash = hash(tuple(self._ncparts)) return self._hash def __lt__(self, other): if not isinstance(other, PurePath) or self._flavour is not other._flavour: return NotImplemented - return self._cparts < other._cparts + return self._ncparts < other._ncparts def __le__(self, other): if not isinstance(other, PurePath) or self._flavour is not other._flavour: return NotImplemented - return self._cparts <= other._cparts + return self._ncparts <= other._ncparts def __gt__(self, other): if not isinstance(other, PurePath) or self._flavour is not other._flavour: return NotImplemented - return self._cparts > other._cparts + return self._ncparts > other._ncparts def __ge__(self, other): if not isinstance(other, PurePath) or self._flavour is not other._flavour: return NotImplemented - return self._cparts >= other._cparts + return self._ncparts >= other._ncparts drive = property(attrgetter('_drv'), doc="""The drive prefix (letter or UNC path), if any.""") @@ -640,9 +640,9 @@ def relative_to(self, *other): else: to_abs_parts = to_parts n = len(to_abs_parts) - def cf(parts): + def nc(parts): return [self._flavour.normcase(p) for p in parts] - if (root or drv) if n == 0 else cf(abs_parts[:n]) != cf(to_abs_parts): + if (root or drv) if n == 0 else nc(abs_parts[:n]) != nc(to_abs_parts): formatted = self._format_parsed_parts(to_drv, to_root, to_parts) raise ValueError("{!r} is not in the subpath of {!r}" " OR one path is relative and the other is absolute." @@ -733,16 +733,16 @@ def match(self, path_pattern): """ Return True if this path matches the given pattern. """ - cf = self._flavour.normcase - path_pattern = cf(path_pattern) + nc = self._flavour.normcase + path_pattern = nc(path_pattern) drv, root, pat_parts = self._parse_parts((path_pattern,)) if not pat_parts: raise ValueError("empty pattern") - if drv and drv != cf(self._drv): + if drv and drv != nc(self._drv): return False - if root and root != cf(self._root): + if root and root != nc(self._root): return False - parts = self._cparts + parts = self._ncparts if drv or root: if len(pat_parts) != len(parts): return False From bc7aded29a523bdd580d7f329ad88d89131f9695 Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Fri, 10 Jun 2022 19:29:13 +0100 Subject: [PATCH 16/44] Restore comment on POSIX paths beginning `//` and tweak implementation. --- Lib/pathlib.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 2d85b5834207ee..e22a7ec914f650 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -274,11 +274,15 @@ def _parse_parts(cls, parts): if altsep: part = part.replace(altsep, sep) drv, rel = cls._flavour.splitdrive(part) - if drv[:1] == sep or rel[:1] == sep: - root = sep - if cls._flavour is posixpath and len(rel) - len(rel.lstrip(sep)) == 2: - root = sep * 2 - rel = rel.lstrip(sep) + # According to POSIX path resolution: + # http://pubs.opengroup.org/onlinepubs/009695399/basedefs/xbd_chap04.html#tag_04_11 + # "A pathname that begins with two successive slashes may be + # interpreted in an implementation-defined manner, although more + # than two leading slashes shall be treated as a single slash". + if cls._flavour is posixpath and rel[:2] == sep * 2 and rel[2:3] != sep: + root, rel = sep * 2, rel[2:] + elif rel[:1] == sep or drv[:1] == sep: + root, rel = sep, rel.lstrip(sep) if sep in rel: for x in reversed(rel.split(sep)): if x and x != '.': From 4d6e4f3ee6d6017a59cfc0ea8a9e27653c28332e Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Sun, 12 Jun 2022 09:37:54 +0100 Subject: [PATCH 17/44] Inline `_join_parsed_parts()` and `_make_child()` in `joinpath()`. --- Lib/pathlib.py | 38 ++++++++++++++------------------------ 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index e22a7ec914f650..0a60156f92a61f 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -355,28 +355,6 @@ def _format_parsed_parts(cls, drv, root, parts): else: return cls._flavour.sep.join(parts) - def _join_parsed_parts(self, drv2, root2, parts2): - """Join the two paths represented by the respective - (drive, root, parts) tuples. Return a new (drive, root, parts) tuple. - """ - drv, root, parts = self._drv, self._root, self._parts - if root2: - if not drv2 and drv: - return drv, root2, [drv + root2] + parts2[1:] - elif drv2: - if drv2 == drv or self._flavour.normcase(drv2) == self._flavour.normcase(drv): - # Same drive => second path is relative to the first - return drv, root, parts + parts2[1:] - else: - # Second path is non-anchored (common case) - return drv, root, parts + parts2 - return drv2, root2, parts2 - - def _make_child(self, args): - drv, root, parts = self._parse_args(args) - drv, root, parts = self._join_parsed_parts(drv, root, parts) - return self._from_parsed_parts(drv, root, parts) - def __str__(self): """Return the string representation of the path, suitable for passing to system calls.""" @@ -620,11 +598,23 @@ def joinpath(self, *args): paths) or a totally different path (if one of the arguments is anchored). """ - return self._make_child(args) + drv1, root1, parts1 = self._drv, self._root, self._parts + drv2, root2, parts2 = self._parse_args(args) + if root2: + if not drv2 and drv1: + return self._from_parsed_parts(drv1, root2, [drv1 + root2] + parts2[1:]) + elif drv2: + if drv2 == drv1 or self._flavour.normcase(drv2) == self._flavour.normcase(drv1): + # Same drive => second path is relative to the first + return self._from_parsed_parts(drv1, root1, parts1 + parts2[1:]) + else: + # Second path is non-anchored (common case) + return self._from_parsed_parts(drv1, root1, parts1 + parts2) + return self._from_parsed_parts(drv2, root2, parts2) def __truediv__(self, key): try: - return self._make_child((key,)) + return self.joinpath(key) except TypeError: return NotImplemented From 70ca8388163f876c6f2d4aa491dd8d504aca7bb4 Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Mon, 13 Jun 2022 07:14:06 +0100 Subject: [PATCH 18/44] Update Lib/pathlib.py Co-authored-by: Adam Turner <9087854+AA-Turner@users.noreply.github.com> --- Lib/pathlib.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 0a60156f92a61f..f3f28fc99706c1 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -26,8 +26,8 @@ _WIN_RESERVED_NAMES = frozenset( {'CON', 'PRN', 'AUX', 'NUL', 'CONIN$', 'CONOUT$'} | - {'COM%s' % c for c in '123456789\xb9\xb2\xb3'} | - {'LPT%s' % c for c in '123456789\xb9\xb2\xb3'} + {f'COM{c}' for c in '123456789\xb9\xb2\xb3'} | + {f'LPT{c}' for c in '123456789\xb9\xb2\xb3'} ) _WINERROR_NOT_READY = 21 # drive exists but is not accessible From a2cc74f3dd9b28baee80e77cda8517566d74758e Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Thu, 16 Jun 2022 16:32:04 +0100 Subject: [PATCH 19/44] Remove unused import --- Lib/pathlib.py | 1 - 1 file changed, 1 deletion(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index f3f28fc99706c1..bd86403c8eabd7 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -5,7 +5,6 @@ import os import posixpath import re -import string import sys import warnings from _collections_abc import Sequence From 0e2478bbac3c60e7b3d116d0fb3b69f11da49476 Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Fri, 17 Jun 2022 22:38:00 +0100 Subject: [PATCH 20/44] Make NEWS entry more precise. --- .../next/Library/2022-03-05-02-14-09.bpo-24132.W6iORO.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Misc/NEWS.d/next/Library/2022-03-05-02-14-09.bpo-24132.W6iORO.rst b/Misc/NEWS.d/next/Library/2022-03-05-02-14-09.bpo-24132.W6iORO.rst index 88f91a80c7e005..065c2f912db5fd 100644 --- a/Misc/NEWS.d/next/Library/2022-03-05-02-14-09.bpo-24132.W6iORO.rst +++ b/Misc/NEWS.d/next/Library/2022-03-05-02-14-09.bpo-24132.W6iORO.rst @@ -1,2 +1,3 @@ -Make :class:`pathlib.Path` subclassable. Previously, attempting to subclass ``Path`` -resulted in an :exc:`AttributeError`. Patch by Barney Gale. +Make :class:`pathlib.PurePath` and :class:`~pathlib.Path` subclassable. +Previously, attempting to instantiate a subclass resulted in an +:exc:`AttributeError` being raised. Patch by Barney Gale. From 0d0cf6012cd0aae28f923ab99ab05c04f2d78c94 Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Fri, 29 Jul 2022 23:58:50 +0100 Subject: [PATCH 21/44] Apply suggestions from code review Co-authored-by: Brett Cannon --- Lib/pathlib.py | 13 ++++++++----- .../2022-03-05-02-14-09.bpo-24132.W6iORO.rst | 6 +++--- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index bd86403c8eabd7..4b9d53baf4d330 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -270,7 +270,7 @@ def _parse_parts(cls, parts): for part in it: if not part: continue - if altsep: + elif altsep: part = part.replace(altsep, sep) drv, rel = cls._flavour.splitdrive(part) # According to POSIX path resolution: @@ -282,6 +282,7 @@ def _parse_parts(cls, parts): root, rel = sep * 2, rel[2:] elif rel[:1] == sep or drv[:1] == sep: root, rel = sep, rel.lstrip(sep) + if sep in rel: for x in reversed(rel.split(sep)): if x and x != '.': @@ -289,6 +290,7 @@ def _parse_parts(cls, parts): else: if rel and rel != '.': parsed.append(sys.intern(rel)) + if drv or root: if not drv: # If no drive is present, try to find one in the previous @@ -297,11 +299,12 @@ def _parse_parts(cls, parts): for part in it: if not part: continue - if altsep: + elif altsep: part = part.replace(altsep, sep) drv = cls._flavour.splitdrive(part)[0] if drv: break + break if drv or root: parsed.append(drv + root) @@ -404,7 +407,7 @@ def as_uri(self): @property def _ncparts(self): - # Cached normcased parts, for hashing and comparison + # Cached normcased parts, for hashing and comparison. try: return self._cached_ncparts except AttributeError: @@ -670,9 +673,9 @@ def match(self, path_pattern): drv, root, pat_parts = self._parse_parts((path_pattern,)) if not pat_parts: raise ValueError("empty pattern") - if drv and drv != nc(self._drv): + elif drv and drv != nc(self._drv): return False - if root and root != nc(self._root): + elif root and root != nc(self._root): return False parts = self._ncparts if drv or root: diff --git a/Misc/NEWS.d/next/Library/2022-03-05-02-14-09.bpo-24132.W6iORO.rst b/Misc/NEWS.d/next/Library/2022-03-05-02-14-09.bpo-24132.W6iORO.rst index 065c2f912db5fd..8ca5213fb23a01 100644 --- a/Misc/NEWS.d/next/Library/2022-03-05-02-14-09.bpo-24132.W6iORO.rst +++ b/Misc/NEWS.d/next/Library/2022-03-05-02-14-09.bpo-24132.W6iORO.rst @@ -1,3 +1,3 @@ -Make :class:`pathlib.PurePath` and :class:`~pathlib.Path` subclassable. -Previously, attempting to instantiate a subclass resulted in an -:exc:`AttributeError` being raised. Patch by Barney Gale. +Make :class:`pathlib.PurePath` and :class:`~pathlib.Path` subclassable +(private to start). Previously, attempting to instantiate a subclass +resulted in an :exc:`AttributeError` being raised. Patch by Barney Gale. From f3048d312d72e9f1cac381aab8238c702a640161 Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Sat, 30 Jul 2022 00:57:43 +0100 Subject: [PATCH 22/44] Clarify `_pparts` and `_ncparts` naming. - `_pparts` --> `_parts_tuple` - `_ncparts` --> `_parts_normcase` - `_cached_ncparts` --> `_parts_normcase_cached` --- Lib/pathlib.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 4b9d53baf4d330..9cbb8b0e913fb6 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -241,7 +241,7 @@ class PurePath(object): """ __slots__ = ( '_drv', '_root', '_parts', - '_str', '_hash', '_pparts', '_cached_ncparts', + '_str', '_hash', '_parts_tuple', '_parts_normcase_cached', ) _flavour = os.path @@ -406,45 +406,45 @@ def as_uri(self): return 'file:' + urlquote_from_bytes(self.as_posix().encode('utf-8')) @property - def _ncparts(self): - # Cached normcased parts, for hashing and comparison. + def _parts_normcase(self): + # Cached parts with normalized case, for hashing and comparison. try: - return self._cached_ncparts + return self._parts_normcase_cached except AttributeError: - self._cached_ncparts = [self._flavour.normcase(p) for p in self._parts] - return self._cached_ncparts + self._parts_normcase_cached = [self._flavour.normcase(p) for p in self._parts] + return self._parts_normcase_cached def __eq__(self, other): if not isinstance(other, PurePath): return NotImplemented - return self._ncparts == other._ncparts and self._flavour is other._flavour + return self._parts_normcase == other._parts_normcase and self._flavour is other._flavour def __hash__(self): try: return self._hash except AttributeError: - self._hash = hash(tuple(self._ncparts)) + self._hash = hash(tuple(self._parts_normcase)) return self._hash def __lt__(self, other): if not isinstance(other, PurePath) or self._flavour is not other._flavour: return NotImplemented - return self._ncparts < other._ncparts + return self._parts_normcase < other._parts_normcase def __le__(self, other): if not isinstance(other, PurePath) or self._flavour is not other._flavour: return NotImplemented - return self._ncparts <= other._ncparts + return self._parts_normcase <= other._parts_normcase def __gt__(self, other): if not isinstance(other, PurePath) or self._flavour is not other._flavour: return NotImplemented - return self._ncparts > other._ncparts + return self._parts_normcase > other._parts_normcase def __ge__(self, other): if not isinstance(other, PurePath) or self._flavour is not other._flavour: return NotImplemented - return self._ncparts >= other._ncparts + return self._parts_normcase >= other._parts_normcase drive = property(attrgetter('_drv'), doc="""The drive prefix (letter or UNC path), if any.""") @@ -589,10 +589,10 @@ def parts(self): # We cache the tuple to avoid building a new one each time .parts # is accessed. XXX is this necessary? try: - return self._pparts + return self._parts_tuple except AttributeError: - self._pparts = tuple(self._parts) - return self._pparts + self._parts_tuple = tuple(self._parts) + return self._parts_tuple def joinpath(self, *args): """Combine this path with one or several arguments, and return a @@ -677,7 +677,7 @@ def match(self, path_pattern): return False elif root and root != nc(self._root): return False - parts = self._ncparts + parts = self._parts_normcase if drv or root: if len(pat_parts) != len(parts): return False From 8a213ae1d5600d383451950c2a634d9b601d8987 Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Sat, 30 Jul 2022 01:14:35 +0100 Subject: [PATCH 23/44] Tidy up usage of `normcase()` --- Lib/pathlib.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 9cbb8b0e913fb6..075d64beba737c 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -563,9 +563,13 @@ def relative_to(self, *other): else: to_abs_parts = to_parts n = len(to_abs_parts) - def nc(parts): - return [self._flavour.normcase(p) for p in parts] - if (root or drv) if n == 0 else nc(abs_parts[:n]) != nc(to_abs_parts): + if not n: + is_error = root or drv + else: + nc_abs_parts = list(map(self._flavour.normcase, abs_parts[:n])) + nc_to_abs_parts = list(map(self._flavour.normcase, to_abs_parts[:n])) + is_error = nc_abs_parts != nc_to_abs_parts + if is_error: formatted = self._format_parsed_parts(to_drv, to_root, to_parts) raise ValueError("{!r} is not in the subpath of {!r}" " OR one path is relative and the other is absolute." @@ -668,14 +672,13 @@ def match(self, path_pattern): """ Return True if this path matches the given pattern. """ - nc = self._flavour.normcase - path_pattern = nc(path_pattern) + path_pattern = self._flavour.normcase(path_pattern) drv, root, pat_parts = self._parse_parts((path_pattern,)) if not pat_parts: raise ValueError("empty pattern") - elif drv and drv != nc(self._drv): + elif drv and drv != self._flavour.normcase(self._drv): return False - elif root and root != nc(self._root): + elif root and root != self._root: return False parts = self._parts_normcase if drv or root: From 402dafe63d8a11a6916d549ada1918ce962a8653 Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Sat, 30 Jul 2022 16:31:31 +0100 Subject: [PATCH 24/44] Tweak `is_absolute()` to use `os.path.isabs()` wherever possible. --- Lib/pathlib.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 075d64beba737c..831385dfa4eef4 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -648,9 +648,10 @@ def parents(self): def is_absolute(self): """True if the path is absolute (has both a root and, if applicable, a drive).""" - if not self._root: - return False - return self._flavour is posixpath or bool(self._drv) + # ntpath.isabs() is defective - see GH-44626 + if self._flavour is ntpath: + return bool(self._drv and self._root) + return self._flavour.isabs(self) def is_reserved(self): """Return True if the path contains one of the special names reserved From 5cc3ab2eb9a49ad2dfc1bb764cdc02c524b92951 Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Sat, 30 Jul 2022 18:09:14 +0100 Subject: [PATCH 25/44] Stop using `self._flavour` in `as_uri()`. --- Lib/pathlib.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 831385dfa4eef4..36dd8cef67bda9 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -389,21 +389,20 @@ def as_uri(self): if not self.is_absolute(): raise ValueError("relative path can't be expressed as a file URI") - if self._flavour is posixpath: - # On POSIX we represent the path using the local filesystem encoding, - # for portability to other applications. - return 'file://' + urlquote_from_bytes(bytes(self)) - - # Under Windows, file URIs use the UTF-8 encoding. drive = self._drv if len(drive) == 2 and drive[1] == ':': # It's a path on a local drive => 'file:///c:/a/b' - rest = self.as_posix()[2:].lstrip('/') - return 'file:///%s/%s' % ( - drive, urlquote_from_bytes(rest.encode('utf-8'))) - else: + prefix = 'file:///' + drive + path = self.as_posix()[2:] + elif drive: # It's a path on a network drive => 'file://host/share/a/b' - return 'file:' + urlquote_from_bytes(self.as_posix().encode('utf-8')) + prefix = 'file:' + path = self.as_posix() + else: + # It's a posix path => 'file:///etc/hosts' + prefix = 'file://' + path = str(self) + return prefix + urlquote_from_bytes(os.fsencode(path)) @property def _parts_normcase(self): From 19a88045cf9c9bbde393d209fd93e88160bc53c4 Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Sat, 30 Jul 2022 22:18:58 +0100 Subject: [PATCH 26/44] Remove `cls._flavour` identity check in `_parse_parts()`. --- Lib/pathlib.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 36dd8cef67bda9..98be41ba624650 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -272,16 +272,13 @@ def _parse_parts(cls, parts): continue elif altsep: part = part.replace(altsep, sep) - drv, rel = cls._flavour.splitdrive(part) - # According to POSIX path resolution: - # http://pubs.opengroup.org/onlinepubs/009695399/basedefs/xbd_chap04.html#tag_04_11 - # "A pathname that begins with two successive slashes may be - # interpreted in an implementation-defined manner, although more - # than two leading slashes shall be treated as a single slash". - if cls._flavour is posixpath and rel[:2] == sep * 2 and rel[2:3] != sep: - root, rel = sep * 2, rel[2:] - elif rel[:1] == sep or drv[:1] == sep: - root, rel = sep, rel.lstrip(sep) + drv, rest = cls._flavour.splitdrive(part) + rel = rest.lstrip(sep) + if drv[:1] == sep: + root = sep + elif rest[:1] == sep: + root = rest[:len(rest) - len(rel)] + root = cls._flavour.normpath(root) if sep in rel: for x in reversed(rel.split(sep)): From 9da46bc3a874b90ac9d7b6581d5e1ff9481fbc9b Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Sun, 31 Jul 2022 00:20:42 +0100 Subject: [PATCH 27/44] Fix Windows tests --- Lib/pathlib.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 98be41ba624650..f19209c88b9b7d 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -272,13 +272,12 @@ def _parse_parts(cls, parts): continue elif altsep: part = part.replace(altsep, sep) - drv, rest = cls._flavour.splitdrive(part) - rel = rest.lstrip(sep) + drv, rest = cls._flavour.splitdrive(cls._flavour.normpath(part)) + rel = part[len(drv):].lstrip(sep) if drv[:1] == sep: root = sep elif rest[:1] == sep: - root = rest[:len(rest) - len(rel)] - root = cls._flavour.normpath(root) + root = sep * (len(rest) - len(rest.lstrip(sep))) if sep in rel: for x in reversed(rel.split(sep)): From 29f9f8120779587f1a9fa2c40cbd3e52cb66ad63 Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Mon, 1 Aug 2022 20:47:09 +0100 Subject: [PATCH 28/44] Restore `_split_root()` method and its tests. --- Lib/pathlib.py | 16 ++++++++------- Lib/test/test_pathlib.py | 44 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index f19209c88b9b7d..c7109d5a5eaec8 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -260,6 +260,14 @@ def __reduce__(self): # when pickling related paths. return (self.__class__, tuple(self._parts)) + @classmethod + def _split_root(cls, part): + sep = cls._flavour.sep + drv, tail = cls._flavour.splitdrive(cls._flavour.normpath(part)) + root = sep * (drv[:1] == sep or len(tail) - len(tail.lstrip(sep))) + rel = part[len(drv):].lstrip(sep) + return drv, root, rel + @classmethod def _parse_parts(cls, parts): parsed = [] @@ -272,13 +280,7 @@ def _parse_parts(cls, parts): continue elif altsep: part = part.replace(altsep, sep) - drv, rest = cls._flavour.splitdrive(cls._flavour.normpath(part)) - rel = part[len(drv):].lstrip(sep) - if drv[:1] == sep: - root = sep - elif rest[:1] == sep: - root = sep * (len(rest) - len(rest.lstrip(sep))) - + drv, root, rel = cls._split_root(part) if sep in rel: for x in reversed(rel.split(sep)): if x and x != '.': diff --git a/Lib/test/test_pathlib.py b/Lib/test/test_pathlib.py index 572d7379e96b8a..84a2d1dd6df565 100644 --- a/Lib/test/test_pathlib.py +++ b/Lib/test/test_pathlib.py @@ -621,6 +621,26 @@ def test_parse_parts(self): check(['c:\\a'], ('', '', ['c:\\a'])) check(['\\a'], ('', '', ['\\a'])) + def test_split_root(self): + f = self.cls._split_root + self.assertEqual(f(''), ('', '', '')) + self.assertEqual(f('a'), ('', '', 'a')) + self.assertEqual(f('a/b'), ('', '', 'a/b')) + self.assertEqual(f('a/b/'), ('', '', 'a/b/')) + self.assertEqual(f('/a'), ('', '/', 'a')) + self.assertEqual(f('/a/b'), ('', '/', 'a/b')) + self.assertEqual(f('/a/b/'), ('', '/', 'a/b/')) + # The root is collapsed when there are redundant slashes + # except when there are exactly two leading slashes, which + # is a special case in POSIX. + self.assertEqual(f('//a'), ('', '//', 'a')) + self.assertEqual(f('///a'), ('', '/', 'a')) + self.assertEqual(f('///a/b'), ('', '/', 'a/b')) + # Paths which look like NT paths aren't treated specially. + self.assertEqual(f('c:/a/b'), ('', '', 'c:/a/b')) + self.assertEqual(f('\\/a/b'), ('', '', '\\/a/b')) + self.assertEqual(f('\\a\\b'), ('', '', '\\a\\b')) + def test_root(self): P = self.cls self.assertEqual(P('/a/b').root, '/') @@ -744,6 +764,30 @@ def test_parse_parts(self): check(['Z:/a', '/b', 'c'], ('Z:', '\\', ['Z:\\', 'b', 'c'])) check(['//?/Z:/a', '/b', 'c'], ('\\\\?\\Z:', '\\', ['\\\\?\\Z:\\', 'b', 'c'])) + def test_split_root(self): + f = self.cls._split_root + self.assertEqual(f(''), ('', '', '')) + self.assertEqual(f('a'), ('', '', 'a')) + self.assertEqual(f('a\\b'), ('', '', 'a\\b')) + self.assertEqual(f('\\a'), ('', '\\', 'a')) + self.assertEqual(f('\\a\\b'), ('', '\\', 'a\\b')) + self.assertEqual(f('c:a\\b'), ('c:', '', 'a\\b')) + self.assertEqual(f('c:\\a\\b'), ('c:', '\\', 'a\\b')) + # Redundant slashes in the root are collapsed. + self.assertEqual(f('\\\\a'), ('', '\\', 'a')) + self.assertEqual(f('\\\\\\a/b'), ('', '\\', 'a/b')) + self.assertEqual(f('c:\\\\a'), ('c:', '\\', 'a')) + self.assertEqual(f('c:\\\\\\a/b'), ('c:', '\\', 'a/b')) + # Valid UNC paths. + self.assertEqual(f('\\\\a\\b'), ('\\\\a\\b', '\\', '')) + self.assertEqual(f('\\\\a\\b\\'), ('\\\\a\\b', '\\', '')) + self.assertEqual(f('\\\\a\\b\\c\\d'), ('\\\\a\\b', '\\', 'c\\d')) + # These are non-UNC paths (according to ntpath.py and test_ntpath). + # However, command.com says such paths are invalid, so it's + # difficult to know what the right semantics are. + self.assertEqual(f('\\\\\\a\\b'), ('', '\\', 'a\\b')) + self.assertEqual(f('\\\\a'), ('', '\\', 'a')) + def test_str(self): p = self.cls('a/b/c') self.assertEqual(str(p), 'a\\b\\c') From b39da536dc355d274e228d7810bbac20d0b70916 Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Fri, 12 Aug 2022 18:11:37 +0100 Subject: [PATCH 29/44] Optimize `_split_root()` --- Lib/pathlib.py | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 730144622ac7d1..e945079db21cdc 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -273,11 +273,34 @@ def __reduce__(self): @classmethod def _split_root(cls, part): - sep = cls._flavour.sep - drv, tail = cls._flavour.splitdrive(cls._flavour.normpath(part)) - root = sep * (drv[:1] == sep or len(tail) - len(tail.lstrip(sep))) - rel = part[len(drv):].lstrip(sep) - return drv, root, rel + # Fast path for POSIX + if cls._flavour is posixpath: + if part.startswith('///'): + return '', '/', part.lstrip('/') + elif part.startswith('//'): + return '', '//', part[2:] + elif part.startswith('/'): + return '', '/', part[1:] + else: + return '', '', part + + # Fast path for Windows + elif cls._flavour is ntpath: + drv, tail = cls._flavour.splitdrive(part) + if tail.startswith('\\') or drv.startswith('\\'): + return drv, '\\', tail.lstrip('\\') + else: + return drv, '', tail + + # Slow path for a generic path flavour. Note that normpath() is used + # to normalize the 'drive' and 'root' but NOT the 'rel' part, as we + # need to preserve '..' path segments. + else: + sep = cls._flavour.sep + drv, tail = cls._flavour.splitdrive(cls._flavour.normpath(part)) + root = sep * (drv[:1] == sep or len(tail) - len(tail.lstrip(sep))) + rel = part[len(drv):].lstrip(sep) + return drv, root, rel @classmethod def _parse_parts(cls, parts): From 806ad150ac5f7397623ea5d0c6869f3b7d43b6a1 Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Tue, 23 Aug 2022 22:38:48 +0100 Subject: [PATCH 30/44] Move `is_reserved()` implementation into `os.path` as a private function. --- Lib/ntpath.py | 24 ++++++++++++++++++++++++ Lib/pathlib.py | 19 +------------------ Lib/posixpath.py | 11 +++++++++++ 3 files changed, 36 insertions(+), 18 deletions(-) diff --git a/Lib/ntpath.py b/Lib/ntpath.py index 959bcd09831186..82bb513c1b0745 100644 --- a/Lib/ntpath.py +++ b/Lib/ntpath.py @@ -311,6 +311,30 @@ def ismount(path): return False +# Is the path reserved? +# This private function is used by pathlib.PureWindowsPath.is_reserved(). +# See GH-88569 for a proposal to make this function public. + +_reserved_names = frozenset( + {'CON', 'PRN', 'AUX', 'NUL', 'CONIN$', 'CONOUT$'} | + {f'COM{c}' for c in '123456789\xb9\xb2\xb3'} | + {f'LPT{c}' for c in '123456789\xb9\xb2\xb3'} +) + +def _isreserved(path): + """Return true if the pathname is reserved by the system.""" + # NOTE: the rules for reserved names seem somewhat complicated + # (e.g. r"..\NUL" is reserved but not r"foo\NUL" if "foo" does not + # exist). We err on the side of caution and return True for paths + # which are not considered reserved by Windows. + path = os.fsdecode(path) + if path.startswith('\\\\'): + # UNC paths are never reserved + return False + name = basename(path).partition('.')[0].partition(':')[0].rstrip(' ') + return name.upper() in _reserved_names + + # Expand paths beginning with '~' or '~user'. # '~' means $HOME; '~user' means that user's home directory. # If the path doesn't begin with '~', or if the user or $HOME is unknown, diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 6baf9bf6db56cf..0a8b787a71fab9 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -30,12 +30,6 @@ # Internals # -_WIN_RESERVED_NAMES = frozenset( - {'CON', 'PRN', 'AUX', 'NUL', 'CONIN$', 'CONOUT$'} | - {f'COM{c}' for c in '123456789\xb9\xb2\xb3'} | - {f'LPT{c}' for c in '123456789\xb9\xb2\xb3'} -) - _WINERROR_NOT_READY = 21 # drive exists but is not accessible _WINERROR_INVALID_NAME = 123 # fix for bpo-35306 _WINERROR_CANT_RESOLVE_FILENAME = 1921 # broken symlink pointing to itself @@ -660,18 +654,7 @@ def is_absolute(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._parts: - return False - - # NOTE: the rules for reserved names seem somewhat complicated - # (e.g. r"..\NUL" is reserved but not r"foo\NUL" if "foo" does not - # exist). We err on the side of caution and return True for paths - # which are not considered reserved by Windows. - if self._parts[0].startswith('\\\\'): - # UNC paths are never reserved - return False - name = self._parts[-1].partition('.')[0].partition(':')[0].rstrip(' ') - return name.upper() in _WIN_RESERVED_NAMES + return self._flavour._isreserved(self) def match(self, path_pattern): """ diff --git a/Lib/posixpath.py b/Lib/posixpath.py index 5e1ebe3293d849..e413e7c2b55b0d 100644 --- a/Lib/posixpath.py +++ b/Lib/posixpath.py @@ -216,6 +216,17 @@ def ismount(path): return False +# Is the path reserved? +# This private function is used by pathlib.PurePosixPath.is_reserved(). +# See GH-88569 for a proposal to make this function public. + +def _isreserved(path): + """Return true if the pathname is reserved by the system.""" + # Raise TypeError if the argument isn't path-like + os.fspath(path) + return False + + # Expand paths beginning with '~' or '~user'. # '~' means $HOME; '~user' means that user's home directory. # If the path doesn't begin with '~', or if the user or $HOME is unknown, From a35000959fe4f1ae83b8c1bd4369f728c185d92a Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Tue, 23 Aug 2022 23:47:55 +0100 Subject: [PATCH 31/44] Use `os.path.normcase()` when globbing. This removes a `flavour is posixpath` check in `_WildcardSelector`. Instead, we normalise the case of both the pattern and the `os.scandir()` entries, and then perform a case-sensitive comparison. The same approach is taken by `fnmatch.fnmatch()`. --- Lib/pathlib.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 0a8b787a71fab9..0fc4cbf11c8ac6 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -95,14 +95,15 @@ def select_from(self, parent_path): is_dir = path_cls.is_dir exists = path_cls.exists scandir = path_cls._scandir + normcase = path_cls._flavour.normcase if not is_dir(parent_path): return iter([]) - return self._select_from(parent_path, is_dir, exists, scandir) + return self._select_from(parent_path, is_dir, exists, scandir, normcase) class _TerminatingSelector: - def _select_from(self, parent_path, is_dir, exists, scandir): + def _select_from(self, parent_path, is_dir, exists, scandir, normcase): yield parent_path @@ -112,11 +113,11 @@ def __init__(self, name, child_parts, flavour): self.name = name _Selector.__init__(self, child_parts, flavour) - def _select_from(self, parent_path, is_dir, exists, scandir): + def _select_from(self, parent_path, is_dir, exists, scandir, normcase): try: path = parent_path._make_child_relpath(self.name) if (is_dir if self.dironly else exists)(path): - for p in self.successor._select_from(path, is_dir, exists, scandir): + for p in self.successor._select_from(path, is_dir, exists, scandir, normcase): yield p except PermissionError: return @@ -125,11 +126,10 @@ def _select_from(self, parent_path, is_dir, exists, scandir): class _WildcardSelector(_Selector): def __init__(self, pat, child_parts, flavour): - flags = re.NOFLAG if flavour is posixpath else re.IGNORECASE - self.match = re.compile(fnmatch.translate(pat), flags).fullmatch + self.match = re.compile(fnmatch.translate(flavour.normcase(pat))).fullmatch _Selector.__init__(self, child_parts, flavour) - def _select_from(self, parent_path, is_dir, exists, scandir): + def _select_from(self, parent_path, is_dir, exists, scandir, normcase): try: # We must close the scandir() object before proceeding to # avoid exhausting file descriptors when globbing deep trees. @@ -148,9 +148,9 @@ def _select_from(self, parent_path, is_dir, exists, scandir): raise continue name = entry.name - if self.match(name): + if self.match(normcase(name)): path = parent_path._make_child_relpath(name) - for p in self.successor._select_from(path, is_dir, exists, scandir): + for p in self.successor._select_from(path, is_dir, exists, scandir, normcase): yield p except PermissionError: return @@ -182,13 +182,13 @@ def _iterate_directories(self, parent_path, is_dir, scandir): except PermissionError: return - def _select_from(self, parent_path, is_dir, exists, scandir): + def _select_from(self, parent_path, is_dir, exists, scandir, normcase): try: yielded = set() try: successor_select = self.successor._select_from for starting_point in self._iterate_directories(parent_path, is_dir, scandir): - for p in successor_select(starting_point, is_dir, exists, scandir): + for p in successor_select(starting_point, is_dir, exists, scandir, normcase): if p not in yielded: yield p yielded.add(p) From 799b40ef2ed0aa9479a05e6bd6c0fa1a7b0adda3 Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Thu, 25 Aug 2022 23:34:32 +0100 Subject: [PATCH 32/44] Simplify `_split_root()` implementation. --- Lib/pathlib.py | 38 ++++++++++---------------------------- 1 file changed, 10 insertions(+), 28 deletions(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 0fc4cbf11c8ac6..e65f57d8dc586f 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -267,34 +267,16 @@ def __reduce__(self): @classmethod def _split_root(cls, part): - # Fast path for POSIX - if cls._flavour is posixpath: - if part.startswith('///'): - return '', '/', part.lstrip('/') - elif part.startswith('//'): - return '', '//', part[2:] - elif part.startswith('/'): - return '', '/', part[1:] - else: - return '', '', part - - # Fast path for Windows - elif cls._flavour is ntpath: - drv, tail = cls._flavour.splitdrive(part) - if tail.startswith('\\') or drv.startswith('\\'): - return drv, '\\', tail.lstrip('\\') - else: - return drv, '', tail - - # Slow path for a generic path flavour. Note that normpath() is used - # to normalize the 'drive' and 'root' but NOT the 'rel' part, as we - # need to preserve '..' path segments. - else: - sep = cls._flavour.sep - drv, tail = cls._flavour.splitdrive(cls._flavour.normpath(part)) - root = sep * (drv[:1] == sep or len(tail) - len(tail.lstrip(sep))) - rel = part[len(drv):].lstrip(sep) - return drv, root, rel + sep = cls._flavour.sep + rel = cls._flavour.splitdrive(part)[1].lstrip(sep) + anchor = part[:len(part)-len(rel)] + if anchor: + anchor = cls._flavour.normpath(anchor) + drv, root = cls._flavour.splitdrive(anchor) + if drv.startswith(sep): + # UNC paths always have a root. + root = sep + return drv, root, rel @classmethod def _parse_parts(cls, parts): From a6b25ea8e0954782e50cd70077daedcf195a8ec4 Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Fri, 26 Aug 2022 20:52:55 +0100 Subject: [PATCH 33/44] Remove tests for malformed UNC paths --- Lib/test/test_pathlib.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/Lib/test/test_pathlib.py b/Lib/test/test_pathlib.py index f046d5413ffb79..fa67f76619bbec 100644 --- a/Lib/test/test_pathlib.py +++ b/Lib/test/test_pathlib.py @@ -778,19 +778,12 @@ def test_split_root(self): self.assertEqual(f('c:a\\b'), ('c:', '', 'a\\b')) self.assertEqual(f('c:\\a\\b'), ('c:', '\\', 'a\\b')) # Redundant slashes in the root are collapsed. - self.assertEqual(f('\\\\a'), ('', '\\', 'a')) - self.assertEqual(f('\\\\\\a/b'), ('', '\\', 'a/b')) self.assertEqual(f('c:\\\\a'), ('c:', '\\', 'a')) self.assertEqual(f('c:\\\\\\a/b'), ('c:', '\\', 'a/b')) # Valid UNC paths. self.assertEqual(f('\\\\a\\b'), ('\\\\a\\b', '\\', '')) self.assertEqual(f('\\\\a\\b\\'), ('\\\\a\\b', '\\', '')) self.assertEqual(f('\\\\a\\b\\c\\d'), ('\\\\a\\b', '\\', 'c\\d')) - # These are non-UNC paths (according to ntpath.py and test_ntpath). - # However, command.com says such paths are invalid, so it's - # difficult to know what the right semantics are. - self.assertEqual(f('\\\\\\a\\b'), ('', '\\', 'a\\b')) - self.assertEqual(f('\\\\a'), ('', '\\', 'a')) def test_str(self): p = self.cls('a/b/c') From b8874dfed181f2be6c331ddf1a4a68a64e396bd6 Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Sat, 27 Aug 2022 22:08:41 +0100 Subject: [PATCH 34/44] Revert "Move `is_reserved()` implementation into `os.path` as a private function." This reverts commit 806ad150ac5f7397623ea5d0c6869f3b7d43b6a1. --- Lib/ntpath.py | 24 ------------------------ Lib/pathlib.py | 19 ++++++++++++++++++- Lib/posixpath.py | 11 ----------- 3 files changed, 18 insertions(+), 36 deletions(-) diff --git a/Lib/ntpath.py b/Lib/ntpath.py index 82bb513c1b0745..959bcd09831186 100644 --- a/Lib/ntpath.py +++ b/Lib/ntpath.py @@ -311,30 +311,6 @@ def ismount(path): return False -# Is the path reserved? -# This private function is used by pathlib.PureWindowsPath.is_reserved(). -# See GH-88569 for a proposal to make this function public. - -_reserved_names = frozenset( - {'CON', 'PRN', 'AUX', 'NUL', 'CONIN$', 'CONOUT$'} | - {f'COM{c}' for c in '123456789\xb9\xb2\xb3'} | - {f'LPT{c}' for c in '123456789\xb9\xb2\xb3'} -) - -def _isreserved(path): - """Return true if the pathname is reserved by the system.""" - # NOTE: the rules for reserved names seem somewhat complicated - # (e.g. r"..\NUL" is reserved but not r"foo\NUL" if "foo" does not - # exist). We err on the side of caution and return True for paths - # which are not considered reserved by Windows. - path = os.fsdecode(path) - if path.startswith('\\\\'): - # UNC paths are never reserved - return False - name = basename(path).partition('.')[0].partition(':')[0].rstrip(' ') - return name.upper() in _reserved_names - - # Expand paths beginning with '~' or '~user'. # '~' means $HOME; '~user' means that user's home directory. # If the path doesn't begin with '~', or if the user or $HOME is unknown, diff --git a/Lib/pathlib.py b/Lib/pathlib.py index e65f57d8dc586f..fe0ab9ada42290 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -30,6 +30,12 @@ # Internals # +_WIN_RESERVED_NAMES = frozenset( + {'CON', 'PRN', 'AUX', 'NUL', 'CONIN$', 'CONOUT$'} | + {f'COM{c}' for c in '123456789\xb9\xb2\xb3'} | + {f'LPT{c}' for c in '123456789\xb9\xb2\xb3'} +) + _WINERROR_NOT_READY = 21 # drive exists but is not accessible _WINERROR_INVALID_NAME = 123 # fix for bpo-35306 _WINERROR_CANT_RESOLVE_FILENAME = 1921 # broken symlink pointing to itself @@ -636,7 +642,18 @@ def is_absolute(self): def is_reserved(self): """Return True if the path contains one of the special names reserved by the system, if any.""" - return self._flavour._isreserved(self) + if self._flavour is posixpath or not self._parts: + return False + + # NOTE: the rules for reserved names seem somewhat complicated + # (e.g. r"..\NUL" is reserved but not r"foo\NUL" if "foo" does not + # exist). We err on the side of caution and return True for paths + # which are not considered reserved by Windows. + if self._parts[0].startswith('\\\\'): + # UNC paths are never reserved + return False + name = self._parts[-1].partition('.')[0].partition(':')[0].rstrip(' ') + return name.upper() in _WIN_RESERVED_NAMES def match(self, path_pattern): """ diff --git a/Lib/posixpath.py b/Lib/posixpath.py index e413e7c2b55b0d..5e1ebe3293d849 100644 --- a/Lib/posixpath.py +++ b/Lib/posixpath.py @@ -216,17 +216,6 @@ def ismount(path): return False -# Is the path reserved? -# This private function is used by pathlib.PurePosixPath.is_reserved(). -# See GH-88569 for a proposal to make this function public. - -def _isreserved(path): - """Return true if the pathname is reserved by the system.""" - # Raise TypeError if the argument isn't path-like - os.fspath(path) - return False - - # Expand paths beginning with '~' or '~user'. # '~' means $HOME; '~user' means that user's home directory. # If the path doesn't begin with '~', or if the user or $HOME is unknown, From 9d5e18a3502a8941783dcad0c9f0e4a65ccb68c6 Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Sat, 27 Aug 2022 22:14:52 +0100 Subject: [PATCH 35/44] Tweak _split_root() to use removesuffix() --- Lib/pathlib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index fe0ab9ada42290..3bd444f8220da7 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -275,7 +275,7 @@ def __reduce__(self): def _split_root(cls, part): sep = cls._flavour.sep rel = cls._flavour.splitdrive(part)[1].lstrip(sep) - anchor = part[:len(part)-len(rel)] + anchor = part.removesuffix(rel) if anchor: anchor = cls._flavour.normpath(anchor) drv, root = cls._flavour.splitdrive(anchor) From f73f426d1eb2625722f1e6a8287c2b495ed6f0bc Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Sat, 27 Aug 2022 22:18:58 +0100 Subject: [PATCH 36/44] Remove test that `/b/c/d` and `///b/c/d` are equivalent. This is no longer true as of 99fcf15052, see #96290 --- Lib/test/test_pathlib.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Lib/test/test_pathlib.py b/Lib/test/test_pathlib.py index fa67f76619bbec..4e92c0373abdeb 100644 --- a/Lib/test/test_pathlib.py +++ b/Lib/test/test_pathlib.py @@ -39,8 +39,7 @@ class _BasePurePathTest(object): ('', 'a', 'b'), ('a', '', 'b'), ('a', 'b', ''), ], '/b/c/d': [ - ('a', '/b/c', 'd'), ('a', '///b//c', 'd/'), - ('/a', '/b/c', 'd'), + ('a', '/b/c', 'd'), ('/a', '/b/c', 'd'), # Empty components get removed. ('/', 'b', '', 'c/d'), ('/', '', 'b/c/d'), ('', '/b/c/d'), ], From 37e4bc1e06907b59878c8ae23e6071a58c68b404 Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Fri, 16 Sep 2022 20:18:54 +0100 Subject: [PATCH 37/44] Restore comment linking to *Naming Files* page in Microsoft docs --- Lib/pathlib.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 3bd444f8220da7..8a3a4e05f86b6a 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -30,6 +30,8 @@ # Internals # +# Reference for Windows paths can be found at +# https://docs.microsoft.com/en-gb/windows/win32/fileio/naming-a-file _WIN_RESERVED_NAMES = frozenset( {'CON', 'PRN', 'AUX', 'NUL', 'CONIN$', 'CONOUT$'} | {f'COM{c}' for c in '123456789\xb9\xb2\xb3'} | From e3f13efcbed4241f65fbdc44d4962ef9a7ee3d66 Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Mon, 7 Nov 2022 20:16:06 +0000 Subject: [PATCH 38/44] Apply suggestions from code review Co-authored-by: Eryk Sun Co-authored-by: Brett Cannon --- Lib/pathlib.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 8a3a4e05f86b6a..6ff2c3942f6dff 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -31,7 +31,7 @@ # # Reference for Windows paths can be found at -# https://docs.microsoft.com/en-gb/windows/win32/fileio/naming-a-file +# https://learn.microsoft.com/en-gb/windows/win32/fileio/naming-a-file . _WIN_RESERVED_NAMES = frozenset( {'CON', 'PRN', 'AUX', 'NUL', 'CONIN$', 'CONOUT$'} | {f'COM{c}' for c in '123456789\xb9\xb2\xb3'} | @@ -597,14 +597,17 @@ def joinpath(self, *args): if root2: if not drv2 and drv1: return self._from_parsed_parts(drv1, root2, [drv1 + root2] + parts2[1:]) + else: + return self._from_parsed_parts(drv2, root2, parts2) elif drv2: if drv2 == drv1 or self._flavour.normcase(drv2) == self._flavour.normcase(drv1): - # Same drive => second path is relative to the first + # Same drive => second path is relative to the first. return self._from_parsed_parts(drv1, root1, parts1 + parts2[1:]) + else: + return self._from_parsed_parts(drv2, root2, parts2) else: - # Second path is non-anchored (common case) + # Second path is non-anchored (common case). return self._from_parsed_parts(drv1, root1, parts1 + parts2) - return self._from_parsed_parts(drv2, root2, parts2) def __truediv__(self, key): try: @@ -636,7 +639,7 @@ def parents(self): def is_absolute(self): """True if the path is absolute (has both a root and, if applicable, a drive).""" - # ntpath.isabs() is defective - see GH-44626 + # ntpath.isabs() is defective - see GH-44626 . if self._flavour is ntpath: return bool(self._drv and self._root) return self._flavour.isabs(self) @@ -652,7 +655,7 @@ def is_reserved(self): # exist). We err on the side of caution and return True for paths # which are not considered reserved by Windows. if self._parts[0].startswith('\\\\'): - # UNC paths are never reserved + # UNC paths are never reserved. return False name = self._parts[-1].partition('.')[0].partition(':')[0].rstrip(' ') return name.upper() in _WIN_RESERVED_NAMES From 07c67d00f68adf2c160895ba9ec0a32408e2d173 Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Tue, 15 Nov 2022 18:23:43 +0000 Subject: [PATCH 39/44] Clarify repr roundtrip tests. --- Lib/test/test_pathlib.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_pathlib.py b/Lib/test/test_pathlib.py index ae0cf3c89de0e3..ef31507e2afc39 100644 --- a/Lib/test/test_pathlib.py +++ b/Lib/test/test_pathlib.py @@ -192,9 +192,10 @@ def test_repr_common(self): inner = r[len(clsname) + 1 : -1] self.assertEqual(eval(inner), p.as_posix()) - if self.cls.__name__ == 'cls': - continue - + def test_repr_roundtrips(self): + for pathstr in ('a', 'a/b', 'a/b/c', '/', '/a/b', '/a/b/c'): + p = self.cls(pathstr) + r = repr(p) # The repr() roundtrips. q = eval(r, pathlib.__dict__) self.assertIs(q.__class__, p.__class__) @@ -3054,11 +3055,17 @@ class PurePathSubclassTest(_BasePurePathTest, unittest.TestCase): class cls(pathlib.PurePath): pass + # repr() roundtripping is not supported in custom subclass + test_repr_roundtrips = None + class PathSubclassTest(_BasePathTest, unittest.TestCase): class cls(pathlib.Path): pass + # repr() roundtripping is not supported in custom subclass + test_repr_roundtrips = None + class CompatiblePathTest(unittest.TestCase): """ From 950aa47d638cd1feb50c93f09a10b2ba89f4b2f4 Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Tue, 15 Nov 2022 19:11:11 +0000 Subject: [PATCH 40/44] Reduce tests diff --- Lib/test/test_pathlib.py | 255 +++++++++++++++++++++------------------ 1 file changed, 139 insertions(+), 116 deletions(-) diff --git a/Lib/test/test_pathlib.py b/Lib/test/test_pathlib.py index ef31507e2afc39..87c7dfacd34978 100644 --- a/Lib/test/test_pathlib.py +++ b/Lib/test/test_pathlib.py @@ -23,36 +23,12 @@ grp = pwd = None -# -# Tests for the pure classes. -# - -class _BasePurePathTest(object): - - # Keys are canonical paths, values are list of tuples of arguments - # supposed to produce equal paths. - equivalences = { - 'a/b': [ - ('a', 'b'), ('a/', 'b'), ('a', 'b/'), ('a/', 'b/'), - ('a/b/',), ('a//b',), ('a//b//',), - # Empty components get removed. - ('', 'a', 'b'), ('a', '', 'b'), ('a', 'b', ''), - ], - '/b/c/d': [ - ('a', '/b/c', 'd'), ('/a', '/b/c', 'd'), - # Empty components get removed. - ('/', 'b', '', 'c/d'), ('/', '', 'b/c/d'), ('', '/b/c/d'), - ], - } - - def setUp(self): - self.sep = self.cls._flavour.sep - self.altsep = self.cls._flavour.altsep +class _BaseFlavourTest(object): def _check_parse_parts(self, arg, expected): f = self.cls._parse_parts - sep = self.sep - altsep = self.altsep + sep = self.flavour.sep + altsep = self.flavour.altsep actual = f([x.replace('/', sep) for x in arg]) self.assertEqual(actual, expected) if altsep: @@ -61,7 +37,7 @@ def _check_parse_parts(self, arg, expected): def test_parse_parts_common(self): check = self._check_parse_parts - sep = self.sep + sep = self.flavour.sep # Unanchored parts. check([], ('', '', [])) check(['a'], ('', '', ['a'])) @@ -87,6 +63,140 @@ def test_parse_parts_common(self): check(['a', '/b', 'c'], ('', sep, [sep, 'b', 'c'])) check(['a', '/b', '/c'], ('', sep, [sep, 'c'])) + +class PosixFlavourTest(_BaseFlavourTest, unittest.TestCase): + cls = pathlib.PurePosixPath + flavour = pathlib.PurePosixPath._flavour + + def test_parse_parts(self): + check = self._check_parse_parts + # Collapsing of excess leading slashes, except for the double-slash + # special case. + check(['//a', 'b'], ('', '//', ['//', 'a', 'b'])) + check(['///a', 'b'], ('', '/', ['/', 'a', 'b'])) + check(['////a', 'b'], ('', '/', ['/', 'a', 'b'])) + # Paths which look like NT paths aren't treated specially. + check(['c:a'], ('', '', ['c:a'])) + check(['c:\\a'], ('', '', ['c:\\a'])) + check(['\\a'], ('', '', ['\\a'])) + + def test_splitroot(self): + f = self.cls._split_root + self.assertEqual(f(''), ('', '', '')) + self.assertEqual(f('a'), ('', '', 'a')) + self.assertEqual(f('a/b'), ('', '', 'a/b')) + self.assertEqual(f('a/b/'), ('', '', 'a/b/')) + self.assertEqual(f('/a'), ('', '/', 'a')) + self.assertEqual(f('/a/b'), ('', '/', 'a/b')) + self.assertEqual(f('/a/b/'), ('', '/', 'a/b/')) + # The root is collapsed when there are redundant slashes + # except when there are exactly two leading slashes, which + # is a special case in POSIX. + self.assertEqual(f('//a'), ('', '//', 'a')) + self.assertEqual(f('///a'), ('', '/', 'a')) + self.assertEqual(f('///a/b'), ('', '/', 'a/b')) + # Paths which look like NT paths aren't treated specially. + self.assertEqual(f('c:/a/b'), ('', '', 'c:/a/b')) + self.assertEqual(f('\\/a/b'), ('', '', '\\/a/b')) + self.assertEqual(f('\\a\\b'), ('', '', '\\a\\b')) + + +class NTFlavourTest(_BaseFlavourTest, unittest.TestCase): + cls = pathlib.PureWindowsPath + flavour = pathlib.PureWindowsPath._flavour + + def test_parse_parts(self): + check = self._check_parse_parts + # First part is anchored. + check(['c:'], ('c:', '', ['c:'])) + check(['c:/'], ('c:', '\\', ['c:\\'])) + check(['/'], ('', '\\', ['\\'])) + check(['c:a'], ('c:', '', ['c:', 'a'])) + check(['c:/a'], ('c:', '\\', ['c:\\', 'a'])) + check(['/a'], ('', '\\', ['\\', 'a'])) + # UNC paths. + check(['//a/b'], ('\\\\a\\b', '\\', ['\\\\a\\b\\'])) + check(['//a/b/'], ('\\\\a\\b', '\\', ['\\\\a\\b\\'])) + check(['//a/b/c'], ('\\\\a\\b', '\\', ['\\\\a\\b\\', 'c'])) + # Second part is anchored, so that the first part is ignored. + check(['a', 'Z:b', 'c'], ('Z:', '', ['Z:', 'b', 'c'])) + check(['a', 'Z:/b', 'c'], ('Z:', '\\', ['Z:\\', 'b', 'c'])) + # UNC paths. + check(['a', '//b/c', 'd'], ('\\\\b\\c', '\\', ['\\\\b\\c\\', 'd'])) + # Collapsing and stripping excess slashes. + check(['a', 'Z://b//c/', 'd/'], ('Z:', '\\', ['Z:\\', 'b', 'c', 'd'])) + # UNC paths. + check(['a', '//b/c//', 'd'], ('\\\\b\\c', '\\', ['\\\\b\\c\\', 'd'])) + # Extended paths. + check(['//?/c:/'], ('\\\\?\\c:', '\\', ['\\\\?\\c:\\'])) + check(['//?/c:/a'], ('\\\\?\\c:', '\\', ['\\\\?\\c:\\', 'a'])) + check(['//?/c:/a', '/b'], ('\\\\?\\c:', '\\', ['\\\\?\\c:\\', 'b'])) + # Extended UNC paths (format is "\\?\UNC\server\share"). + check(['//?/UNC/b/c'], ('\\\\?\\UNC\\b\\c', '\\', ['\\\\?\\UNC\\b\\c\\'])) + check(['//?/UNC/b/c/d'], ('\\\\?\\UNC\\b\\c', '\\', ['\\\\?\\UNC\\b\\c\\', 'd'])) + # Second part has a root but not drive. + check(['a', '/b', 'c'], ('', '\\', ['\\', 'b', 'c'])) + check(['Z:/a', '/b', 'c'], ('Z:', '\\', ['Z:\\', 'b', 'c'])) + check(['//?/Z:/a', '/b', 'c'], ('\\\\?\\Z:', '\\', ['\\\\?\\Z:\\', 'b', 'c'])) + # Joining with the same drive => the first path is appended to if + # the second path is relative. + check(['c:/a/b', 'c:x/y'], ('c:', '\\', ['c:\\', 'a', 'b', 'x', 'y'])) + check(['c:/a/b', 'c:/x/y'], ('c:', '\\', ['c:\\', 'x', 'y'])) + + def test_splitroot(self): + f = self.cls._split_root + self.assertEqual(f(''), ('', '', '')) + self.assertEqual(f('a'), ('', '', 'a')) + self.assertEqual(f('a\\b'), ('', '', 'a\\b')) + self.assertEqual(f('\\a'), ('', '\\', 'a')) + self.assertEqual(f('\\a\\b'), ('', '\\', 'a\\b')) + self.assertEqual(f('c:a\\b'), ('c:', '', 'a\\b')) + self.assertEqual(f('c:\\a\\b'), ('c:', '\\', 'a\\b')) + # Redundant slashes in the root are collapsed. + self.assertEqual(f('\\\\a'), ('', '\\', 'a')) + self.assertEqual(f('\\\\\\a/b'), ('', '\\', 'a/b')) + self.assertEqual(f('c:\\\\a'), ('c:', '\\', 'a')) + self.assertEqual(f('c:\\\\\\a/b'), ('c:', '\\', 'a/b')) + # Valid UNC paths. + self.assertEqual(f('\\\\a\\b'), ('\\\\a\\b', '\\', '')) + self.assertEqual(f('\\\\a\\b\\'), ('\\\\a\\b', '\\', '')) + self.assertEqual(f('\\\\a\\b\\c\\d'), ('\\\\a\\b', '\\', 'c\\d')) + # These are non-UNC paths (according to ntpath.py and test_ntpath). + # However, command.com says such paths are invalid, so it's + # difficult to know what the right semantics are. + self.assertEqual(f('\\\\\\a\\b'), ('', '\\', 'a\\b')) + self.assertEqual(f('\\\\a'), ('', '\\', 'a')) + + +# +# Tests for the pure classes. +# + +class _BasePurePathTest(object): + + # Keys are canonical paths, values are list of tuples of arguments + # supposed to produce equal paths. + equivalences = { + 'a/b': [ + ('a', 'b'), ('a/', 'b'), ('a', 'b/'), ('a/', 'b/'), + ('a/b/',), ('a//b',), ('a//b//',), + # Empty components get removed. + ('', 'a', 'b'), ('a', '', 'b'), ('a', 'b', ''), + ], + '/b/c/d': [ + ('a', '/b/c', 'd'), ('a', '///b//c', 'd/'), + ('/a', '/b/c', 'd'), + # Empty components get removed. + ('/', 'b', '', 'c/d'), ('/', '', 'b/c/d'), ('', '/b/c/d'), + ], + } + + def setUp(self): + p = self.cls('a') + self.flavour = p._flavour + self.sep = self.flavour.sep + self.altsep = self.flavour.altsep + def test_constructor_common(self): P = self.cls p = P('a') @@ -523,7 +633,7 @@ def test_with_suffix_common(self): self.assertRaises(ValueError, P('a/b').with_suffix, './.d') self.assertRaises(ValueError, P('a/b').with_suffix, '.d/.') self.assertRaises(ValueError, P('a/b').with_suffix, - (self.sep, 'd')) + (self.flavour.sep, 'd')) def test_relative_to_common(self): P = self.cls @@ -640,38 +750,6 @@ def test_pickling_common(self): class PurePosixPathTest(_BasePurePathTest, unittest.TestCase): cls = pathlib.PurePosixPath - def test_parse_parts(self): - check = self._check_parse_parts - # Collapsing of excess leading slashes, except for the double-slash - # special case. - check(['//a', 'b'], ('', '//', ['//', 'a', 'b'])) - check(['///a', 'b'], ('', '/', ['/', 'a', 'b'])) - check(['////a', 'b'], ('', '/', ['/', 'a', 'b'])) - # Paths which look like NT paths aren't treated specially. - check(['c:a'], ('', '', ['c:a'])) - check(['c:\\a'], ('', '', ['c:\\a'])) - check(['\\a'], ('', '', ['\\a'])) - - def test_split_root(self): - f = self.cls._split_root - self.assertEqual(f(''), ('', '', '')) - self.assertEqual(f('a'), ('', '', 'a')) - self.assertEqual(f('a/b'), ('', '', 'a/b')) - self.assertEqual(f('a/b/'), ('', '', 'a/b/')) - self.assertEqual(f('/a'), ('', '/', 'a')) - self.assertEqual(f('/a/b'), ('', '/', 'a/b')) - self.assertEqual(f('/a/b/'), ('', '/', 'a/b/')) - # The root is collapsed when there are redundant slashes - # except when there are exactly two leading slashes, which - # is a special case in POSIX. - self.assertEqual(f('//a'), ('', '//', 'a')) - self.assertEqual(f('///a'), ('', '/', 'a')) - self.assertEqual(f('///a/b'), ('', '/', 'a/b')) - # Paths which look like NT paths aren't treated specially. - self.assertEqual(f('c:/a/b'), ('', '', 'c:/a/b')) - self.assertEqual(f('\\/a/b'), ('', '', '\\/a/b')) - self.assertEqual(f('\\a\\b'), ('', '', '\\a\\b')) - def test_root(self): P = self.cls self.assertEqual(P('/a/b').root, '/') @@ -761,61 +839,6 @@ class PureWindowsPathTest(_BasePurePathTest, unittest.TestCase): ], }) - def test_parse_parts(self): - check = self._check_parse_parts - # First part is anchored. - check(['c:'], ('c:', '', ['c:'])) - check(['c:/'], ('c:', '\\', ['c:\\'])) - check(['/'], ('', '\\', ['\\'])) - check(['c:a'], ('c:', '', ['c:', 'a'])) - check(['c:/a'], ('c:', '\\', ['c:\\', 'a'])) - check(['/a'], ('', '\\', ['\\', 'a'])) - # UNC paths. - check(['//a/b'], ('\\\\a\\b', '\\', ['\\\\a\\b\\'])) - check(['//a/b/'], ('\\\\a\\b', '\\', ['\\\\a\\b\\'])) - check(['//a/b/c'], ('\\\\a\\b', '\\', ['\\\\a\\b\\', 'c'])) - # Second part is anchored, so that the first part is ignored. - check(['a', 'Z:b', 'c'], ('Z:', '', ['Z:', 'b', 'c'])) - check(['a', 'Z:/b', 'c'], ('Z:', '\\', ['Z:\\', 'b', 'c'])) - # UNC paths. - check(['a', '//b/c', 'd'], ('\\\\b\\c', '\\', ['\\\\b\\c\\', 'd'])) - # Collapsing and stripping excess slashes. - check(['a', 'Z://b//c/', 'd/'], ('Z:', '\\', ['Z:\\', 'b', 'c', 'd'])) - # UNC paths. - check(['a', '//b/c//', 'd'], ('\\\\b\\c', '\\', ['\\\\b\\c\\', 'd'])) - # Extended paths. - check(['//?/c:/'], ('\\\\?\\c:', '\\', ['\\\\?\\c:\\'])) - check(['//?/c:/a'], ('\\\\?\\c:', '\\', ['\\\\?\\c:\\', 'a'])) - check(['//?/c:/a', '/b'], ('\\\\?\\c:', '\\', ['\\\\?\\c:\\', 'b'])) - # Extended UNC paths (format is "\\?\UNC\server\share"). - check(['//?/UNC/b/c'], ('\\\\?\\UNC\\b\\c', '\\', ['\\\\?\\UNC\\b\\c\\'])) - check(['//?/UNC/b/c/d'], ('\\\\?\\UNC\\b\\c', '\\', ['\\\\?\\UNC\\b\\c\\', 'd'])) - # Second part has a root but not drive. - check(['a', '/b', 'c'], ('', '\\', ['\\', 'b', 'c'])) - check(['Z:/a', '/b', 'c'], ('Z:', '\\', ['Z:\\', 'b', 'c'])) - check(['//?/Z:/a', '/b', 'c'], ('\\\\?\\Z:', '\\', ['\\\\?\\Z:\\', 'b', 'c'])) - # Joining with the same drive => the first path is appended to if - # the second path is relative. - check(['c:/a/b', 'c:x/y'], ('c:', '\\', ['c:\\', 'a', 'b', 'x', 'y'])) - check(['c:/a/b', 'c:/x/y'], ('c:', '\\', ['c:\\', 'x', 'y'])) - - def test_split_root(self): - f = self.cls._split_root - self.assertEqual(f(''), ('', '', '')) - self.assertEqual(f('a'), ('', '', 'a')) - self.assertEqual(f('a\\b'), ('', '', 'a\\b')) - self.assertEqual(f('\\a'), ('', '\\', 'a')) - self.assertEqual(f('\\a\\b'), ('', '\\', 'a\\b')) - self.assertEqual(f('c:a\\b'), ('c:', '', 'a\\b')) - self.assertEqual(f('c:\\a\\b'), ('c:', '\\', 'a\\b')) - # Redundant slashes in the root are collapsed. - self.assertEqual(f('c:\\\\a'), ('c:', '\\', 'a')) - self.assertEqual(f('c:\\\\\\a/b'), ('c:', '\\', 'a/b')) - # Valid UNC paths. - self.assertEqual(f('\\\\a\\b'), ('\\\\a\\b', '\\', '')) - self.assertEqual(f('\\\\a\\b\\'), ('\\\\a\\b', '\\', '')) - self.assertEqual(f('\\\\a\\b\\c\\d'), ('\\\\a\\b', '\\', 'c\\d')) - def test_str(self): p = self.cls('a/b/c') self.assertEqual(str(p), 'a\\b\\c') From d5f6f0332acc02f18bcfeffee94cd7eee0f5c4f2 Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Sat, 27 Aug 2022 22:18:58 +0100 Subject: [PATCH 41/44] Remove test that `/b/c/d` and `///b/c/d` are equivalent. This is no longer true as of 99fcf15052, see #96290 --- Lib/test/test_pathlib.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Lib/test/test_pathlib.py b/Lib/test/test_pathlib.py index 87c7dfacd34978..62c405646d7688 100644 --- a/Lib/test/test_pathlib.py +++ b/Lib/test/test_pathlib.py @@ -184,8 +184,7 @@ class _BasePurePathTest(object): ('', 'a', 'b'), ('a', '', 'b'), ('a', 'b', ''), ], '/b/c/d': [ - ('a', '/b/c', 'd'), ('a', '///b//c', 'd/'), - ('/a', '/b/c', 'd'), + ('a', '/b/c', 'd'), ('/a', '/b/c', 'd'), # Empty components get removed. ('/', 'b', '', 'c/d'), ('/', '', 'b/c/d'), ('', '/b/c/d'), ], From b66a4fbf50a05f51e52f3f1c8f2a5a0345c0810e Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Tue, 15 Nov 2022 19:31:38 +0000 Subject: [PATCH 42/44] Fix missing full stops. --- Lib/test/test_pathlib.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_pathlib.py b/Lib/test/test_pathlib.py index 62c405646d7688..d4b6217f989c43 100644 --- a/Lib/test/test_pathlib.py +++ b/Lib/test/test_pathlib.py @@ -3077,7 +3077,7 @@ class PurePathSubclassTest(_BasePurePathTest, unittest.TestCase): class cls(pathlib.PurePath): pass - # repr() roundtripping is not supported in custom subclass + # repr() roundtripping is not supported in custom subclass. test_repr_roundtrips = None @@ -3085,7 +3085,7 @@ class PathSubclassTest(_BasePathTest, unittest.TestCase): class cls(pathlib.Path): pass - # repr() roundtripping is not supported in custom subclass + # repr() roundtripping is not supported in custom subclass. test_repr_roundtrips = None From fbaadf45d443c33a1df8d1122aee0805de3c7822 Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Tue, 15 Nov 2022 19:58:28 +0000 Subject: [PATCH 43/44] Remove four more test assertions affected by #96290 --- Lib/test/test_pathlib.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/Lib/test/test_pathlib.py b/Lib/test/test_pathlib.py index d4b6217f989c43..3948eeef760795 100644 --- a/Lib/test/test_pathlib.py +++ b/Lib/test/test_pathlib.py @@ -153,19 +153,12 @@ def test_splitroot(self): self.assertEqual(f('c:a\\b'), ('c:', '', 'a\\b')) self.assertEqual(f('c:\\a\\b'), ('c:', '\\', 'a\\b')) # Redundant slashes in the root are collapsed. - self.assertEqual(f('\\\\a'), ('', '\\', 'a')) - self.assertEqual(f('\\\\\\a/b'), ('', '\\', 'a/b')) self.assertEqual(f('c:\\\\a'), ('c:', '\\', 'a')) self.assertEqual(f('c:\\\\\\a/b'), ('c:', '\\', 'a/b')) # Valid UNC paths. self.assertEqual(f('\\\\a\\b'), ('\\\\a\\b', '\\', '')) self.assertEqual(f('\\\\a\\b\\'), ('\\\\a\\b', '\\', '')) self.assertEqual(f('\\\\a\\b\\c\\d'), ('\\\\a\\b', '\\', 'c\\d')) - # These are non-UNC paths (according to ntpath.py and test_ntpath). - # However, command.com says such paths are invalid, so it's - # difficult to know what the right semantics are. - self.assertEqual(f('\\\\\\a\\b'), ('', '\\', 'a\\b')) - self.assertEqual(f('\\\\a'), ('', '\\', 'a')) # From 40dc5147b6da2c16d42f817d6eec24b30501e24f Mon Sep 17 00:00:00 2001 From: barneygale Date: Sat, 17 Dec 2022 21:01:01 +0000 Subject: [PATCH 44/44] Wrap repr test cases in `self.subTest()` block --- Lib/test/test_pathlib.py | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/Lib/test/test_pathlib.py b/Lib/test/test_pathlib.py index f01483d67ca096..5b13d5064484f0 100644 --- a/Lib/test/test_pathlib.py +++ b/Lib/test/test_pathlib.py @@ -285,24 +285,26 @@ def test_as_uri_common(self): def test_repr_common(self): for pathstr in ('a', 'a/b', 'a/b/c', '/', '/a/b', '/a/b/c'): - p = self.cls(pathstr) - clsname = p.__class__.__name__ - r = repr(p) - # The repr() is in the form ClassName("forward-slashes path"). - self.assertTrue(r.startswith(clsname + '('), r) - self.assertTrue(r.endswith(')'), r) - inner = r[len(clsname) + 1 : -1] - self.assertEqual(eval(inner), p.as_posix()) + with self.subTest(pathstr=pathstr): + p = self.cls(pathstr) + clsname = p.__class__.__name__ + r = repr(p) + # The repr() is in the form ClassName("forward-slashes path"). + self.assertTrue(r.startswith(clsname + '('), r) + self.assertTrue(r.endswith(')'), r) + inner = r[len(clsname) + 1 : -1] + self.assertEqual(eval(inner), p.as_posix()) def test_repr_roundtrips(self): for pathstr in ('a', 'a/b', 'a/b/c', '/', '/a/b', '/a/b/c'): - p = self.cls(pathstr) - r = repr(p) - # The repr() roundtrips. - q = eval(r, pathlib.__dict__) - self.assertIs(q.__class__, p.__class__) - self.assertEqual(q, p) - self.assertEqual(repr(q), r) + with self.subTest(pathstr=pathstr): + p = self.cls(pathstr) + r = repr(p) + # The repr() roundtrips. + q = eval(r, pathlib.__dict__) + self.assertIs(q.__class__, p.__class__) + self.assertEqual(q, p) + self.assertEqual(repr(q), r) def test_eq_common(self): P = self.cls