Skip to content

bpo-24132: Add direct subclassing of PurePath/Path in pathlib #26906

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 7 commits into from
4 changes: 4 additions & 0 deletions Doc/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor Author

@kfollstad kfollstad Jun 25, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or IS_POSIX/IS_WINDOWS? I am unaware of any precedence for the naming convention here. However having the ability to skip tests depending on platform, gives one the ability to actually have sphinx doctest the code in the documentation rather than just skipping it with :: and trusting that it is correct (as is the current practice throughout much of pathlib's documentation).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesnt really matter in this snippet that’s invisible to readers of the doc 🙂

I think lower case looks fine.

'''

manpages_url = 'https://manpages.debian.org/{path}'
Expand Down
52 changes: 51 additions & 1 deletion Doc/library/pathlib.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is surprising to me: python docs document one version, so what is this for?
(also > and not >=?)

: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]
['', '']
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A minor point: the lists here are a little bit distracting (we are looking at methods/properties, not working with lists).
Could use separate lines, or >>> Path().home().drive, MyPath().home().drive (which will have parens in the output but that’s fine, we’re used to commas being sometimes multiple things, sometimes a tuple object).


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
-----------------------------------------------

Expand Down
80 changes: 56 additions & 24 deletions Lib/pathlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -939,24 +955,36 @@ 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
# a single part relative to this path.
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

Expand All @@ -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.
Expand Down Expand Up @@ -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,))
Expand All @@ -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")
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Loading