From 81e301b876a07222283eb87de2128c0bfa5a3f80 Mon Sep 17 00:00:00 2001 From: barneygale Date: Tue, 23 May 2023 20:30:44 +0100 Subject: [PATCH 01/15] GH-89812: Add `pathlib._LexicalPath` This internal class excludes the `__fspath__()`, `__bytes__()` and `as_uri()` methods, which must not be inherited by a future `tarfile.TarPath` class. --- Lib/pathlib.py | 111 +++++++++++++++++++++------------------ Lib/test/test_pathlib.py | 44 +++++++++------- 2 files changed, 85 insertions(+), 70 deletions(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 3d68c161603d08..eea679e63e2ae7 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -233,14 +233,10 @@ def __repr__(self): return "<{}.parents>".format(type(self._path).__name__) -class PurePath(object): - """Base class for manipulating paths without I/O. +class _LexicalPath(object): + """Base class for manipulating paths using only lexical operations. - PurePath represents a filesystem path and offers operations which - don't imply any actual filesystem I/O. Depending on your system, - instantiating a PurePath will return either a PurePosixPath or a - PureWindowsPath object. You can also instantiate either of these classes - directly, regardless of your system. + This class does not provide __fspath__(), __bytes__() or as_uri(). """ __slots__ = ( @@ -280,16 +276,6 @@ class PurePath(object): ) _flavour = os.path - def __new__(cls, *args, **kwargs): - """Construct a PurePath from one or several strings and or existing - PurePath objects. The strings and path objects are combined so as - to yield a canonicalized path, which is incorporated into the - new PurePath object. - """ - if cls is PurePath: - cls = PureWindowsPath if os.name == 'nt' else PurePosixPath - return object.__new__(cls) - def __reduce__(self): # Using the parts tuple helps share interned path parts # when pickling related paths. @@ -298,7 +284,7 @@ def __reduce__(self): def __init__(self, *args): paths = [] for arg in args: - if isinstance(arg, PurePath): + if isinstance(arg, _LexicalPath): path = arg._raw_path else: try: @@ -378,43 +364,15 @@ def __str__(self): self._tail) or '.' return self._str - def __fspath__(self): - return str(self) - def as_posix(self): """Return the string representation of the path with forward (/) slashes.""" f = self._flavour return str(self).replace(f.sep, '/') - def __bytes__(self): - """Return the bytes representation of the path. This is only - recommended to use under Unix.""" - return os.fsencode(self) - def __repr__(self): return "{}({!r})".format(self.__class__.__name__, self.as_posix()) - 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") - - drive = self.drive - if len(drive) == 2 and drive[1] == ':': - # It's a path on a local drive => 'file:///c:/a/b' - prefix = 'file:///' + drive - path = self.as_posix()[2:] - elif drive: - # It's a path on a network drive => 'file://host/share/a/b' - 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 _str_normcase(self): # String with normalized case, for hashing and equality checks @@ -434,7 +392,7 @@ def _parts_normcase(self): return self._parts_normcase_cached def __eq__(self, other): - if not isinstance(other, PurePath): + if not isinstance(other, _LexicalPath): return NotImplemented return self._str_normcase == other._str_normcase and self._flavour is other._flavour @@ -446,22 +404,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, _LexicalPath) or self._flavour is not other._flavour: return NotImplemented return self._parts_normcase < other._parts_normcase def __le__(self, other): - if not isinstance(other, PurePath) or self._flavour is not other._flavour: + if not isinstance(other, _LexicalPath) or self._flavour is not other._flavour: return NotImplemented return self._parts_normcase <= other._parts_normcase def __gt__(self, other): - if not isinstance(other, PurePath) or self._flavour is not other._flavour: + if not isinstance(other, _LexicalPath) or self._flavour is not other._flavour: return NotImplemented return self._parts_normcase > other._parts_normcase def __ge__(self, other): - if not isinstance(other, PurePath) or self._flavour is not other._flavour: + if not isinstance(other, _LexicalPath) or self._flavour is not other._flavour: return NotImplemented return self._parts_normcase >= other._parts_normcase @@ -707,6 +665,57 @@ def match(self, path_pattern, *, case_sensitive=None): return False return True + +class PurePath(_LexicalPath): + """Base class for manipulating paths without I/O. + + PurePath represents a filesystem path and offers operations which + don't imply any actual filesystem I/O. Depending on your system, + instantiating a PurePath will return either a PurePosixPath or a + PureWindowsPath object. You can also instantiate either of these classes + directly, regardless of your system. + """ + __slots__ = () + + def __new__(cls, *args, **kwargs): + """Construct a PurePath from one or several strings and or existing + PurePath objects. The strings and path objects are combined so as + to yield a canonicalized path, which is incorporated into the + new PurePath object. + """ + if cls is PurePath: + cls = PureWindowsPath if os.name == 'nt' else PurePosixPath + return object.__new__(cls) + + def __fspath__(self): + return str(self) + + def __bytes__(self): + """Return the bytes representation of the path. This is only + recommended to use under Unix.""" + return os.fsencode(self) + + 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") + + drive = self.drive + if len(drive) == 2 and drive[1] == ':': + # It's a path on a local drive => 'file:///c:/a/b' + prefix = 'file:///' + drive + path = self.as_posix()[2:] + elif drive: + # It's a path on a network drive => 'file://host/share/a/b' + 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)) + + # Can't subclass os.PathLike from PurePath and keep the constructor # optimizations in PurePath.__slots__. os.PathLike.register(PurePath) diff --git a/Lib/test/test_pathlib.py b/Lib/test/test_pathlib.py index ab2c2b232a0411..3a6d9c4140036b 100644 --- a/Lib/test/test_pathlib.py +++ b/Lib/test/test_pathlib.py @@ -37,7 +37,7 @@ def with_segments(self, *pathsegments): return type(self)(*pathsegments, session_id=self.session_id) -class _BasePurePathTest(object): +class _BaseLexicalPathTest(object): # Keys are canonical paths, values are list of tuples of arguments # supposed to produce equal paths. @@ -227,18 +227,6 @@ def test_as_posix_common(self): self.assertEqual(P(pathstr).as_posix(), pathstr) # Other tests for as_posix() are in test_equivalences(). - def test_as_bytes_common(self): - sep = os.fsencode(self.sep) - P = self.cls - self.assertEqual(bytes(P('a/b')), b'a' + sep + b'b') - - def test_as_uri_common(self): - P = self.cls - with self.assertRaises(ValueError): - P('a').as_uri() - with self.assertRaises(ValueError): - P().as_uri() - def test_repr_common(self): for pathstr in ('a', 'a/b', 'a/b/c', '/', '/a/b', '/a/b/c'): with self.subTest(pathstr=pathstr): @@ -358,12 +346,6 @@ def test_parts_common(self): parts = p.parts self.assertEqual(parts, (sep, 'a', 'b')) - def test_fspath_common(self): - P = self.cls - p = P('a/b') - self._check_str(p.__fspath__(), ('a/b',)) - self._check_str(os.fspath(p), ('a/b',)) - def test_equivalences(self): for k, tuples in self.equivalences.items(): canon = k.replace('/', self.sep) @@ -702,6 +684,30 @@ def test_pickling_common(self): self.assertEqual(str(pp), str(p)) +class LexicalPathTest(_BaseLexicalPathTest, unittest.TestCase): + cls = pathlib._LexicalPath + + +class _BasePurePathTest(_BaseLexicalPathTest): + def test_fspath_common(self): + P = self.cls + p = P('a/b') + self._check_str(p.__fspath__(), ('a/b',)) + self._check_str(os.fspath(p), ('a/b',)) + + def test_bytes_common(self): + sep = os.fsencode(self.sep) + P = self.cls + self.assertEqual(bytes(P('a/b')), b'a' + sep + b'b') + + def test_as_uri_common(self): + P = self.cls + with self.assertRaises(ValueError): + P('a').as_uri() + with self.assertRaises(ValueError): + P().as_uri() + + class PurePosixPathTest(_BasePurePathTest, unittest.TestCase): cls = pathlib.PurePosixPath From ace5824a9205f2e81b6aa7780c1fadb4ba7b8fc6 Mon Sep 17 00:00:00 2001 From: barneygale Date: Tue, 23 May 2023 23:46:20 +0100 Subject: [PATCH 02/15] Rename `_LexicalPath` --> `_BasePurePath` --- Lib/pathlib.py | 16 ++++++++-------- Lib/test/test_pathlib.py | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index eea679e63e2ae7..5f0abc7150f1d1 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -233,7 +233,7 @@ def __repr__(self): return "<{}.parents>".format(type(self._path).__name__) -class _LexicalPath(object): +class _BasePurePath(object): """Base class for manipulating paths using only lexical operations. This class does not provide __fspath__(), __bytes__() or as_uri(). @@ -284,7 +284,7 @@ def __reduce__(self): def __init__(self, *args): paths = [] for arg in args: - if isinstance(arg, _LexicalPath): + if isinstance(arg, _BasePurePath): path = arg._raw_path else: try: @@ -392,7 +392,7 @@ def _parts_normcase(self): return self._parts_normcase_cached def __eq__(self, other): - if not isinstance(other, _LexicalPath): + if not isinstance(other, _BasePurePath): return NotImplemented return self._str_normcase == other._str_normcase and self._flavour is other._flavour @@ -404,22 +404,22 @@ def __hash__(self): return self._hash def __lt__(self, other): - if not isinstance(other, _LexicalPath) or self._flavour is not other._flavour: + if not isinstance(other, _BasePurePath) or self._flavour is not other._flavour: return NotImplemented return self._parts_normcase < other._parts_normcase def __le__(self, other): - if not isinstance(other, _LexicalPath) or self._flavour is not other._flavour: + if not isinstance(other, _BasePurePath) or self._flavour is not other._flavour: return NotImplemented return self._parts_normcase <= other._parts_normcase def __gt__(self, other): - if not isinstance(other, _LexicalPath) or self._flavour is not other._flavour: + if not isinstance(other, _BasePurePath) or self._flavour is not other._flavour: return NotImplemented return self._parts_normcase > other._parts_normcase def __ge__(self, other): - if not isinstance(other, _LexicalPath) or self._flavour is not other._flavour: + if not isinstance(other, _BasePurePath) or self._flavour is not other._flavour: return NotImplemented return self._parts_normcase >= other._parts_normcase @@ -666,7 +666,7 @@ def match(self, path_pattern, *, case_sensitive=None): return True -class PurePath(_LexicalPath): +class PurePath(_BasePurePath): """Base class for manipulating paths without I/O. PurePath represents a filesystem path and offers operations which diff --git a/Lib/test/test_pathlib.py b/Lib/test/test_pathlib.py index 3a6d9c4140036b..e6a00b16fefe5f 100644 --- a/Lib/test/test_pathlib.py +++ b/Lib/test/test_pathlib.py @@ -37,7 +37,7 @@ def with_segments(self, *pathsegments): return type(self)(*pathsegments, session_id=self.session_id) -class _BaseLexicalPathTest(object): +class _BaseBasePurePathTest(object): # Keys are canonical paths, values are list of tuples of arguments # supposed to produce equal paths. @@ -684,11 +684,11 @@ def test_pickling_common(self): self.assertEqual(str(pp), str(p)) -class LexicalPathTest(_BaseLexicalPathTest, unittest.TestCase): - cls = pathlib._LexicalPath +class BasePurePathTest(_BaseBasePurePathTest, unittest.TestCase): + cls = pathlib._BasePurePath -class _BasePurePathTest(_BaseLexicalPathTest): +class _BasePurePathTest(_BaseBasePurePathTest): def test_fspath_common(self): P = self.cls p = P('a/b') From d1b23c88334136b70e2c12476ff6839fdbc20ee1 Mon Sep 17 00:00:00 2001 From: barneygale Date: Wed, 24 May 2023 15:07:53 +0100 Subject: [PATCH 03/15] Document __bytes__ in PurePath docstring --- Lib/pathlib.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 5f0abc7150f1d1..cfb8511cdff864 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -674,6 +674,11 @@ class PurePath(_BasePurePath): instantiating a PurePath will return either a PurePosixPath or a PureWindowsPath object. You can also instantiate either of these classes directly, regardless of your system. + + On Posix, calling ``bytes()`` on a path gives the raw filesystem path as + a bytes object, as encoded by ``os.fsencode()``. On Windows, the unicode + form is the canonical representation of fileysstem paths, and so calling + ``bytes()`` on a path is not recommended. """ __slots__ = () From 115002aee671ee1221c5c771ec55191a71bd6818 Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Wed, 24 May 2023 16:04:49 +0100 Subject: [PATCH 04/15] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Éric --- Lib/pathlib.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index cfb8511cdff864..62a9f35ec7afbb 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -236,7 +236,7 @@ def __repr__(self): class _BasePurePath(object): """Base class for manipulating paths using only lexical operations. - This class does not provide __fspath__(), __bytes__() or as_uri(). + This class does not provide the methods __fspath__, __bytes__ or as_uri. """ __slots__ = ( @@ -675,7 +675,7 @@ class PurePath(_BasePurePath): PureWindowsPath object. You can also instantiate either of these classes directly, regardless of your system. - On Posix, calling ``bytes()`` on a path gives the raw filesystem path as + On Posix, calling ``bytes(path)`` gives the raw filesystem path as a bytes object, as encoded by ``os.fsencode()``. On Windows, the unicode form is the canonical representation of fileysstem paths, and so calling ``bytes()`` on a path is not recommended. From f257e4826e93ed192854e0c57d267c806229db87 Mon Sep 17 00:00:00 2001 From: barneygale Date: Wed, 24 May 2023 16:05:43 +0100 Subject: [PATCH 05/15] Revert "Document __bytes__ in PurePath docstring" This reverts commit d1b23c88334136b70e2c12476ff6839fdbc20ee1. --- Lib/pathlib.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 62a9f35ec7afbb..7e3b7a68275e53 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -674,11 +674,6 @@ class PurePath(_BasePurePath): instantiating a PurePath will return either a PurePosixPath or a PureWindowsPath object. You can also instantiate either of these classes directly, regardless of your system. - - On Posix, calling ``bytes(path)`` gives the raw filesystem path as - a bytes object, as encoded by ``os.fsencode()``. On Windows, the unicode - form is the canonical representation of fileysstem paths, and so calling - ``bytes()`` on a path is not recommended. """ __slots__ = () From 68d28912f47a3c280f3da4a01a1aadfcc318043f Mon Sep 17 00:00:00 2001 From: Barney Gale Date: Wed, 24 May 2023 17:36:01 +0100 Subject: [PATCH 06/15] 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 7e3b7a68275e53..6a2d5c473367a3 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -233,7 +233,7 @@ def __repr__(self): return "<{}.parents>".format(type(self._path).__name__) -class _BasePurePath(object): +class _BasePurePath: """Base class for manipulating paths using only lexical operations. This class does not provide the methods __fspath__, __bytes__ or as_uri. From 458c33d2d7ccbb2e90a6eacfd5fb3a2a776d43dc Mon Sep 17 00:00:00 2001 From: barneygale Date: Wed, 24 May 2023 22:31:47 +0100 Subject: [PATCH 07/15] Subclass from `os.PathLike` --- Lib/os.py | 2 ++ Lib/pathlib.py | 7 +------ Lib/test/test_os.py | 7 +++++++ 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Lib/os.py b/Lib/os.py index 598c9e502301f7..31b957f13215d5 100644 --- a/Lib/os.py +++ b/Lib/os.py @@ -1079,6 +1079,8 @@ class PathLike(abc.ABC): """Abstract base class for implementing the file system path protocol.""" + __slots__ = () + @abc.abstractmethod def __fspath__(self): """Return the file system path representation of the object.""" diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 6a2d5c473367a3..74926345b83614 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -666,7 +666,7 @@ def match(self, path_pattern, *, case_sensitive=None): return True -class PurePath(_BasePurePath): +class PurePath(_BasePurePath, os.PathLike): """Base class for manipulating paths without I/O. PurePath represents a filesystem path and offers operations which @@ -716,11 +716,6 @@ def as_uri(self): return prefix + urlquote_from_bytes(os.fsencode(path)) -# Can't subclass os.PathLike from PurePath and keep the constructor -# optimizations in PurePath.__slots__. -os.PathLike.register(PurePath) - - class PurePosixPath(PurePath): """PurePath subclass for non-Windows systems. diff --git a/Lib/test/test_os.py b/Lib/test/test_os.py index 584cc05ca82a55..38f8ad27816782 100644 --- a/Lib/test/test_os.py +++ b/Lib/test/test_os.py @@ -4640,6 +4640,13 @@ class A(os.PathLike): def test_pathlike_class_getitem(self): self.assertIsInstance(os.PathLike[bytes], types.GenericAlias) + def test_pathlike_subclass_slots(self): + class A(os.PathLike): + __slots__ = () + def __fspath__(self): + return '' + self.assertFalse(hasattr(A(), '__dict__')) + class TimesTests(unittest.TestCase): def test_times(self): From 6d9a1a2464e7312f09651b9c045eeb1f82933b8e Mon Sep 17 00:00:00 2001 From: barneygale Date: Wed, 24 May 2023 22:44:12 +0100 Subject: [PATCH 08/15] Add news blurb --- .../next/Library/2023-05-24-22-43-09.gh-issue-104810.Yj69d8.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Library/2023-05-24-22-43-09.gh-issue-104810.Yj69d8.rst diff --git a/Misc/NEWS.d/next/Library/2023-05-24-22-43-09.gh-issue-104810.Yj69d8.rst b/Misc/NEWS.d/next/Library/2023-05-24-22-43-09.gh-issue-104810.Yj69d8.rst new file mode 100644 index 00000000000000..e596ab36f5c729 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2023-05-24-22-43-09.gh-issue-104810.Yj69d8.rst @@ -0,0 +1 @@ +Add missing :attr:`~object.__slots__` to :class:`os.PathLike`. From bd237827073c974c1a1e26920a692dfc7a9c2cd6 Mon Sep 17 00:00:00 2001 From: barneygale Date: Wed, 24 May 2023 22:53:10 +0100 Subject: [PATCH 09/15] Revert "Subclass from `os.PathLike`" This reverts commit 458c33d2d7ccbb2e90a6eacfd5fb3a2a776d43dc. --- Lib/os.py | 2 -- Lib/pathlib.py | 7 ++++++- Lib/test/test_os.py | 7 ------- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/Lib/os.py b/Lib/os.py index 31b957f13215d5..598c9e502301f7 100644 --- a/Lib/os.py +++ b/Lib/os.py @@ -1079,8 +1079,6 @@ class PathLike(abc.ABC): """Abstract base class for implementing the file system path protocol.""" - __slots__ = () - @abc.abstractmethod def __fspath__(self): """Return the file system path representation of the object.""" diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 74926345b83614..6a2d5c473367a3 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -666,7 +666,7 @@ def match(self, path_pattern, *, case_sensitive=None): return True -class PurePath(_BasePurePath, os.PathLike): +class PurePath(_BasePurePath): """Base class for manipulating paths without I/O. PurePath represents a filesystem path and offers operations which @@ -716,6 +716,11 @@ def as_uri(self): return prefix + urlquote_from_bytes(os.fsencode(path)) +# Can't subclass os.PathLike from PurePath and keep the constructor +# optimizations in PurePath.__slots__. +os.PathLike.register(PurePath) + + class PurePosixPath(PurePath): """PurePath subclass for non-Windows systems. diff --git a/Lib/test/test_os.py b/Lib/test/test_os.py index 38f8ad27816782..584cc05ca82a55 100644 --- a/Lib/test/test_os.py +++ b/Lib/test/test_os.py @@ -4640,13 +4640,6 @@ class A(os.PathLike): def test_pathlike_class_getitem(self): self.assertIsInstance(os.PathLike[bytes], types.GenericAlias) - def test_pathlike_subclass_slots(self): - class A(os.PathLike): - __slots__ = () - def __fspath__(self): - return '' - self.assertFalse(hasattr(A(), '__dict__')) - class TimesTests(unittest.TestCase): def test_times(self): From a69417f2f05bc1d598419eb0f124dde8faf52ee7 Mon Sep 17 00:00:00 2001 From: barneygale Date: Wed, 24 May 2023 22:53:13 +0100 Subject: [PATCH 10/15] Revert "Add news blurb" This reverts commit 6d9a1a2464e7312f09651b9c045eeb1f82933b8e. --- .../next/Library/2023-05-24-22-43-09.gh-issue-104810.Yj69d8.rst | 1 - 1 file changed, 1 deletion(-) delete mode 100644 Misc/NEWS.d/next/Library/2023-05-24-22-43-09.gh-issue-104810.Yj69d8.rst diff --git a/Misc/NEWS.d/next/Library/2023-05-24-22-43-09.gh-issue-104810.Yj69d8.rst b/Misc/NEWS.d/next/Library/2023-05-24-22-43-09.gh-issue-104810.Yj69d8.rst deleted file mode 100644 index e596ab36f5c729..00000000000000 --- a/Misc/NEWS.d/next/Library/2023-05-24-22-43-09.gh-issue-104810.Yj69d8.rst +++ /dev/null @@ -1 +0,0 @@ -Add missing :attr:`~object.__slots__` to :class:`os.PathLike`. From 98e30d4d4d6dc2329e4b0bd1a69b461cb653ea20 Mon Sep 17 00:00:00 2001 From: barneygale Date: Wed, 14 Jun 2023 17:11:53 +0100 Subject: [PATCH 11/15] Fix missed isinstance() call. --- Lib/pathlib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 30a248f678c50e..8c7bcaad68c9e4 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -680,7 +680,7 @@ def match(self, path_pattern, *, case_sensitive=None): """ Return True if this path matches the given pattern. """ - if not isinstance(path_pattern, PurePath): + if not isinstance(path_pattern, _BasePurePath): path_pattern = self.with_segments(path_pattern) if case_sensitive is None: case_sensitive = _is_case_sensitive(self._flavour) From e36eb74fc19325f9f500355c21256e5b246c7d70 Mon Sep 17 00:00:00 2001 From: barneygale Date: Thu, 22 Jun 2023 23:42:30 +0100 Subject: [PATCH 12/15] Drop methods from `PurePath` itself, as it cannot be instantiated. --- Lib/pathlib.py | 62 ++++++++++++++++++++-------------------- Lib/test/test_pathlib.py | 28 +++++++++--------- 2 files changed, 45 insertions(+), 45 deletions(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 8c7bcaad68c9e4..7d1ec37ed76785 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -236,10 +236,14 @@ def __repr__(self): return "<{}.parents>".format(type(self._path).__name__) -class _BasePurePath: - """Base class for manipulating paths using only lexical operations. +class PurePath: + """Base class for manipulating paths without I/O. - This class does not provide the methods __fspath__, __bytes__ or as_uri. + PurePath represents a filesystem path and offers operations which + don't imply any actual filesystem I/O. Depending on your system, + instantiating a PurePath will return either a PurePosixPath or a + PureWindowsPath object. You can also instantiate either of these classes + directly, regardless of your system. """ __slots__ = ( @@ -283,6 +287,16 @@ class _BasePurePath: ) _flavour = os.path + def __new__(cls, *args, **kwargs): + """Construct a PurePath from one or several strings and or existing + PurePath objects. The strings and path objects are combined so as + to yield a canonicalized path, which is incorporated into the + new PurePath object. + """ + if cls is PurePath: + cls = PureWindowsPath if os.name == 'nt' else PurePosixPath + return object.__new__(cls) + def __reduce__(self): # Using the parts tuple helps share interned path parts # when pickling related paths. @@ -291,7 +305,7 @@ def __reduce__(self): def __init__(self, *args): paths = [] for arg in args: - if isinstance(arg, _BasePurePath): + if isinstance(arg, PurePath): if arg._flavour is ntpath and self._flavour is posixpath: # GH-103631: Convert separators for backwards compatibility. paths.extend(path.replace('\\', '/') for path in arg._raw_paths) @@ -418,7 +432,7 @@ def _lines(self): return self._lines_cached def __eq__(self, other): - if not isinstance(other, _BasePurePath): + if not isinstance(other, PurePath): return NotImplemented return self._str_normcase == other._str_normcase and self._flavour is other._flavour @@ -430,22 +444,22 @@ def __hash__(self): return self._hash def __lt__(self, other): - if not isinstance(other, _BasePurePath) or self._flavour is not other._flavour: + if not isinstance(other, PurePath) or self._flavour is not other._flavour: return NotImplemented return self._parts_normcase < other._parts_normcase def __le__(self, other): - if not isinstance(other, _BasePurePath) or self._flavour is not other._flavour: + if not isinstance(other, PurePath) or self._flavour is not other._flavour: return NotImplemented return self._parts_normcase <= other._parts_normcase def __gt__(self, other): - if not isinstance(other, _BasePurePath) or self._flavour is not other._flavour: + if not isinstance(other, PurePath) or self._flavour is not other._flavour: return NotImplemented return self._parts_normcase > other._parts_normcase def __ge__(self, other): - if not isinstance(other, _BasePurePath) or self._flavour is not other._flavour: + if not isinstance(other, PurePath) or self._flavour is not other._flavour: return NotImplemented return self._parts_normcase >= other._parts_normcase @@ -680,7 +694,7 @@ def match(self, path_pattern, *, case_sensitive=None): """ Return True if this path matches the given pattern. """ - if not isinstance(path_pattern, _BasePurePath): + if not isinstance(path_pattern, PurePath): path_pattern = self.with_segments(path_pattern) if case_sensitive is None: case_sensitive = _is_case_sensitive(self._flavour) @@ -693,27 +707,13 @@ def match(self, path_pattern, *, case_sensitive=None): raise ValueError("empty pattern") -class PurePath(_BasePurePath): - """Base class for manipulating paths without I/O. +class _PurePathExt(PurePath): + """PurePath subclass that adds some non-lexical methods. - PurePath represents a filesystem path and offers operations which - don't imply any actual filesystem I/O. Depending on your system, - instantiating a PurePath will return either a PurePosixPath or a - PureWindowsPath object. You can also instantiate either of these classes - directly, regardless of your system. + This class provides the methods __fspath__, __bytes__ and as_uri. """ __slots__ = () - def __new__(cls, *args, **kwargs): - """Construct a PurePath from one or several strings and or existing - PurePath objects. The strings and path objects are combined so as - to yield a canonicalized path, which is incorporated into the - new PurePath object. - """ - if cls is PurePath: - cls = PureWindowsPath if os.name == 'nt' else PurePosixPath - return object.__new__(cls) - def __fspath__(self): return str(self) @@ -745,10 +745,10 @@ def as_uri(self): # Subclassing os.PathLike makes isinstance() checks slower, # which in turn makes Path construction slower. Register instead! -os.PathLike.register(PurePath) +os.PathLike.register(_PurePathExt) -class PurePosixPath(PurePath): +class PurePosixPath(_PurePathExt): """PurePath subclass for non-Windows systems. On a POSIX system, instantiating a PurePath should return this object. @@ -758,7 +758,7 @@ class PurePosixPath(PurePath): __slots__ = () -class PureWindowsPath(PurePath): +class PureWindowsPath(_PurePathExt): """PurePath subclass for Windows systems. On a Windows system, instantiating a PurePath should return this object. @@ -771,7 +771,7 @@ class PureWindowsPath(PurePath): # Filesystem-accessing classes -class Path(PurePath): +class Path(_PurePathExt): """PurePath subclass that can make system calls. Path represents a filesystem path but unlike PurePath, also offers diff --git a/Lib/test/test_pathlib.py b/Lib/test/test_pathlib.py index 4d44527464f142..c4ff624150480d 100644 --- a/Lib/test/test_pathlib.py +++ b/Lib/test/test_pathlib.py @@ -39,8 +39,8 @@ # Tests for the pure classes. # -class BasePurePathTest(unittest.TestCase): - cls = pathlib._BasePurePath +class PurePathTest(unittest.TestCase): + cls = pathlib.PurePath # Keys are canonical paths, values are list of tuples of arguments # supposed to produce equal paths. @@ -79,6 +79,14 @@ def test_constructor_common(self): self.assertEqual(P(P('a'), P('b'), P('c')), P(FakePath("a/b/c"))) self.assertEqual(P(P('./a:b')), P('./a:b')) + def test_concrete_class(self): + if self.cls is pathlib.PurePath: + expected = pathlib.PureWindowsPath if os.name == 'nt' else pathlib.PurePosixPath + else: + expected = self.cls + p = self.cls('a') + self.assertIs(type(p), expected) + def test_different_flavours_unequal(self): p = self.cls('a') if p._flavour is posixpath: @@ -737,16 +745,8 @@ def test_pickling_common(self): self.assertEqual(str(pp), str(p)) -class PurePathTest(BasePurePathTest): - cls = pathlib.PurePath - - def test_concrete_class(self): - if self.cls is pathlib.PurePath: - expected = pathlib.PureWindowsPath if os.name == 'nt' else pathlib.PurePosixPath - else: - expected = self.cls - p = self.cls('a') - self.assertIs(type(p), expected) +class PurePathExtTest(PurePathTest): + cls = pathlib._PurePathExt def test_fspath_common(self): P = self.cls @@ -767,7 +767,7 @@ def test_as_uri_common(self): P().as_uri() -class PurePosixPathTest(PurePathTest): +class PurePosixPathTest(PurePathExtTest): cls = pathlib.PurePosixPath def test_drive_root_parts(self): @@ -861,7 +861,7 @@ def test_parse_windows_path(self): self.assertEqual(p, pp) -class PureWindowsPathTest(PurePathTest): +class PureWindowsPathTest(PurePathExtTest): cls = pathlib.PureWindowsPath equivalences = PurePathTest.equivalences.copy() From 0ea24cc2e7355627ba97d64aa69bdb6bba56b669 Mon Sep 17 00:00:00 2001 From: barneygale Date: Fri, 23 Jun 2023 17:24:41 +0100 Subject: [PATCH 13/15] Move as_uri() back --- Lib/pathlib.py | 45 +++++++++++++++++++--------------------- Lib/test/test_pathlib.py | 14 ++++++------- 2 files changed, 28 insertions(+), 31 deletions(-) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 7d1ec37ed76785..f87fb29133c70e 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -400,6 +400,26 @@ def as_posix(self): def __repr__(self): return "{}({!r})".format(self.__class__.__name__, self.as_posix()) + 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") + + drive = self.drive + if len(drive) == 2 and drive[1] == ':': + # It's a path on a local drive => 'file:///c:/a/b' + prefix = 'file:///' + drive + path = self.as_posix()[2:] + elif drive: + # It's a path on a network drive => 'file://host/share/a/b' + 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 _str_normcase(self): # String with normalized case, for hashing and equality checks @@ -708,10 +728,7 @@ def match(self, path_pattern, *, case_sensitive=None): class _PurePathExt(PurePath): - """PurePath subclass that adds some non-lexical methods. - - This class provides the methods __fspath__, __bytes__ and as_uri. - """ + """PurePath subclass that provides __fspath__ and __bytes__ methods.""" __slots__ = () def __fspath__(self): @@ -722,26 +739,6 @@ def __bytes__(self): recommended to use under Unix.""" return os.fsencode(self) - 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") - - drive = self.drive - if len(drive) == 2 and drive[1] == ':': - # It's a path on a local drive => 'file:///c:/a/b' - prefix = 'file:///' + drive - path = self.as_posix()[2:] - elif drive: - # It's a path on a network drive => 'file://host/share/a/b' - 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)) - # Subclassing os.PathLike makes isinstance() checks slower, # which in turn makes Path construction slower. Register instead! diff --git a/Lib/test/test_pathlib.py b/Lib/test/test_pathlib.py index c4ff624150480d..f18e4fe0e571fd 100644 --- a/Lib/test/test_pathlib.py +++ b/Lib/test/test_pathlib.py @@ -266,6 +266,13 @@ def test_as_posix_common(self): self.assertEqual(P(pathstr).as_posix(), pathstr) # Other tests for as_posix() are in test_equivalences(). + def test_as_uri_common(self): + P = self.cls + with self.assertRaises(ValueError): + P('a').as_uri() + with self.assertRaises(ValueError): + P().as_uri() + def test_repr_common(self): for pathstr in ('a', 'a/b', 'a/b/c', '/', '/a/b', '/a/b/c'): with self.subTest(pathstr=pathstr): @@ -759,13 +766,6 @@ def test_bytes_common(self): P = self.cls self.assertEqual(bytes(P('a/b')), b'a' + sep + b'b') - def test_as_uri_common(self): - P = self.cls - with self.assertRaises(ValueError): - P('a').as_uri() - with self.assertRaises(ValueError): - P().as_uri() - class PurePosixPathTest(PurePathExtTest): cls = pathlib.PurePosixPath From 342e64b1b53eeb4810c8b01236419723cfa32d05 Mon Sep 17 00:00:00 2001 From: barneygale Date: Fri, 23 Jun 2023 17:36:53 +0100 Subject: [PATCH 14/15] Adjust docs --- Doc/library/pathlib.rst | 55 ++++++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/Doc/library/pathlib.rst b/Doc/library/pathlib.rst index ad801d5d7cdc4b..7b4c48bb67ec08 100644 --- a/Doc/library/pathlib.rst +++ b/Doc/library/pathlib.rst @@ -106,9 +106,9 @@ we also call *flavours*: PurePosixPath('setup.py') Each element of *pathsegments* can be either a string representing a - path segment, or an object implementing the :class:`os.PathLike` interface + path segment, an object implementing the :class:`os.PathLike` interface where the :meth:`~os.PathLike.__fspath__` method returns a string, - such as another path object:: + or another path object:: >>> PurePath('foo', 'some/path', 'bar') PurePosixPath('foo/some/path/bar') @@ -151,11 +151,6 @@ we also call *flavours*: to ``PurePosixPath('bar')``, which is wrong if ``foo`` is a symbolic link to another directory) - Pure path objects implement the :class:`os.PathLike` interface, allowing them - to be used anywhere the interface is accepted. - - .. versionchanged:: 3.6 - Added support for the :class:`os.PathLike` interface. .. class:: PurePosixPath(*pathsegments) @@ -232,14 +227,6 @@ relative path (e.g., ``r'\foo'``):: >>> PureWindowsPath('c:/Windows', '/Program Files') PureWindowsPath('c:/Program Files') -A path object can be used anywhere an object implementing :class:`os.PathLike` -is accepted:: - - >>> import os - >>> p = PurePath('/etc') - >>> os.fspath(p) - '/etc' - The string representation of a path is the raw filesystem path itself (in native form, e.g. with backslashes under Windows), which you can pass to any function taking a file path as a string:: @@ -251,16 +238,6 @@ pass to any function taking a file path as a string:: >>> str(p) 'c:\\Program Files' -Similarly, calling :class:`bytes` on a path gives the raw filesystem path as a -bytes object, as encoded by :func:`os.fsencode`:: - - >>> bytes(p) - b'/etc' - -.. note:: - Calling :class:`bytes` is only recommended under Unix. Under Windows, - the unicode form is the canonical representation of filesystem paths. - Accessing individual parts ^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -781,6 +758,34 @@ bugs or failures in your application):: NotImplementedError: cannot instantiate 'WindowsPath' on your system +Operators +^^^^^^^^^ + +Concrete path objects implement the :class:`os.PathLike` interface, +allowing them to be used anywhere the interface is accepted:: + + >>> import os + >>> p = Path('/etc') + >>> os.fspath(p) + '/etc' + +.. versionchanged:: 3.6 + Added support for the :class:`os.PathLike` interface. + +Calling :class:`bytes` on a concrete path gives the raw filesystem path as a +bytes object, as encoded by :func:`os.fsencode`:: + + >>> bytes(p) + b'/etc' + +.. note:: + Calling :class:`bytes` is only recommended under Unix. Under Windows, + the unicode form is the canonical representation of filesystem paths. + +For backwards compatibility, these operations are also supported by +instances of :class:`PurePosixPath` and :class:`PureWindowsPath`. + + Methods ^^^^^^^ From 90bc6b36f3fabd774bd0e6272bbb46f692094a33 Mon Sep 17 00:00:00 2001 From: barneygale Date: Fri, 23 Jun 2023 17:43:58 +0100 Subject: [PATCH 15/15] Add tests --- Lib/test/test_pathlib.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Lib/test/test_pathlib.py b/Lib/test/test_pathlib.py index f18e4fe0e571fd..2512d1b4d88a9c 100644 --- a/Lib/test/test_pathlib.py +++ b/Lib/test/test_pathlib.py @@ -1543,6 +1543,17 @@ class cls(pathlib.PurePath): # repr() roundtripping is not supported in custom subclass. test_repr_roundtrips = None + def test_not_path_like(self): + p = self.cls() + self.assertNotIsInstance(p, os.PathLike) + with self.assertRaises(TypeError): + os.fspath(p) + + def test_not_bytes_like(self): + p = self.cls() + with self.assertRaises(TypeError): + bytes(p) + @only_posix class PosixPathAsPureTest(PurePosixPathTest):