Support subclassing pathlib.Path

pathlib.Path is incredibly useful. However subclassing it is a bit difficult since Path generates either a WindowsPath or PosixPath ( or equivalent respective PurePath). It may be useful to subclass Path if someone wants to add additional methods / attributes e.g. a required field if one wants to make an expected path element.

from dataclasses import dataclass, field, KW_ONLY
from typing import Optional, Union
from pathlib import Path, PurePath,  _PosixFlavour, PosixPath, WindowsPath, _posix_flavour, _Flavour, _windows_flavour
from enum import StrEnum


class OSFlavour(StrEnum):
    NT = 'nt'
    POSIX = 'posix'

    def __str__(self):
        _str = self.value
        if _str == self.NT: _str = 'windows'
        return _str
    
    def __repr__(self):
        _str = self.value
        if _str == self.NT: _str = 'windows'
        return _str
    
    @classmethod
    def on_posix():
        return os.name == 'posix'
    
    @classmethod
    def on_windows():
        return os.name == 'nt'
    
    @classmethod
    def is_posix(cls, other: Union[str, 'OSFlavour']) -> bool:
        if isinstance(other, cls): other = other.value
        return other == cls.POSIX and cls.on_posix()
    
    @classmethod
    def is_windows(cls, other: Union[str, 'OSFlavour']) -> bool:
        if isinstance(other, cls): other = other.value
        return other == cls.NT and cls.on_windows()
    
    def am_posix(self):
        return OSFlavour.is_posix(self.value)
    
    def am_windows(self):
        return OSFlavour.is_windows(self.value)
    
    @classmethod
    def get_flavour(cls):
        if cls.on_posix(): return _posix_flavour
        if cls.on_windows(): return _windows_flavour
        raise ValueError(f'os.name {os.name} not recognized')


import os
from dataclasses import dataclass, field
from pathlib import Path, PurePath, PosixPath, WindowsPath

POSIX = 'posix'
NT = 'nt'

def is_posix():
    return os.name == 'posix'

def is_windows():
    return os.name == 'nt'

def get_flavour():
    if is_posix(): return _posix_flavour
    if is_windows(): return _windows_flavour
    raise ValueError(f'os.name {os.name} not recognized')

@dataclass
class BasePathEntry(PurePath):
    required: bool = False
    _flavour: '_Flavour' = field(default_factory=get_flavour, init=False, repr=False)

@dataclass
class PosixPathEntry(BasePathEntry, PosixPath):
    __repr__ = lambda self: f'PosixPathEntry({self.as_posix()})'

@dataclass
class WindowsPathEntry(BasePathEntry, WindowsPath):
    __repr__ = lambda self: f'WindowsPathEntry({self.as_posix()})'

@dataclass
class PathEntry(Path):
    _flavour: '_Flavour' = field(default_factory=get_flavour, init=False, repr=False)

    @classmethod
    def _from_parts(cls, *args, **kwargs):
        self = super()._from_parts(*args)
        return self

    def __new__(cls, *args, **kwargs):
        required = kwargs.pop('required', False)
        if cls is PathEntry:
            cls = WindowsPathEntry if is_windows() else PosixPathEntry
        self = cls._from_parts(*args, **kwargs)
        self.required = required
        if not self._flavour.is_supported:
            raise NotImplementedError(f"Cannot instantiate {cls.__name__} on your system")
        return self

> Blockquote

This will be possible in newer python releases thanks to @barneygale

He’s kept what’s amounted to a little blog, imo, and feature update here

Subclassing from pathlib.Path should work fine from Python 3.12 (currently in beta) - your subclass will use the current system’s “flavour”, similar to what happens when you attempt to initialise Path.

Resolved issue here, for reference: https://github.com/python/cpython/issues/68320

3 Likes

@barneygale That is super! It just doesn’t help now >.<

In any case two small things.

In the mean time I am using this little hack

from pathlib import Path
from typing import Union, Any, Callable
from dataclasses import dataclass, field, fields

@dataclass
class PathLibPath:
    path: Path = field(default_factory=Path)

    def __post_init__(self):
        if isinstance(self.path, str):
            self.path = Path(self.path)
        self._other_params = {f.name: getattr(self, f.name) for f in fields(self) if f.name != "path"}

    def __repr__(self):
        params = ', '.join([f"{k}={repr(v)}" for k, v in self._other_params.items()])
        return f"{self.__class__.__name__}('{self.path.as_posix()}', {params})"

    def wrap_path_method(self, attr: Callable) -> Callable:
        def newfunc(*args, **kwargs):
            result = attr(*args, **kwargs)
            
            if isinstance(result, Path):
                # Create an instance of the same class as `self`
                return self.__class__(str(result), **self._other_params)                  
            return result
            
            # Copy docstring
            newfunc.__doc__ = attr.__doc__  
            # Copy annotations
            newfunc.__annotations__ = getattr(attr, '__annotations__', {})

        return newfunc

    def __getattr__(self, name: str) -> Any:
        attr = getattr(self.path, name)
        if callable(attr):
            return self.wrap_path_method(attr)
        return attr

that favors simplicity instead of configurability.

Secondly, as a core dev, what do you think about including an enum for OS Flavours?

from enum import StrEnum
from pathlib import _posix_flavour, _windows_flavour
NT = 'nt'
POSIX = 'posix'
WINDOWS = 'windows'
class OSFlavour(StrEnum):
    NT = NT
    POSIX = POSIX

    def __str__(self):
        _str = self.value
        if _str == self.NT: _str = WINDOWS
        return _str
    
    def __repr__(self):
        _str = self.value
        if _str == self.NT: _str = WINDOWS
        return _str
    
    @classmethod
    def on_posix():
        return os.name == POSIX
    
    @classmethod
    def on_windows():
        return os.name == NT
    
    @classmethod
    def is_posix(cls, other: Union[str, 'OSFlavour']) -> bool:
        if isinstance(other, cls): other = other.value
        return other == cls.POSIX and cls.on_posix()
    
    @classmethod
    def is_windows(cls, other: Union[str, 'OSFlavour']) -> bool:
        if isinstance(other, cls): other = other.value
        return other == cls.NT and cls.on_windows()
    
    def am_posix(self):
        return OSFlavour.is_posix(self.value)
    
    def am_windows(self):
        return OSFlavour.is_windows(self.value)
    
    @classmethod
    def get_flavour(cls):
        if cls.on_posix(): return _posix_flavour
        if cls.on_windows(): return _windows_flavour
        raise ValueError(f'os.name {os.name} not recognized')

In 3.12+, the PurePath._flavour attribute is set to either posixpath or ntpath, which are modules providing os.path on POSIX and Windows respectively. All OS-specific behaviour in pathlib is now driven by this attribute. I think this works quite a lot like the enum you’re suggesting.

I’m hoping to make that public in 3.13, so this sort of thing would be possible:

path = get_a_path_object()
path.flavour is posixpath  # is it posix-flavoured?
path.flavour is ntpath     # is it windows-flavoured?
path.flavour is os.path    # does the flavour match the current OS?

Currently you could do this:

isinstance(path, PurePosixPath)    # is it posix-flavoured?
isinstance(path, PureWindowsPath)  # is it windows-flavoured?

… but in 3.12 it misses direct subclasses of PurePath and Path. There’s an issue logged about that here: Support for detecting the flavour of pathlib subclasses · Issue #100502 · python/cpython · GitHub

Hmmm… will there be a need to know all flavours or which flavours are available?

I like this

path = get_a_path_object()
path.flavour is posixpath  # is it posix-flavoured?
path.flavour is ntpath     # is it windows-flavoured?
path.flavour is os.path    # does the flavour match the current OS?

As that was part of the main think I was considering. Having a generic way of checking flavour / comparing them.

1 Like

There only 2 flavors, windows and everything else, posix.

1 Like

I’ve opened a PR to make the _flavour attribute public. Now seems as good a time as any!