diff --git a/Doc/conf.py b/Doc/conf.py index 2a1d0e3dfd873e..c90db7b72479b2 100644 --- a/Doc/conf.py +++ b/Doc/conf.py @@ -22,6 +22,10 @@ import _tkinter except ImportError: _tkinter = None +from os import name as system_name +is_posix = system_name == 'posix' +is_windows = system_name == 'nt' +del system_name ''' manpages_url = 'https://manpages.debian.org/{path}' diff --git a/Doc/library/pathlib.rst b/Doc/library/pathlib.rst index b6507eb4d6fa2c..c4e7a1d69040a9 100644 --- a/Doc/library/pathlib.rst +++ b/Doc/library/pathlib.rst @@ -273,7 +273,7 @@ Methods and properties .. testsetup:: - from pathlib import PurePath, PurePosixPath, PureWindowsPath + from pathlib import PurePath, PurePosixPath, PureWindowsPath, Path Pure paths provide the following methods and properties: @@ -1229,6 +1229,56 @@ call fails (for example because the path doesn't exist). .. versionchanged:: 3.10 The *newline* parameter was added. +Subclassing and Extensibility +----------------------------- + +Both :class:`PurePath` and :class:`Path` are directly subclassable and extensible as you +see fit: + + >>> class MyPath(Path): + ... def my_method(self, *args, **kwargs): + ... ... # Platform agnostic implementation + +.. note:: + Unlike :class:`PurePath` or :class:`Path`, instantiating the derived + class will not generate a differently named class: + + .. doctest:: + :pyversion: > 3.11 + :skipif: is_windows + + >>> Path('.') # On POSIX + PosixPath('.') + >>> MyPath('.') + MyPath('.') + + Despite this, the subclass will otherwise match the class that would be + returned by the factory on your particular system type. For instance, + when instantiated on a POSIX system: + + .. doctest:: + :pyversion: > 3.11 + :skipif: is_windows + + >>> [Path('/dir').is_absolute, MyPath('/dir').is_absolute()] + [True, True] + >>> [Path().home().drive, MyPath().home().drive] + ['', ''] + + However on Windows, the *same code* will instead return values which + apply to that system: + + .. doctest:: + :pyversion: > 3.11 + :skipif: is_posix + + >>> [Path('/dir').is_absolute(), MyPath('/dir').is_absolute()] + [False, False] + >>> [Path().home().drive, MyPath().home().drive] + ['C:', 'C:'] + +.. versionadded:: 3.11 + Correspondence to tools in the :mod:`os` module ----------------------------------------------- diff --git a/Lib/pathlib.py b/Lib/pathlib.py index 8e6eb48b9767ca..913c7170de3c56 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py @@ -325,21 +325,24 @@ def touch(self, path, mode=0o666, exist_ok=True): readlink = os.readlink else: def readlink(self, path): - raise NotImplementedError("os.readlink() not available on this system") + raise NotImplementedError("os.readlink() is not available " + "on this system") def owner(self, path): try: import pwd return pwd.getpwuid(self.stat(path).st_uid).pw_name except ImportError: - raise NotImplementedError("Path.owner() is unsupported on this system") + raise NotImplementedError(f"{self.__class__.__name__}.owner() " + f"is unsupported on this system") def group(self, path): try: import grp return grp.getgrgid(self.stat(path).st_gid).gr_name except ImportError: - raise NotImplementedError("Path.group() is unsupported on this system") + raise NotImplementedError(f"{self.__class__.__name__}.group() " + f"is unsupported on this system") getcwd = os.getcwd @@ -545,10 +548,23 @@ def __new__(cls, *args): 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 + if not hasattr(cls, '_flavour'): + is_posix = os.name == 'posix' + cls._flavour = _posix_flavour if is_posix else _windows_flavour return cls._from_parts(args) + def __init__(self, *pathsegments, **kwargs): + # __init__ was empty for 8 years, therefore one should avoid + # making any assumption below that super().__init__() + # will be called outside of the code in pathlib. + if self.__class__ is PurePath: + self._masquerade() + + def _masquerade(self): + is_posix_flavoured = self._flavour.__class__ == _PosixFlavour + disguise_cls = PurePosixPath if is_posix_flavoured else PureWindowsPath + self.__class__ = disguise_cls + def __reduce__(self): # Using the parts tuple helps share interned path parts # when pickling related paths. @@ -939,17 +955,24 @@ class Path(PurePath): object. You can also instantiate a PosixPath or WindowsPath directly, but cannot instantiate a WindowsPath on a POSIX system or vice versa. """ + _flavour = None _accessor = _normal_accessor __slots__ = () 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: + if not hasattr(cls, '_flavour') or cls._flavour is None: + is_posix = os.name == 'posix' + cls._flavour = _posix_flavour if is_posix else _windows_flavour + if not cls._flavour.is_supported: raise NotImplementedError("cannot instantiate %r on your system" % (cls.__name__,)) - return self + return cls._from_parts(args) + + def __init__(self, *pathsegments, **kwargs): + # Similar to PurePath.__init__, avoid assuming that this will be + # called via super() outside of pathlib. + if self.__class__ is Path: + self._masquerade() def _make_child_relpath(self, part): # This is an optimization used for dir walking. `part` must be @@ -957,6 +980,11 @@ def _make_child_relpath(self, part): parts = self._parts + [part] return self._from_parsed_parts(self._drv, self._root, parts) + def _masquerade(self): + is_posix_flavoured = self._flavour.__class__ == _PosixFlavour + disguise_cls = PosixPath if is_posix_flavoured else WindowsPath + self.__class__ = disguise_cls + def __enter__(self): return self @@ -965,7 +993,7 @@ def __exit__(self, t, v, tb): # In previous versions of pathlib, this method marked this path as # closed; subsequent attempts to perform I/O would raise an IOError. # This functionality was never documented, and had the effect of - # making Path objects mutable, contrary to PEP 428. In Python 3.9 the + # making path objects mutable, contrary to PEP 428. In Python 3.9 the # _closed attribute was removed, and this method made a no-op. # This method and __enter__()/__exit__() should be deprecated and # removed in the future. @@ -1012,7 +1040,7 @@ def glob(self, pattern): """Iterate over this subtree and yield all existing files (of any kind, including directories) matching the given relative pattern. """ - sys.audit("pathlib.Path.glob", self, pattern) + sys.audit(f"pathlib.{self.__class__.__name__}.glob", self, pattern) if not pattern: raise ValueError("Unacceptable pattern: {!r}".format(pattern)) drv, root, pattern_parts = self._flavour.parse_parts((pattern,)) @@ -1027,7 +1055,7 @@ def rglob(self, pattern): directories) matching the given relative pattern, anywhere in this subtree. """ - sys.audit("pathlib.Path.rglob", self, pattern) + sys.audit(f"pathlib.{self.__class__.__name__}.rglob", self, pattern) drv, root, pattern_parts = self._flavour.parse_parts((pattern,)) if drv or root: raise NotImplementedError("Non-relative patterns are unsupported") @@ -1215,9 +1243,9 @@ def rename(self, target): The target path may be absolute or relative. Relative paths are interpreted relative to the current working directory, *not* the - directory of the Path object. + directory of this object. - Returns the new Path instance pointing to the target path. + Returns the new class instance pointing to the target path. """ self._accessor.rename(self, target) return self.__class__(target) @@ -1228,9 +1256,9 @@ def replace(self, target): The target path may be absolute or relative. Relative paths are interpreted relative to the current working directory, *not* the - directory of the Path object. + directory of this object. - Returns the new Path instance pointing to the target path. + Returns the new class instance pointing to the target path. """ self._accessor.replace(self, target) return self.__class__(target) @@ -1256,15 +1284,16 @@ def link_to(self, target): Note this function does not make this path a hard link to *target*, despite the implication of the function and argument names. The order - of arguments (target, link) is the reverse of Path.symlink_to, but + of arguments (target, link) is the reverse of symlink_to, but matches that of os.link. Deprecated since Python 3.10 and scheduled for removal in Python 3.12. Use `hardlink_to()` instead. """ - warnings.warn("pathlib.Path.link_to() is deprecated and is scheduled " - "for removal in Python 3.12. " - "Use pathlib.Path.hardlink_to() instead.", + classname = self.__class__.__name__ + warnings.warn(f"pathlib.{classname}.link_to() is deprecated and is " + f"scheduled for removal in Python 3.12. " + f"Use pathlib.{classname}.hardlink_to() instead.", DeprecationWarning, stacklevel=2) self._accessor.link(self, target) @@ -1322,6 +1351,9 @@ def is_mount(self): """ Check if this path is a POSIX mount point """ + if os.name != "posix": + raise NotImplementedError(f"{self.__class__.__name__}.is_mount() " + f"is unsupported on this system") # Need to exist and be a dir if not self.exists() or not self.is_dir(): return False @@ -1436,14 +1468,14 @@ class PosixPath(Path, PurePosixPath): On a POSIX system, instantiating a Path should return this object. """ + _flavour = _posix_flavour __slots__ = () + class WindowsPath(Path, PureWindowsPath): """Path subclass for Windows systems. On a Windows system, instantiating a Path should return this object. """ + _flavour = _windows_flavour __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 54b7977b43f235..6e6e498b704290 100644 --- a/Lib/test/test_pathlib.py +++ b/Lib/test/test_pathlib.py @@ -1296,22 +1296,92 @@ def test_is_reserved(self): # UNC paths are never reserved. self.assertIs(False, P('//my/share/nul/con/aux').is_reserved()) -class PurePathTest(_BasePurePathTest, unittest.TestCase): + +class _PathPurePathCommonTest: + def assertClassProperties(self, instances, correct_cls): + _instances = instances if hasattr(instances, '__iter__') else [instances] + for instance in _instances: + self.assertEqual(instance.__class__, correct_cls) + self.assertEqual( + instance.__repr__(), + f"{correct_cls.__name__}('{instance.as_posix()}')" + ) + self.assertTrue(isinstance(instance, correct_cls)) + self.assertTrue(isinstance(instance, self.cls)) + self.assertTrue(issubclass(instance.__class__, correct_cls)) + self.assertTrue(issubclass(instance.__class__, self.cls)) + self.assertTrue(type(instance), correct_cls) + + def assertNonIONewInstancesClassProperties(self, instance, correct_cls): + self.assertClassProperties(instance.with_name('new_name'), correct_cls) + self.assertClassProperties(instance.with_stem('new_name2'), + correct_cls) + self.assertClassProperties(instance.with_suffix('.ext'), correct_cls) + self.assertClassProperties( + instance.relative_to(instance.drive, instance.root), correct_cls + ) + self.assertClassProperties(instance.joinpath('.'), correct_cls) + self.assertClassProperties(instance.parents, correct_cls) + self.assertClassProperties(instance / "subdir", correct_cls) + self.assertClassProperties("" / instance, correct_cls) + + +class PurePathTest( + _BasePurePathTest, _PathPurePathCommonTest, unittest.TestCase +): cls = pathlib.PurePath - def test_concrete_class(self): - p = self.cls('a') - self.assertIs(type(p), - pathlib.PureWindowsPath if os.name == 'nt' else pathlib.PurePosixPath) + def _get_class_to_generate(self): + is_posix = os.name == 'posix' + return pathlib.PurePosixPath if is_posix else pathlib.PureWindowsPath + + def _get_anti_system_flavour_class(self): + is_posix = os.name == 'posix' + return pathlib.PureWindowsPath if is_posix else pathlib.PurePosixPath + + def test_instance_class_properties(self): + p = self.cls('placeholder') + correct_cls = self._get_class_to_generate() + self.assertClassProperties(p, correct_cls) + self.assertNonIONewInstancesClassProperties(p, correct_cls) + + def test_direct_subclassing(self): + P = self.cls + try: + class Derived(P): + pass + except Exception as e: + self.fail(f"Failed to subclass {P}: {e}") + else: + try: + derived = Derived('non_empty_pathsegment') + except Exception as e: + self.fail("Failed to be able to instantiate a class " + f"derived from {P}: {e}") + else: + # Unlike how PurePath behaves, when instantiated, the + # instances of its user-created direct subclass keep + # their original class name (instead of becoming a + # flavour-named variant). Hence we use Derived here. + correct_cls = Derived + self.assertClassProperties(derived, correct_cls) + self.assertNonIONewInstancesClassProperties(derived, + correct_cls) def test_different_flavours_unequal(self): p = pathlib.PurePosixPath('a') q = pathlib.PureWindowsPath('a') self.assertNotEqual(p, q) - def test_different_flavours_unordered(self): - p = pathlib.PurePosixPath('a') - q = pathlib.PureWindowsPath('a') + def test_subclass_different_flavours_unequal(self): + class Derived(pathlib.PurePath): + pass + p = Derived('a') + PureAntiFlavourPath = self._get_anti_system_flavour_class() + q = PureAntiFlavourPath('a') + self.assertNotEqual(p, q) + + def _test_different_flavours_unordered(self, p, q): with self.assertRaises(TypeError): p < q with self.assertRaises(TypeError): @@ -1321,6 +1391,19 @@ def test_different_flavours_unordered(self): with self.assertRaises(TypeError): p >= q + def test_different_flavours_unordered(self): + p = pathlib.PurePosixPath('a') + q = pathlib.PureWindowsPath('a') + self._test_different_flavours_unordered(p, q) + + def test_subclass_different_flavours_unordered(self): + class Derived(pathlib.PurePath): + pass + p = Derived('a') + PureAntiFlavourPath = self._get_anti_system_flavour_class() + q = PureAntiFlavourPath('a') + self._test_different_flavours_unordered(p, q) + # # Tests for the concrete classes. @@ -2414,17 +2497,67 @@ def test_complex_symlinks_relative_dot_dot(self): self._check_complex_symlinks(os.path.join('dirA', '..')) -class PathTest(_BasePathTest, unittest.TestCase): +class PathTest(_BasePathTest, _PathPurePathCommonTest, unittest.TestCase): cls = pathlib.Path + def _get_class_to_generate(self): + is_posix = os.name == 'posix' + return pathlib.PosixPath if is_posix else pathlib.WindowsPath + + def assertIONoLinkNewInstancesClassProperties(self, instance, correct_cls): + self.assertClassProperties(instance.cwd(), correct_cls) + self.assertClassProperties(instance.home(), correct_cls) + self.assertClassProperties(instance.iterdir(), correct_cls) + self.assertClassProperties(instance.glob('*'), correct_cls) + self.assertClassProperties(instance.rglob('*'), correct_cls) + self.assertClassProperties(instance.absolute(), correct_cls) + self.assertClassProperties(instance.resolve(strict=False), correct_cls) + self.assertClassProperties(instance.expanduser(), correct_cls) + self.assertClassProperties(instance.replace(BASE), correct_cls) + + def test_instance_class_properties(self): + P = self.cls + p = P(BASE) + correct_cls = self._get_class_to_generate() + self.assertClassProperties(p, correct_cls) + self.assertNonIONewInstancesClassProperties(p, correct_cls) + self.assertIONoLinkNewInstancesClassProperties(p, correct_cls) + if os_helper.can_symlink(): + link = P(BASE, "linkA") + self.assertClassProperties(link.readlink(), correct_cls) + + def test_direct_subclassing(self): + P = self.cls + try: + class Derived(P): + pass + except Exception as e: + self.fail(f"Failed to subclass {P}: {e}") + else: + try: + derived_base = Derived(BASE) + except Exception as e: + self.fail("Failed to be able to instantiate a class " + f"derived from {P}: {e}") + else: + # Much like in the original version of this method, + # we use Derived here because unlike Path, user-created + # subclasses of Path keep their names when instantiated + # (instead of becoming a flavour-named variant). + correct_cls = Derived + self.assertClassProperties(derived_base, correct_cls) + self.assertNonIONewInstancesClassProperties(derived_base, + correct_cls) + self.assertIONoLinkNewInstancesClassProperties(derived_base, + correct_cls) + if os_helper.can_symlink(): + derived_link = Derived(BASE, "linkA") + self.assertClassProperties(derived_link.readlink(), + correct_cls) + def test_class_getitem(self): self.assertIs(self.cls[str], self.cls) - def test_concrete_class(self): - p = self.cls('a') - self.assertIs(type(p), - pathlib.WindowsPath if os.name == 'nt' else pathlib.PosixPath) - def test_unsupported_flavour(self): if os.name == 'nt': self.assertRaises(NotImplementedError, pathlib.PosixPath) diff --git a/Misc/ACKS b/Misc/ACKS index 87de95b938c20e..3b7461d3253753 100644 --- a/Misc/ACKS +++ b/Misc/ACKS @@ -545,6 +545,7 @@ Matt Fleming Hernán Martínez Foffani Benjamin Fogle Artem Fokin +Kevin Follstad Arnaud Fontaine Michael Foord Amaury Forgeot d'Arc diff --git a/Misc/NEWS.d/next/Library/2021-06-16-17-38-36.bpo-24132.FqsJWY.rst b/Misc/NEWS.d/next/Library/2021-06-16-17-38-36.bpo-24132.FqsJWY.rst new file mode 100644 index 00000000000000..de79d7029c48d1 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2021-06-16-17-38-36.bpo-24132.FqsJWY.rst @@ -0,0 +1,2 @@ +Add a mechanism and support for direct subclassing of :class:`PurePath` +and :class:`Path` in pathlib.