From 049e49551896f1846c2eb535579118bf70b5aaee Mon Sep 17 00:00:00 2001 From: Seth Morton Date: Sun, 22 Dec 2024 13:57:22 -0800 Subject: [PATCH 01/24] Make annotations in-source for classes.py Remove classes.pyi. --- path/classes.py | 10 +++++++--- path/classes.pyi | 8 -------- 2 files changed, 7 insertions(+), 11 deletions(-) delete mode 100644 path/classes.pyi diff --git a/path/classes.py b/path/classes.py index b6101d0a..2914996b 100644 --- a/path/classes.py +++ b/path/classes.py @@ -1,8 +1,10 @@ import functools +from typing import Any, Callable class ClassProperty(property): - def __get__(self, cls, owner): + def __get__(self, cls: Any, owner: type | None = None) -> Any: + assert self.fget is not None return self.fget.__get__(None, owner)() @@ -12,10 +14,12 @@ class multimethod: instancemethod when invoked from the instance. """ - def __init__(self, func): + func: Callable[..., Any] + + def __init__(self, func: Callable[..., Any]): self.func = func - def __get__(self, instance, owner): + def __get__(self, instance: Any | None, owner: type | None) -> Any: """ If called on an instance, pass the instance as the first argument. diff --git a/path/classes.pyi b/path/classes.pyi deleted file mode 100644 index 0e119d0b..00000000 --- a/path/classes.pyi +++ /dev/null @@ -1,8 +0,0 @@ -from typing import Any, Callable, Optional - -class ClassProperty(property): - def __get__(self, cls: Any, owner: type | None = ...) -> Any: ... - -class multimethod: - def __init__(self, func: Callable[..., Any]): ... - def __get__(self, instance: Any, owner: type | None) -> Any: ... From c544d87662204b484b8fb49475276fe926e81feb Mon Sep 17 00:00:00 2001 From: Seth Morton Date: Sun, 22 Dec 2024 14:09:30 -0800 Subject: [PATCH 02/24] Make annotations in-source for matchers.py Remove matchers.pyi. --- path/matchers.py | 17 ++++++++++++----- path/matchers.pyi | 21 --------------------- 2 files changed, 12 insertions(+), 26 deletions(-) delete mode 100644 path/matchers.pyi diff --git a/path/matchers.py b/path/matchers.py index 20ca92e2..7ddc8772 100644 --- a/path/matchers.py +++ b/path/matchers.py @@ -2,7 +2,10 @@ import fnmatch import ntpath -from typing import Any, overload +from typing import TYPE_CHECKING, Any, Callable, overload + +if TYPE_CHECKING: + from typing_extensions import Literal @overload @@ -36,15 +39,18 @@ class Base: class Null(Base): - def __call__(self, path): + def __call__(self, path: str) -> Literal[True]: return True class Pattern(Base): - def __init__(self, pattern): + pattern: str + _pattern: str + + def __init__(self, pattern: str): self.pattern = pattern - def get_pattern(self, normcase): + def get_pattern(self, normcase: Callable[[str], str]) -> str: try: return self._pattern except AttributeError: @@ -52,7 +58,8 @@ def get_pattern(self, normcase): self._pattern = normcase(self.pattern) return self._pattern - def __call__(self, path): + # NOTE: 'path' should be annotated with Path, but cannot due to circular imports. + def __call__(self, path) -> bool: normcase = getattr(self, 'normcase', path.module.normcase) pattern = self.get_pattern(normcase) return fnmatch.fnmatchcase(normcase(path.name), pattern) diff --git a/path/matchers.pyi b/path/matchers.pyi deleted file mode 100644 index 4c4925d3..00000000 --- a/path/matchers.pyi +++ /dev/null @@ -1,21 +0,0 @@ -from __future__ import annotations - -from typing import Callable - -from typing_extensions import Literal - -from path import Path - -class Base: - pass - -class Null(Base): - def __call__(self, path: str) -> Literal[True]: ... - -class Pattern(Base): - def __init__(self, pattern: str) -> None: ... - def get_pattern(self, normcase: Callable[[str], str]) -> str: ... - def __call__(self, path: Path) -> bool: ... - -class CaseInsensitive(Pattern): - normcase: Callable[[str], str] From ee77cb39efa60e309dddb3fd31f41b1a5f6c5097 Mon Sep 17 00:00:00 2001 From: Seth Morton Date: Sun, 22 Dec 2024 14:25:05 -0800 Subject: [PATCH 03/24] Add annotations to masks.py --- path/masks.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/path/masks.py b/path/masks.py index c7de97ca..2c727bee 100644 --- a/path/masks.py +++ b/path/masks.py @@ -1,9 +1,11 @@ +from __future__ import annotations + import functools import itertools import operator import re -from typing import Any, Callable +from typing import Any, Callable, Iterable, Iterator # from jaraco.functools @@ -13,7 +15,7 @@ def compose(*funcs: Callable[..., Any]) -> Callable[..., Any]: # from jaraco.structures.binary -def gen_bit_values(number): +def gen_bit_values(number: int) -> Iterator[int]: """ Return a zero or one for each bit of a numeric value up to the most significant 1 bit, beginning with the least significant bit. @@ -26,7 +28,12 @@ def gen_bit_values(number): # from more_itertools -def padded(iterable, fillvalue=None, n=None, next_multiple=False): +def padded( + iterable: Iterable[Any], + fillvalue: Any | None = None, + n: int | None = None, + next_multiple: bool = False, +) -> Iterator[Any]: """Yield the elements from *iterable*, followed by *fillvalue*, such that at least *n* items are emitted. @@ -148,14 +155,14 @@ class Permissions(int): """ @property - def symbolic(self): + def symbolic(self) -> str: return ''.join( ['-', val][bit] for val, bit in zip(itertools.cycle('rwx'), self.bits) ) @property - def bits(self): + def bits(self) -> Iterator[int]: return reversed(tuple(padded(gen_bit_values(self), 0, n=9))) - def __str__(self): + def __str__(self) -> str: return self.symbolic From e0be2fc6c0f48da874d5501f57d1b179798dc6a4 Mon Sep 17 00:00:00 2001 From: Seth Morton Date: Sun, 22 Dec 2024 19:43:58 -0800 Subject: [PATCH 04/24] Copy annotations from __init__.pyi to __init__.py __init__.pyi has been removed. Annotations were copied from the stuf file as-is, without changing either the annotations or anything about the source code (with exceptions mentioned below). In some cases, the annotations are not correct (or better ones could be made) or mypy will now flag code inside the annotated methods - these will be corrected in subsequent commits. This commit is intended to serve as a reference point for translation from stub annotations to in-source annotations. The exceptions to as-is copy are the following: - When the annotation for an input parameter was "Path | str" it has been replaced with "str" since "Path" is a subclass of "str" - When the return type was "Path" it has been replaced with "Self" (except in the cases where it truely should be "Path" like in "TempDir") - In cases where the stub contained explicit parameters and the source had "*args, **kwargs", these have been replaced with explicit parameters to match the stub. In several of these cases, this required changing the source code to call the forwarded function with the explict parameters. --- path/__init__.py | 374 +++++++++++++++++++++++++++------------------- path/__init__.pyi | 330 ---------------------------------------- 2 files changed, 220 insertions(+), 484 deletions(-) delete mode 100644 path/__init__.pyi diff --git a/path/__init__.py b/path/__init__.py index fc002f83..de7c2aa3 100644 --- a/path/__init__.py +++ b/path/__init__.py @@ -42,6 +42,7 @@ import tempfile import warnings from numbers import Number +from types import ModuleType, TracebackType with contextlib.suppress(ImportError): import win32security @@ -54,12 +55,17 @@ from typing import ( TYPE_CHECKING, + IO, + Any, Callable, + Generator, + Iterable, Iterator, overload, ) if TYPE_CHECKING: + from typing_extensions import Never, Self from _typeshed import ( OpenBinaryMode, OpenTextMode, @@ -70,6 +76,8 @@ __all__ = ['Path', 'TempDir'] +# Type for the match argument for several methods +_Match = str | Callable[[str], bool] | None LINESEPS = ['\r\n', '\r', '\n'] U_LINESEPS = LINESEPS + ['\u0085', '\u2028', '\u2029'] @@ -111,10 +119,12 @@ class Traversal: False """ - def __init__(self, follow): + def __init__(self, follow: Callable[[Path], bool]): self.follow = follow - def __call__(self, walker): + def __call__( + self, walker: Generator[Path, Callable[[], bool] | None, None] + ) -> Iterator[Path]: traverse = None while True: try: @@ -126,7 +136,7 @@ def __call__(self, walker): traverse = functools.partial(self.follow, item) -def _strip_newlines(lines): +def _strip_newlines(lines: Iterable[str]) -> Iterator[str]: r""" >>> list(_strip_newlines(['Hello World\r\n', 'foo'])) ['Hello World', 'foo'] @@ -150,7 +160,7 @@ class Path(str): the Path instance. """ - module = os.path + module: Any = os.path """ The path module to use for path operations. .. seealso:: :mod:`os.path` @@ -159,7 +169,7 @@ class Path(str): def __new__(cls, other='.'): return super().__new__(cls, other) - def __init__(self, other='.'): + def __init__(self, other: Any = '.') -> None: if other is None: raise TypeError("Invalid initial value for path: None") with contextlib.suppress(AttributeError): @@ -167,7 +177,7 @@ def __init__(self, other='.'): @classmethod @functools.lru_cache - def using_module(cls, module): + def using_module(cls, module: ModuleType) -> type[Self]: subclass_name = cls.__name__ + '_' + module.__name__ bases = (cls,) ns = {'module': module} @@ -175,7 +185,7 @@ def using_module(cls, module): @classes.ClassProperty @classmethod - def _next_class(cls): + def _next_class(cls) -> type[Self]: """ What class should be used to construct new instances from this class """ @@ -183,18 +193,18 @@ def _next_class(cls): # --- Special Python methods. - def __repr__(self): + def __repr__(self) -> str: return f'{type(self).__name__}({super().__repr__()})' # Adding a Path and a string yields a Path. - def __add__(self, more): + def __add__(self, more: str) -> Self: return self._next_class(super().__add__(more)) - def __radd__(self, other): + def __radd__(self, other: str) -> Self: return self._next_class(other.__add__(self)) # The / operator joins Paths. - def __truediv__(self, rel): + def __truediv__(self, rel: str) -> Self: """fp.__truediv__(rel) == fp / rel == fp.joinpath(rel) Join two path components, adding a separator character if @@ -205,7 +215,7 @@ def __truediv__(self, rel): return self._next_class(self.module.join(self, rel)) # The / operator joins Paths the other way around - def __rtruediv__(self, rel): + def __rtruediv__(self, rel: str) -> Self: """fp.__rtruediv__(rel) == rel / fp Join two path components, adding a separator character if @@ -215,12 +225,17 @@ def __rtruediv__(self, rel): """ return self._next_class(self.module.join(rel, self)) - def __enter__(self): + def __enter__(self) -> Self: self._old_dir = self.cwd() os.chdir(self) return self - def __exit__(self, *_): + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: os.chdir(self._old_dir) @classmethod @@ -238,39 +253,39 @@ def home(cls) -> Path: # # --- Operations on Path strings. - def absolute(self): + def absolute(self) -> Self: """.. seealso:: :func:`os.path.abspath`""" return self._next_class(self.module.abspath(self)) - def normcase(self): + def normcase(self) -> Self: """.. seealso:: :func:`os.path.normcase`""" return self._next_class(self.module.normcase(self)) - def normpath(self): + def normpath(self) -> Self: """.. seealso:: :func:`os.path.normpath`""" return self._next_class(self.module.normpath(self)) - def realpath(self): + def realpath(self) -> Self: """.. seealso:: :func:`os.path.realpath`""" return self._next_class(self.module.realpath(self)) - def expanduser(self): + def expanduser(self) -> Self: """.. seealso:: :func:`os.path.expanduser`""" return self._next_class(self.module.expanduser(self)) - def expandvars(self): + def expandvars(self) -> Self: """.. seealso:: :func:`os.path.expandvars`""" return self._next_class(self.module.expandvars(self)) - def dirname(self): + def dirname(self) -> Self: """.. seealso:: :attr:`parent`, :func:`os.path.dirname`""" return self._next_class(self.module.dirname(self)) - def basename(self): + def basename(self) -> Self: """.. seealso:: :attr:`name`, :func:`os.path.basename`""" return self._next_class(self.module.basename(self)) - def expand(self): + def expand(self) -> Self: """Clean up a filename by calling :meth:`expandvars()`, :meth:`expanduser()`, and :meth:`normpath()` on it. @@ -280,7 +295,7 @@ def expand(self): return self.expandvars().expanduser().normpath() @property - def stem(self): + def stem(self) -> str: """The same as :meth:`name`, but with one file extension stripped off. >>> Path('/home/guido/python.tar.gz').stem @@ -289,7 +304,7 @@ def stem(self): base, ext = self.module.splitext(self.name) return base - def with_stem(self, stem): + def with_stem(self, stem: str) -> Self: """Return a new path with the stem changed. >>> Path('/home/guido/python.tar.gz').with_stem("foo") @@ -298,12 +313,12 @@ def with_stem(self, stem): return self.with_name(stem + self.suffix) @property - def suffix(self): + def suffix(self) -> Self: """The file extension, for example ``'.py'``.""" f, suffix = self.module.splitext(self) return suffix - def with_suffix(self, suffix): + def with_suffix(self, suffix: str) -> Self: """Return a new path with the file suffix changed (or added, if none) >>> Path('/home/guido/python.tar.gz').with_suffix(".foo") @@ -323,7 +338,7 @@ def with_suffix(self, suffix): return self.stripext() + suffix @property - def drive(self): + def drive(self) -> Self: """The drive specifier, for example ``'C:'``. This is always empty on systems that don't use drive specifiers. @@ -358,7 +373,7 @@ def drive(self): """, ) - def with_name(self, name): + def with_name(self, name: str) -> Self: """Return a new path with the name changed. >>> Path('/home/guido/python.tar.gz').with_name("foo.zip") @@ -366,7 +381,7 @@ def with_name(self, name): """ return self._next_class(removesuffix(self, self.name) + name) - def splitpath(self): + def splitpath(self) -> tuple[Self, str]: """Return two-tuple of ``.parent``, ``.name``. .. seealso:: :attr:`parent`, :attr:`name`, :func:`os.path.split` @@ -374,7 +389,7 @@ def splitpath(self): parent, child = self.module.split(self) return self._next_class(parent), child - def splitdrive(self): + def splitdrive(self) -> tuple[Self, Self]: """Return two-tuple of ``.drive`` and rest without drive. Split the drive specifier from this path. If there is @@ -386,7 +401,7 @@ def splitdrive(self): drive, rel = self.module.splitdrive(self) return self._next_class(drive), self._next_class(rel) - def splitext(self): + def splitext(self) -> tuple[Self, str]: """Return two-tuple of ``.stripext()`` and ``.ext``. Split the filename extension from this path and return @@ -401,7 +416,7 @@ def splitext(self): filename, ext = self.module.splitext(self) return self._next_class(filename), ext - def stripext(self): + def stripext(self) -> Self: """Remove one file extension from the path. For example, ``Path('/home/guido/python.tar.gz').stripext()`` @@ -410,7 +425,7 @@ def stripext(self): return self.splitext()[0] @classes.multimethod - def joinpath(cls, first, *others): + def joinpath(cls, first: str, *others: str) -> Self: """ Join first to zero or more :class:`Path` components, adding a separator character (:samp:`{first}.module.sep`) @@ -421,7 +436,7 @@ def joinpath(cls, first, *others): """ return cls._next_class(cls.module.join(first, *others)) - def splitall(self): + def splitall(self) -> list[Self | str]: r"""Return a list of the path components in this path. The first item in the list will be a Path. Its value will be @@ -436,17 +451,17 @@ def splitall(self): """ return list(self._parts()) - def parts(self): + def parts(self) -> tuple[Self | str]: """ >>> Path('/foo/bar/baz').parts() (Path('/'), 'foo', 'bar', 'baz') """ return tuple(self._parts()) - def _parts(self): + def _parts(self) -> Iterator[Self | str]: return reversed(tuple(self._parts_iter())) - def _parts_iter(self): + def _parts_iter(self) -> Iterator[Self | str]: loc = self while loc != os.curdir and loc != os.pardir: prev = loc @@ -456,14 +471,14 @@ def _parts_iter(self): yield child yield loc - def relpath(self, start='.'): + def relpath(self, start: str = '.') -> Self: """Return this path as a relative path, based from `start`, which defaults to the current working directory. """ cwd = self._next_class(start) return cwd.relpathto(self) - def relpathto(self, dest): + def relpathto(self, dest: str) -> Self: """Return a relative path from `self` to `dest`. If there is no relative path from `self` to `dest`, for example if @@ -503,7 +518,7 @@ def relpathto(self, dest): # --- Listing, searching, walking, and matching - def iterdir(self, match=None): + def iterdir(self, match: _Match = None) -> Iterator[Self]: """Yields items in this directory. Use :meth:`files` or :meth:`dirs` instead if you want a listing @@ -519,7 +534,7 @@ def iterdir(self, match=None): match = matchers.load(match) return filter(match, (self / child for child in os.listdir(self))) - def dirs(self, *args, **kwargs): + def dirs(self, match: _Match = None) -> list[Self]: """List of this directory's subdirectories. The elements of the list are Path objects. @@ -528,9 +543,9 @@ def dirs(self, *args, **kwargs): Accepts parameters to :meth:`iterdir`. """ - return [p for p in self.iterdir(*args, **kwargs) if p.is_dir()] + return [p for p in self.iterdir(match) if p.is_dir()] - def files(self, *args, **kwargs): + def files(self, match: _Match = None) -> list[Self]: """List of the files in self. The elements of the list are Path objects. @@ -539,9 +554,11 @@ def files(self, *args, **kwargs): Accepts parameters to :meth:`iterdir`. """ - return [p for p in self.iterdir(*args, **kwargs) if p.is_file()] + return [p for p in self.iterdir(match) if p.is_file()] - def walk(self, match=None, errors='strict'): + def walk( + self, match: _Match = None, errors: str = 'strict' + ) -> Generator[Self, Callable[[], bool] | None, None]: """Iterator over files and subdirs, recursively. The iterator yields Path objects naming each child item of @@ -581,15 +598,17 @@ def walk(self, match=None, errors='strict'): if do_traverse: yield from child.walk(errors=errors, match=match) - def walkdirs(self, *args, **kwargs): + def walkdirs(self, match: _Match = None, errors: str = 'strict') -> Iterator[Self]: """Iterator over subdirs, recursively.""" - return (item for item in self.walk(*args, **kwargs) if item.is_dir()) + return (item for item in self.walk(match, errors) if item.is_dir()) - def walkfiles(self, *args, **kwargs): + def walkfiles(self, match: _Match = None, errors: str = 'strict') -> Iterator[Self]: """Iterator over files, recursively.""" - return (item for item in self.walk(*args, **kwargs) if item.is_file()) + return (item for item in self.walk(match, errors) if item.is_file()) - def fnmatch(self, pattern, normcase=None): + def fnmatch( + self, pattern: str, normcase: Callable[[str], str] | None = None + ) -> bool: """Return ``True`` if `self.name` matches the given `pattern`. `pattern` - A filename pattern with wildcards, @@ -608,7 +627,7 @@ def fnmatch(self, pattern, normcase=None): pattern = normcase(pattern) return fnmatch.fnmatchcase(name, pattern) - def glob(self, pattern): + def glob(self, pattern: str) -> list[Self]: """Return a list of Path objects that match the pattern. `pattern` - a path relative to this directory, with wildcards. @@ -625,7 +644,7 @@ def glob(self, pattern): cls = self._next_class return [cls(s) for s in glob.glob(self / pattern)] - def iglob(self, pattern): + def iglob(self, pattern: str) -> Iterator[Self]: """Return an iterator of Path objects that match the pattern. `pattern` - a path relative to this directory, with wildcards. @@ -655,7 +674,7 @@ def open(self, *args, **kwargs): """ return open(self, *args, **kwargs) - def bytes(self): + def bytes(self) -> builtins.bytes: """Open this file, read all bytes, return them as a string.""" with self.open('rb') as f: return f.read() @@ -716,7 +735,7 @@ def chunks(self, size, *args, **kwargs): with self.open(*args, **kwargs) as f: yield from iter(lambda: f.read(size) or None, None) - def write_bytes(self, bytes, append=False): + def write_bytes(self, bytes: builtins.bytes, append: bool = False) -> None: """Open this file and write the given bytes to it. Default behavior is to overwrite any existing file. @@ -725,7 +744,7 @@ def write_bytes(self, bytes, append=False): with self.open('ab' if append else 'wb') as f: f.write(bytes) - def read_text(self, encoding=None, errors=None): + def read_text(self, encoding: str | None = None, errors: str | None = None) -> str: r"""Open this file, read it in, return the content as a string. Optional parameters are passed to :meth:`open`. @@ -735,7 +754,7 @@ def read_text(self, encoding=None, errors=None): with self.open(encoding=encoding, errors=errors) as f: return f.read() - def read_bytes(self): + def read_bytes(self) -> builtins.bytes: r"""Return the contents of this file as bytes.""" with self.open(mode='rb') as f: return f.read() @@ -804,7 +823,12 @@ def write_text( bytes = text.encode(encoding or sys.getdefaultencoding(), errors) self.write_bytes(bytes, append=append) - def lines(self, encoding=None, errors=None, retain=True): + def lines( + self, + encoding: str | None = None, + errors: str | None = None, + retain: bool = True, + ) -> list[str]: r"""Open this file, read all lines, return them in a list. Optional arguments: @@ -824,11 +848,11 @@ def lines(self, encoding=None, errors=None, retain=True): def write_lines( self, - lines, - encoding=None, - errors='strict', + lines: list[str], + encoding: str | None = None, + errors: str = 'strict', *, - append=False, + append: bool = False, ): r"""Write the given lines of text to this file. @@ -852,10 +876,10 @@ def write_lines( f.writelines(self._replace_linesep(lines)) @staticmethod - def _replace_linesep(lines): + def _replace_linesep(lines: Iterable[str]) -> Iterator[str]: return (line + os.linesep for line in _strip_newlines(lines)) - def read_md5(self): + def read_md5(self) -> builtins.bytes: """Calculate the md5 hash for this file. This reads through the entire file. @@ -864,7 +888,7 @@ def read_md5(self): """ return self.read_hash('md5') - def _hash(self, hash_name): + def _hash(self, hash_name: str) -> hashlib._Hash: """Returns a hash object for the file at the current path. `hash_name` should be a hash algo name (such as ``'md5'`` @@ -875,7 +899,7 @@ def _hash(self, hash_name): m.update(chunk) return m - def read_hash(self, hash_name): + def read_hash(self, hash_name) -> builtins.bytes: """Calculate given hash for this file. List of supported hashes can be obtained from :mod:`hashlib` package. @@ -885,7 +909,7 @@ def read_hash(self, hash_name): """ return self._hash(hash_name).digest() - def read_hexhash(self, hash_name): + def read_hexhash(self, hash_name) -> str: """Calculate given hash for this file, returning hexdigest. List of supported hashes can be obtained from :mod:`hashlib` package. @@ -900,7 +924,7 @@ def read_hexhash(self, hash_name): # (e.g. isdir on Windows, Python 3.2.2), and compiled functions don't get # bound. Playing it safe and wrapping them all in method calls. - def isabs(self): + def isabs(self) -> bool: """ >>> Path('.').isabs() False @@ -909,23 +933,23 @@ def isabs(self): """ return self.module.isabs(self) - def exists(self): + def exists(self) -> bool: """.. seealso:: :func:`os.path.exists`""" return self.module.exists(self) - def is_dir(self): + def is_dir(self) -> bool: """.. seealso:: :func:`os.path.isdir`""" return self.module.isdir(self) - def is_file(self): + def is_file(self) -> bool: """.. seealso:: :func:`os.path.isfile`""" return self.module.isfile(self) - def islink(self): + def islink(self) -> bool: """.. seealso:: :func:`os.path.islink`""" return self.module.islink(self) - def ismount(self): + def ismount(self) -> bool: """ >>> Path('.').ismount() False @@ -934,15 +958,15 @@ def ismount(self): """ return self.module.ismount(self) - def samefile(self, other): + def samefile(self, other: str) -> bool: """.. seealso:: :func:`os.path.samefile`""" return self.module.samefile(self, other) - def getatime(self): + def getatime(self) -> float: """.. seealso:: :attr:`atime`, :func:`os.path.getatime`""" return self.module.getatime(self) - def set_atime(self, value): + def set_atime(self, value: Number | datetime.datetime): mtime_ns = self.stat().st_atime_ns self.utime(ns=(_make_timestamp_ns(value), mtime_ns)) @@ -968,11 +992,11 @@ def set_atime(self, value): """, ) - def getmtime(self): + def getmtime(self) -> float: """.. seealso:: :attr:`mtime`, :func:`os.path.getmtime`""" return self.module.getmtime(self) - def set_mtime(self, value): + def set_mtime(self, value: Number | datetime.datetime) -> None: atime_ns = self.stat().st_atime_ns self.utime(ns=(atime_ns, _make_timestamp_ns(value))) @@ -995,7 +1019,7 @@ def set_mtime(self, value): """, ) - def getctime(self): + def getctime(self) -> float: """.. seealso:: :attr:`ctime`, :func:`os.path.getctime`""" return self.module.getctime(self) @@ -1009,7 +1033,7 @@ def getctime(self): """, ) - def getsize(self): + def getsize(self) -> int: """.. seealso:: :attr:`size`, :func:`os.path.getsize`""" return self.module.getsize(self) @@ -1038,7 +1062,14 @@ def permissions(self) -> masks.Permissions: """ return masks.Permissions(self.stat().st_mode) - def access(self, *args, **kwargs): + def access( + self, + mode: int, + *, + dir_fd: int | None = None, + effective_ids: bool = False, + follow_symlinks: bool = True, + ) -> bool: """ Return does the real user have access to this path. @@ -1047,9 +1078,15 @@ def access(self, *args, **kwargs): .. seealso:: :func:`os.access` """ - return os.access(self, *args, **kwargs) + return os.access( + self, + mode, + dir_fd=dir_fd, + effective_ids=effective_ids, + follow_symlinks=follow_symlinks, + ) - def stat(self, *, follow_symlinks=True): + def stat(self, *, follow_symlinks: bool = True) -> os.stat_result: """ Perform a ``stat()`` system call on this path. @@ -1060,7 +1097,7 @@ def stat(self, *, follow_symlinks=True): """ return os.stat(self, follow_symlinks=follow_symlinks) - def lstat(self): + def lstat(self) -> os.stat_result: """ Like :meth:`stat`, but do not follow symbolic links. @@ -1071,7 +1108,7 @@ def lstat(self): """ return os.lstat(self) - def __get_owner_windows(self): # pragma: nocover + def __get_owner_windows(self) -> str: # pragma: nocover r""" Return the name of the owner of this file or directory. Follow symbolic links. @@ -1087,7 +1124,7 @@ def __get_owner_windows(self): # pragma: nocover account, domain, typecode = win32security.LookupAccountSid(None, sid) return domain + '\\' + account - def __get_owner_unix(self): # pragma: nocover + def __get_owner_unix(self) -> str: # pragma: nocover """ Return the name of the owner of this file or directory. Follow symbolic links. @@ -1097,7 +1134,7 @@ def __get_owner_unix(self): # pragma: nocover st = self.stat() return pwd.getpwuid(st.st_uid).pw_name - def __get_owner_not_implemented(self): # pragma: nocover + def __get_owner_not_implemented(self) -> Never: # pragma: nocover raise NotImplementedError("Ownership not available on this platform.") get_owner = ( @@ -1119,7 +1156,7 @@ def __get_owner_not_implemented(self): # pragma: nocover if 'grp' in globals(): # pragma: no cover - def group(self, *, follow_symlinks=True): + def group(self, *, follow_symlinks: bool = True) -> str: """ Return the group name of the file gid. """ @@ -1128,7 +1165,7 @@ def group(self, *, follow_symlinks=True): if hasattr(os, 'statvfs'): - def statvfs(self): + def statvfs(self) -> os.statvfs_result: """Perform a ``statvfs()`` system call on this path. .. seealso:: :func:`os.statvfs` @@ -1137,22 +1174,29 @@ def statvfs(self): if hasattr(os, 'pathconf'): - def pathconf(self, name): + def pathconf(self, name: str | int) -> int: """.. seealso:: :func:`os.pathconf`""" return os.pathconf(self, name) # # --- Modifying operations on files and directories - def utime(self, *args, **kwargs): + def utime( + self, + times: tuple[int, int] | tuple[float, float] | None = None, + *, + ns: tuple[int, int] = ..., + dir_fd: int | None = None, + follow_symlinks: bool = True, + ) -> Self: """Set the access and modified times of this file. .. seealso:: :func:`os.utime` """ - os.utime(self, *args, **kwargs) + os.utime(self, times, ns=ns, dir_fd=dir_fd, follow_symlinks=follow_symlinks) return self - def chmod(self, mode): + def chmod(self, mode: str | int) -> Self: """ Set the mode. May be the new mode (os.chmod behavior) or a `symbolic mode `_. @@ -1173,7 +1217,7 @@ def chmod(self, mode): if hasattr(os, 'chown'): - def chown(self, uid=-1, gid=-1): + def chown(self, uid: str | int = -1, gid: str | int = -1) -> Self: """ Change the owner and group by names or numbers. @@ -1189,17 +1233,17 @@ def resolve_gid(gid): os.chown(self, resolve_uid(uid), resolve_gid(gid)) return self - def rename(self, new): + def rename(self, new: str) -> Self: """.. seealso:: :func:`os.rename`""" os.rename(self, new) return self._next_class(new) - def renames(self, new): + def renames(self, new: str) -> Self: """.. seealso:: :func:`os.renames`""" os.renames(self, new) return self._next_class(new) - def replace(self, target_or_old: Path | str, *args) -> Path: + def replace(self, target_or_old: str, *args) -> Self: """ Replace a path or substitute substrings. @@ -1236,36 +1280,36 @@ def replace(self, target_or_old: Path | str, *args) -> Path: # # --- Create/delete operations on directories - def mkdir(self, mode=0o777): + def mkdir(self, mode: int = 0o777) -> Self: """.. seealso:: :func:`os.mkdir`""" os.mkdir(self, mode) return self - def mkdir_p(self, mode=0o777): + def mkdir_p(self, mode: int = 0o777) -> Self: """Like :meth:`mkdir`, but does not raise an exception if the directory already exists.""" with contextlib.suppress(FileExistsError): self.mkdir(mode) return self - def makedirs(self, mode=0o777): + def makedirs(self, mode: int = 0o777) -> Self: """.. seealso:: :func:`os.makedirs`""" os.makedirs(self, mode) return self - def makedirs_p(self, mode=0o777): + def makedirs_p(self, mode: int = 0o777) -> Self: """Like :meth:`makedirs`, but does not raise an exception if the directory already exists.""" with contextlib.suppress(FileExistsError): self.makedirs(mode) return self - def rmdir(self): + def rmdir(self) -> Self: """.. seealso:: :func:`os.rmdir`""" os.rmdir(self) return self - def rmdir_p(self): + def rmdir_p(self) -> Self: """Like :meth:`rmdir`, but does not raise an exception if the directory is not empty or does not exist.""" suppressed = FileNotFoundError, FileExistsError, DirectoryNotEmpty @@ -1274,12 +1318,12 @@ def rmdir_p(self): self.rmdir() return self - def removedirs(self): + def removedirs(self) -> Self: """.. seealso:: :func:`os.removedirs`""" os.removedirs(self) return self - def removedirs_p(self): + def removedirs_p(self) -> Self: """Like :meth:`removedirs`, but does not raise an exception if the directory is not empty or does not exist.""" with contextlib.suppress(FileExistsError, DirectoryNotEmpty): @@ -1289,7 +1333,7 @@ def removedirs_p(self): # --- Modifying operations on files - def touch(self): + def touch(self) -> Self: """Set the access/modified times of this file to the current time. Create the file if it does not exist. """ @@ -1297,12 +1341,12 @@ def touch(self): os.utime(self, None) return self - def remove(self): + def remove(self) -> Self: """.. seealso:: :func:`os.remove`""" os.remove(self) return self - def remove_p(self): + def remove_p(self) -> Self: """Like :meth:`remove`, but does not raise an exception if the file does not exist.""" with contextlib.suppress(FileNotFoundError): @@ -1322,7 +1366,7 @@ def hardlink_to(self, target: str) -> None: """ os.link(target, self) - def link(self, newpath): + def link(self, newpath: str) -> Self: """Create a hard link at `newpath`, pointing to this file. .. seealso:: :func:`os.link` @@ -1338,7 +1382,7 @@ def symlink_to(self, target: str, target_is_directory: bool = False) -> None: """ os.symlink(target, self, target_is_directory) - def symlink(self, newlink=None): + def symlink(self, newlink: str | None = None) -> Self: """Create a symbolic link at `newlink`, pointing here. If newlink is not supplied, the symbolic link will assume @@ -1351,7 +1395,7 @@ def symlink(self, newlink=None): os.symlink(self, newlink) return self._next_class(newlink) - def readlink(self): + def readlink(self) -> Self: """Return the path to which this symbolic link points. The result may be an absolute or a relative path. @@ -1360,7 +1404,7 @@ def readlink(self): """ return self._next_class(removeprefix(os.readlink(self), '\\\\?\\')) - def readlinkabs(self): + def readlinkabs(self) -> Self: """Return the path to which this symbolic link points. The result is always an absolute path. @@ -1384,14 +1428,14 @@ def readlinkabs(self): move = shutil.move rmtree = shutil.rmtree - def rmtree_p(self): + def rmtree_p(self) -> Self: """Like :meth:`rmtree`, but does not raise an exception if the directory does not exist.""" with contextlib.suppress(FileNotFoundError): self.rmtree() return self - def chdir(self): + def chdir(self) -> None: """.. seealso:: :func:`os.chdir`""" os.chdir(self) @@ -1399,11 +1443,12 @@ def chdir(self): def merge_tree( self, - dst, - symlinks=False, + dst: str, + symlinks: bool = False, *, - copy_function=shutil.copy2, - ignore=lambda dir, contents: [], + copy_function: Callable[[str, str], None] = shutil.copy2, + ignore: Callable[[Any, list[str]], list[str] | set[str]] = lambda dir, + contents: [], ): """ Copy entire contents of self to dst, overwriting existing @@ -1449,15 +1494,15 @@ def ignored(item): if hasattr(os, 'chroot'): - def chroot(self): # pragma: nocover + def chroot(self) -> None: # pragma: nocover """.. seealso:: :func:`os.chroot`""" os.chroot(self) if hasattr(os, 'startfile'): - def startfile(self, *args, **kwargs): # pragma: nocover + def startfile(self, operation: str | None = None) -> Self: # pragma: nocover """.. seealso:: :func:`os.startfile`""" - os.startfile(self, *args, **kwargs) + os.startfile(self, operation=operation) return self # in-place re-writing, courtesy of Martijn Pieters @@ -1465,13 +1510,13 @@ def startfile(self, *args, **kwargs): # pragma: nocover @contextlib.contextmanager def in_place( self, - mode='r', - buffering=-1, - encoding=None, - errors=None, - newline=None, - backup_extension=None, - ): + mode: str = 'r', + buffering: int = -1, + encoding: str | None = None, + errors: str | None = None, + newline: str | None = None, + backup_extension: str | None = None, + ) -> Iterator[tuple[IO[Any], IO[Any]]]: """ A context in which a file may be re-written in-place with new content. @@ -1546,7 +1591,7 @@ def in_place( @classes.ClassProperty @classmethod - def special(cls): + def special(cls) -> Callable[[str | None], SpecialResolver]: """ Return a SpecialResolver object suitable referencing a suitable directory for the relevant platform for the given @@ -1573,7 +1618,7 @@ def special(cls): class DirectoryNotEmpty(OSError): @staticmethod @contextlib.contextmanager - def translate(): + def translate() -> Iterator[None]: try: yield except OSError as exc: @@ -1582,7 +1627,7 @@ def translate(): raise -def only_newer(copy_func): +def only_newer(copy_func: Callable[[str, str], None]) -> Callable[[str, str], None]: """ Wrap a copy function (like shutil.copy2) to return the dst if it's newer than the source. @@ -1607,7 +1652,7 @@ class ExtantPath(Path): OSError: does-not-exist does not exist. """ - def _validate(self): + def _validate(self) -> None: if not self.exists(): raise OSError(f"{self} does not exist.") @@ -1622,31 +1667,43 @@ class ExtantFile(Path): FileNotFoundError: does-not-exist does not exist as a file. """ - def _validate(self): + def _validate(self) -> None: if not self.is_file(): raise FileNotFoundError(f"{self} does not exist as a file.") class SpecialResolver: class ResolverScope: - def __init__(self, paths, scope): + def __init__(self, paths: SpecialResolver, scope: str) -> None: self.paths = paths self.scope = scope - def __getattr__(self, class_): + def __getattr__(self, class_: str) -> _MultiPathType: return self.paths.get_dir(self.scope, class_) - def __init__(self, path_class, *args, **kwargs): + def __init__( + self, + path_class: type, + appname: str | None = None, + appauthor: str | None = None, + version: str | None = None, + roaming: bool = False, + multipath: bool = False, + ): appdirs = importlib.import_module('appdirs') - - vars(self).update( - path_class=path_class, wrapper=appdirs.AppDirs(*args, **kwargs) + self.path_class = path_class + self.wrapper = appdirs.AppDirs( + appname=appname, + appauthor=appauthor, + version=version, + roaming=roaming, + multipath=multipath, ) - def __getattr__(self, scope): + def __getattr__(self, scope: str) -> ResolverScope: return self.ResolverScope(self, scope) - def get_dir(self, scope, class_): + def get_dir(self, scope: str, class_: str) -> _MultiPathType: """ Return the callable function from appdirs, but with the result wrapped in self.path_class @@ -1663,28 +1720,32 @@ class Multi: """ @classmethod - def for_class(cls, path_cls): + def for_class(cls, path_cls: type) -> type[_MultiPathType]: name = 'Multi' + path_cls.__name__ return type(name, (cls, path_cls), {}) @classmethod - def detect(cls, input): + def detect(cls, input: str) -> _MultiPathType: if os.pathsep not in input: cls = cls._next_class return cls(input) - def __iter__(self): + def __iter__(self) -> Iterator[Path]: return iter(map(self._next_class, self.split(os.pathsep))) @classes.ClassProperty @classmethod - def _next_class(cls): + def _next_class(cls) -> type[Path]: """ Multi-subclasses should use the parent class """ return next(class_ for class_ in cls.__mro__ if not issubclass(class_, Multi)) +class _MultiPathType(Multi, Path): + pass + + class TempDir(Path): """ A temporary directory via :func:`tempfile.mkdtemp`, and @@ -1707,39 +1768,44 @@ class TempDir(Path): @classes.ClassProperty @classmethod - def _next_class(cls): + def _next_class(cls) -> type[Path]: return Path - def __new__(cls, *args, **kwargs): + def __new__(cls, *args, **kwargs) -> Self: dirname = tempfile.mkdtemp(*args, **kwargs) return super().__new__(cls, dirname) - def __init__(self, *args, **kwargs): + def __init__(self) -> None: pass - def __enter__(self): + def __enter__(self) -> Self: # TempDir should return a Path version of itself and not itself # so that a second context manager does not create a second # temporary directory, but rather changes CWD to the location # of the temporary directory. return self._next_class(self) - def __exit__(self, exc_type, exc_value, traceback): + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: self.rmtree() class Handlers: - def strict(msg): + def strict(msg: str) -> Never: raise - def warn(msg): + def warn(msg: str) -> None: warnings.warn(msg, TreeWalkWarning, stacklevel=2) - def ignore(msg): + def ignore(msg: str) -> None: pass @classmethod - def _resolve(cls, param): + def _resolve(cls, param: str | Callable[[str], None]) -> Callable[[str], None]: if not callable(param) and param not in vars(Handlers): raise ValueError("invalid errors parameter") return vars(cls).get(param, param) diff --git a/path/__init__.pyi b/path/__init__.pyi deleted file mode 100644 index d88187cc..00000000 --- a/path/__init__.pyi +++ /dev/null @@ -1,330 +0,0 @@ -from __future__ import annotations - -import builtins -import contextlib -import os -import sys -from types import ModuleType, TracebackType -from typing import ( - IO, - Any, - AnyStr, - Callable, - Generator, - Iterable, - Iterator, -) - -from typing_extensions import Self - -from . import classes - -# Type for the match argument for several methods -_Match = str | Callable[[str], bool] | Callable[[Path], bool] | None - -class TreeWalkWarning(Warning): - pass - -class Traversal: - follow: Callable[[Path], bool] - - def __init__(self, follow: Callable[[Path], bool]): ... - def __call__( - self, - walker: Generator[Path, Callable[[], bool] | None, None], - ) -> Iterator[Path]: ... - -class Path(str): - module: Any - - def __init__(self, other: Any = ...) -> None: ... - @classmethod - def using_module(cls, module: ModuleType) -> type[Path]: ... - @classes.ClassProperty - @classmethod - def _next_class(cls) -> type[Self]: ... - def __repr__(self) -> str: ... - def __add__(self, more: str) -> Self: ... - def __radd__(self, other: str) -> Self: ... - def __div__(self, rel: str) -> Self: ... - def __truediv__(self, rel: str) -> Self: ... - def __rdiv__(self, rel: str) -> Self: ... - def __rtruediv__(self, rel: str) -> Self: ... - def __enter__(self) -> Self: ... - def __exit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: TracebackType | None, - ) -> None: ... - def absolute(self) -> Self: ... - def normcase(self) -> Self: ... - def normpath(self) -> Self: ... - def realpath(self) -> Self: ... - def expanduser(self) -> Self: ... - def expandvars(self) -> Self: ... - def dirname(self) -> Self: ... - def basename(self) -> Self: ... - def expand(self) -> Self: ... - @property - def stem(self) -> str: ... - def with_stem(self, stem: str) -> Self: ... - @property - def suffix(self) -> Self: ... - def with_suffix(self, suffix: str) -> Self: ... - @property - def drive(self) -> Self: ... - @property - def parent(self) -> Self: ... - @property - def name(self) -> Self: ... - def with_name(self, name: str) -> Self: ... - def splitpath(self) -> tuple[Self, str]: ... - def splitdrive(self) -> tuple[Self, Self]: ... - def splitext(self) -> tuple[Self, str]: ... - def stripext(self) -> Self: ... - @classes.multimethod - def joinpath(cls, first: str, *others: str) -> Self: ... - def splitall(self) -> list[Self | str]: ... - def parts(self) -> tuple[Self | str, ...]: ... - def _parts(self) -> Iterator[Self | str]: ... - def _parts_iter(self) -> Iterator[Self | str]: ... - def relpath(self, start: str = ...) -> Self: ... - def relpathto(self, dest: str) -> Self: ... - - # --- Listing, searching, walking, and matching - def iterdir(self, match: _Match = ...) -> Iterator[Self]: ... - def dirs(self, match: _Match = ...) -> list[Self]: ... - def files(self, match: _Match = ...) -> list[Self]: ... - def walk( - self, - match: _Match = ..., - errors: str = ..., - ) -> Generator[Self, Callable[[], bool] | None, None]: ... - def walkdirs( - self, - match: _Match = ..., - errors: str = ..., - ) -> Iterator[Self]: ... - def walkfiles( - self, - match: _Match = ..., - errors: str = ..., - ) -> Iterator[Self]: ... - def fnmatch( - self, - pattern: Path | str, - normcase: Callable[[str], str] | None = ..., - ) -> bool: ... - def glob(self, pattern: str) -> list[Self]: ... - def iglob(self, pattern: str) -> Iterator[Self]: ... - def bytes(self) -> builtins.bytes: ... - def write_bytes(self, bytes: builtins.bytes, append: bool = ...) -> None: ... - def read_text( - self, encoding: str | None = ..., errors: str | None = ... - ) -> str: ... - def read_bytes(self) -> builtins.bytes: ... - def lines( - self, - encoding: str | None = ..., - errors: str | None = ..., - retain: bool = ..., - ) -> list[str]: ... - def write_lines( - self, - lines: list[str], - encoding: str | None = ..., - errors: str = ..., - *, - append: bool = ..., - ) -> None: ... - def read_md5(self) -> builtins.bytes: ... - def read_hash(self, hash_name: str) -> builtins.bytes: ... - def read_hexhash(self, hash_name: str) -> str: ... - def isabs(self) -> bool: ... - def exists(self) -> bool: ... - def is_dir(self) -> bool: ... - def is_file(self) -> bool: ... - def islink(self) -> bool: ... - def ismount(self) -> bool: ... - def samefile(self, other: str) -> bool: ... - def getatime(self) -> float: ... - @property - def atime(self) -> float: ... - def getmtime(self) -> float: ... - @property - def mtime(self) -> float: ... - def getctime(self) -> float: ... - @property - def ctime(self) -> float: ... - def getsize(self) -> int: ... - @property - def size(self) -> int: ... - def access( - self, - mode: int, - *, - dir_fd: int | None = ..., - effective_ids: bool = ..., - follow_symlinks: bool = ..., - ) -> bool: ... - def stat(self) -> os.stat_result: ... - def lstat(self) -> os.stat_result: ... - def get_owner(self) -> str: ... - @property - def owner(self) -> str: ... - - if sys.platform != 'win32': - def statvfs(self) -> os.statvfs_result: ... - def pathconf(self, name: str | int) -> int: ... - - def utime( - self, - times: tuple[int, int] | tuple[float, float] | None = ..., - *, - ns: tuple[int, int] = ..., - dir_fd: int | None = ..., - follow_symlinks: bool = ..., - ) -> Path: ... - def chmod(self, mode: str | int) -> Self: ... - - if sys.platform != 'win32': - def chown(self, uid: int | str = ..., gid: int | str = ...) -> Self: ... - - def rename(self, new: str) -> Self: ... - def renames(self, new: str) -> Self: ... - def mkdir(self, mode: int = ...) -> Self: ... - def mkdir_p(self, mode: int = ...) -> Self: ... - def makedirs(self, mode: int = ...) -> Self: ... - def makedirs_p(self, mode: int = ...) -> Self: ... - def rmdir(self) -> Self: ... - def rmdir_p(self) -> Self: ... - def removedirs(self) -> Self: ... - def removedirs_p(self) -> Self: ... - def touch(self) -> Self: ... - def remove(self) -> Self: ... - def remove_p(self) -> Self: ... - def unlink(self) -> Self: ... - def unlink_p(self) -> Self: ... - def link(self, newpath: str) -> Self: ... - def symlink(self, newlink: str | None = ...) -> Self: ... - def readlink(self) -> Self: ... - def readlinkabs(self) -> Self: ... - def copyfile(self, dst: str, *, follow_symlinks: bool = ...) -> str: ... - def copymode(self, dst: str, *, follow_symlinks: bool = ...) -> None: ... - def copystat(self, dst: str, *, follow_symlinks: bool = ...) -> None: ... - def copy(self, dst: str, *, follow_symlinks: bool = ...) -> Any: ... - def copy2(self, dst: str, *, follow_symlinks: bool = ...) -> Any: ... - def copytree( - self, - dst: str, - symlinks: bool = ..., - ignore: Callable[[str, list[str]], Iterable[str]] | None = ..., - copy_function: Callable[[str, str], None] = ..., - ignore_dangling_symlinks: bool = ..., - dirs_exist_ok: bool = ..., - ) -> Any: ... - def move( - self, dst: str, copy_function: Callable[[str, str], None] = ... - ) -> Any: ... - def rmtree( - self, - ignore_errors: bool = ..., - onerror: Callable[[Any, Any, Any], Any] | None = ..., - ) -> None: ... - def rmtree_p(self) -> Self: ... - def chdir(self) -> None: ... - def cd(self) -> None: ... - def merge_tree( - self, - dst: str, - symlinks: bool = ..., - *, - copy_function: Callable[[str, str], None] = ..., - ignore: Callable[[Any, list[str]], list[str] | set[str]] = ..., - ) -> None: ... - - if sys.platform != 'win32': - def chroot(self) -> None: ... - - if sys.platform == 'win32': - def startfile(self, operation: str | None = ...) -> Self: ... - - @contextlib.contextmanager - def in_place( - self, - mode: str = ..., - buffering: int = ..., - encoding: str | None = ..., - errors: str | None = ..., - newline: str | None = ..., - backup_extension: str | None = ..., - ) -> Iterator[tuple[IO[Any], IO[Any]]]: ... - @classes.ClassProperty - @classmethod - def special(cls) -> Callable[[str | None], SpecialResolver]: ... - -class DirectoryNotEmpty(OSError): - @staticmethod - def translate() -> Iterator[None]: ... - -def only_newer(copy_func: Callable[[str, str], None]) -> Callable[[str, str], None]: ... - -class ExtantPath(Path): - def _validate(self) -> None: ... - -class ExtantFile(Path): - def _validate(self) -> None: ... - -class SpecialResolver: - class ResolverScope: - def __init__(self, paths: SpecialResolver, scope: str) -> None: ... - def __getattr__(self, class_: str) -> MultiPathType: ... - - def __init__( - self, - path_class: type, - appname: str | None = ..., - appauthor: str | None = ..., - version: str | None = ..., - roaming: bool = ..., - multipath: bool = ..., - ): ... - def __getattr__(self, scope: str) -> ResolverScope: ... - def get_dir(self, scope: str, class_: str) -> MultiPathType: ... - -class Multi: - @classmethod - def for_class(cls, path_cls: type) -> type[MultiPathType]: ... - @classmethod - def detect(cls, input: str) -> MultiPathType: ... - def __iter__(self) -> Iterator[Path]: ... - @classes.ClassProperty - @classmethod - def _next_class(cls) -> type[Path]: ... - -class MultiPathType(Multi, Path): - pass - -class TempDir(Path): - @classes.ClassProperty - @classmethod - def _next_class(cls) -> type[Path]: ... - def __new__( - cls, - suffix: AnyStr | None = ..., - prefix: AnyStr | None = ..., - dir: AnyStr | os.PathLike[AnyStr] | None = ..., - ) -> Self: ... - def __init__(self) -> None: ... - def __enter__(self) -> Self: ... - def __exit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: TracebackType | None, - ) -> None: ... - -class Handlers: - @classmethod - def _resolve(cls, param: str | Callable[[str], None]) -> Callable[[str], None]: ... From ee57d431ce1491d4a557c7e55f07bfc7e84f399d Mon Sep 17 00:00:00 2001 From: Seth Morton Date: Sun, 22 Dec 2024 20:47:26 -0800 Subject: [PATCH 05/24] Correct annotation for Path.utime The ns keyword argument can be either present or not, but it has no default. To specify this requires overloads and **kwargs. --- path/__init__.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/path/__init__.py b/path/__init__.py index de7c2aa3..d5793e9f 100644 --- a/path/__init__.py +++ b/path/__init__.py @@ -1181,19 +1181,30 @@ def pathconf(self, name: str | int) -> int: # # --- Modifying operations on files and directories + @overload def utime( self, times: tuple[int, int] | tuple[float, float] | None = None, *, - ns: tuple[int, int] = ..., dir_fd: int | None = None, follow_symlinks: bool = True, - ) -> Self: + ) -> Self: ... + @overload + def utime( + self, + times: tuple[int, int] | tuple[float, float] | None = None, + *, + ns: tuple[int, int], + dir_fd: int | None = None, + follow_symlinks: bool = True, + ) -> Self: ... + + def utime(self, *args, **kwargs) -> Self: """Set the access and modified times of this file. .. seealso:: :func:`os.utime` """ - os.utime(self, times, ns=ns, dir_fd=dir_fd, follow_symlinks=follow_symlinks) + os.utime(self, *args, **kwargs) return self def chmod(self, mode: str | int) -> Self: From d373f87846f0391ce65d9cf69ba3bb9331a11aaa Mon Sep 17 00:00:00 2001 From: Seth Morton Date: Sun, 22 Dec 2024 20:56:35 -0800 Subject: [PATCH 06/24] Eliminate attribute error type hint for _validate The base Path does not have a _validate, but subclasses do. The base Path.__init__ calls _validate, but wraps it in an AttributeError suppression. mypy still throws an error despite. To solve, a do-nothing _validate function has been added to Path itself, and the AttributeError suppression has been removed. --- path/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/path/__init__.py b/path/__init__.py index d5793e9f..dd78ea32 100644 --- a/path/__init__.py +++ b/path/__init__.py @@ -172,8 +172,10 @@ def __new__(cls, other='.'): def __init__(self, other: Any = '.') -> None: if other is None: raise TypeError("Invalid initial value for path: None") - with contextlib.suppress(AttributeError): - self._validate() + self._validate() + + def _validate(self) -> None: + pass @classmethod @functools.lru_cache From 0e61b7f3752c84d15081ce8922362de67cee73bb Mon Sep 17 00:00:00 2001 From: Seth Morton Date: Sun, 22 Dec 2024 21:03:00 -0800 Subject: [PATCH 07/24] Replace Number with float or int The Number ABC is more abstract than needed when defining allowed input types - float captures both float and int. Further, when returning explictly an int, the return type should be annotated as such instead of as Number. This also eliminates a mypy error about multiplying a Number with a float. --- path/__init__.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/path/__init__.py b/path/__init__.py index dd78ea32..f499b60d 100644 --- a/path/__init__.py +++ b/path/__init__.py @@ -41,7 +41,6 @@ import sys import tempfile import warnings -from numbers import Number from types import ModuleType, TracebackType with contextlib.suppress(ImportError): @@ -89,8 +88,8 @@ _default_linesep = object() -def _make_timestamp_ns(value: Number | datetime.datetime) -> Number: - timestamp_s = value if isinstance(value, Number) else value.timestamp() +def _make_timestamp_ns(value: float | datetime.datetime) -> int: + timestamp_s = value if isinstance(value, (float, int)) else value.timestamp() return int(timestamp_s * 10**9) @@ -968,7 +967,7 @@ def getatime(self) -> float: """.. seealso:: :attr:`atime`, :func:`os.path.getatime`""" return self.module.getatime(self) - def set_atime(self, value: Number | datetime.datetime): + def set_atime(self, value: float | datetime.datetime): mtime_ns = self.stat().st_atime_ns self.utime(ns=(_make_timestamp_ns(value), mtime_ns)) @@ -998,7 +997,7 @@ def getmtime(self) -> float: """.. seealso:: :attr:`mtime`, :func:`os.path.getmtime`""" return self.module.getmtime(self) - def set_mtime(self, value: Number | datetime.datetime) -> None: + def set_mtime(self, value: float | datetime.datetime) -> None: atime_ns = self.stat().st_atime_ns self.utime(ns=(atime_ns, _make_timestamp_ns(value))) From ee0084dc7ee7d09140ac0f52c7042e6982805c59 Mon Sep 17 00:00:00 2001 From: Seth Morton Date: Sun, 22 Dec 2024 21:06:51 -0800 Subject: [PATCH 08/24] Fix error WRT to returned tuple elements --- path/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/path/__init__.py b/path/__init__.py index f499b60d..537e1798 100644 --- a/path/__init__.py +++ b/path/__init__.py @@ -452,7 +452,7 @@ def splitall(self) -> list[Self | str]: """ return list(self._parts()) - def parts(self) -> tuple[Self | str]: + def parts(self) -> tuple[Self | str, ...]: """ >>> Path('/foo/bar/baz').parts() (Path('/'), 'foo', 'bar', 'baz') From 243f2806db8e8ab390d2cc9ba20952d91ac60f92 Mon Sep 17 00:00:00 2001 From: Seth Morton Date: Sun, 22 Dec 2024 21:11:21 -0800 Subject: [PATCH 09/24] Cannot reassign different types to the same name mypy has a hard time if the same name is used to hold more than one type, so where this error was occuring a new name is used for the second type. --- path/__init__.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/path/__init__.py b/path/__init__.py index 537e1798..bab12070 100644 --- a/path/__init__.py +++ b/path/__init__.py @@ -487,15 +487,15 @@ def relpathto(self, dest: str) -> Self: ``dest.absolute()``. """ origin = self.absolute() - dest = self._next_class(dest).absolute() + dest_path = self._next_class(dest).absolute() orig_list = origin.normcase().splitall() # Don't normcase dest! We want to preserve the case. - dest_list = dest.splitall() + dest_list = dest_path.splitall() if orig_list[0] != self.module.normcase(dest_list[0]): # Can't get here from there. - return dest + return dest_path # Find the location where the two paths start to differ. i = 0 @@ -1475,8 +1475,8 @@ def merge_tree( src.merge_tree(dst, copy_function=only_newer(shutil.copy2)) """ - dst = self._next_class(dst) - dst.makedirs_p() + dst_path = self._next_class(dst) + dst_path.makedirs_p() sources = list(self.iterdir()) _ignored = ignore(self, [item.name for item in sources]) @@ -1485,7 +1485,7 @@ def ignored(item): return item.name in _ignored for source in itertools.filterfalse(ignored, sources): - dest = dst / source.name + dest = dst_path / source.name if symlinks and source.islink(): target = source.readlink() target.symlink(dest) @@ -1499,7 +1499,7 @@ def ignored(item): else: copy_function(source, dest) - self.copystat(dst) + self.copystat(dst_path) # # --- Special stuff from os From da5093ec6891bfb1ac40db9876a07bbeb5fde6c8 Mon Sep 17 00:00:00 2001 From: Seth Morton Date: Sun, 22 Dec 2024 21:24:46 -0800 Subject: [PATCH 10/24] Correct annotations around Handlers The following changes had to be made: - In Path.walk, Handlers._resolve transformed "errors" from a string into a function, which mypy does not like. To resolve, it now assignes "error_fn". - In the recursive Path.walk call, error_fn is not of type str for the errors argument, which would be a typing violation - this has been explictly ignored so as to not make the function option public. - mypy likes to use isinstance to differentiate types, so the logic for Handers._resolve was made more mypy-friendly so that it properly understood the types it was dealing with. - To avoid expected argument errors, the "public" Handlers functions have been made into staticmethods. --- path/__init__.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/path/__init__.py b/path/__init__.py index bab12070..53315c9c 100644 --- a/path/__init__.py +++ b/path/__init__.py @@ -576,13 +576,13 @@ def walk( `errors` may also be an arbitrary callable taking a msg parameter. """ - errors = Handlers._resolve(errors) + error_fn = Handlers._resolve(errors) match = matchers.load(match) try: childList = self.iterdir() except Exception as exc: - errors(f"Unable to list directory '{self}': {exc}") + error_fn(f"Unable to list directory '{self}': {exc}") return for child in childList: @@ -593,11 +593,11 @@ def walk( try: do_traverse = traverse() except Exception as exc: - errors(f"Unable to access '{child}': {exc}") + error_fn(f"Unable to access '{child}': {exc}") continue if do_traverse: - yield from child.walk(errors=errors, match=match) + yield from child.walk(errors=error_fn, match=match) # type: ignore[arg-type] def walkdirs(self, match: _Match = None, errors: str = 'strict') -> Iterator[Self]: """Iterator over subdirs, recursively.""" @@ -1807,17 +1807,26 @@ def __exit__( class Handlers: + @staticmethod def strict(msg: str) -> Never: raise + @staticmethod def warn(msg: str) -> None: warnings.warn(msg, TreeWalkWarning, stacklevel=2) + @staticmethod def ignore(msg: str) -> None: pass @classmethod def _resolve(cls, param: str | Callable[[str], None]) -> Callable[[str], None]: - if not callable(param) and param not in vars(Handlers): - raise ValueError("invalid errors parameter") - return vars(cls).get(param, param) + msg = "invalid errors parameter" + if isinstance(param, str): + if param not in vars(cls): + raise ValueError(msg) + return {"strict": cls.strict, "warn": cls.warn, "ignore": cls.ignore}[param] + else: + if not callable(param): + raise ValueError(msg) + return param From e191025c8f1ed884ac7e31765878451e8d4b0216 Mon Sep 17 00:00:00 2001 From: Seth Morton Date: Sun, 22 Dec 2024 21:37:19 -0800 Subject: [PATCH 11/24] Remove unneeded explict arguments for __exit__ --- path/__init__.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/path/__init__.py b/path/__init__.py index 53315c9c..87b89a03 100644 --- a/path/__init__.py +++ b/path/__init__.py @@ -41,7 +41,7 @@ import sys import tempfile import warnings -from types import ModuleType, TracebackType +from types import ModuleType with contextlib.suppress(ImportError): import win32security @@ -231,12 +231,7 @@ def __enter__(self) -> Self: os.chdir(self) return self - def __exit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: TracebackType | None, - ) -> None: + def __exit__(self, *_) -> None: os.chdir(self._old_dir) @classmethod @@ -1797,12 +1792,7 @@ def __enter__(self) -> Self: # of the temporary directory. return self._next_class(self) - def __exit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - traceback: TracebackType | None, - ) -> None: + def __exit__(self, *_) -> None: self.rmtree() From 318ef04b596dbd1fb79534c28300fd365e4a215f Mon Sep 17 00:00:00 2001 From: Seth Morton Date: Sun, 22 Dec 2024 21:46:20 -0800 Subject: [PATCH 12/24] Fully annotate open When calling e.g. "self.open()", mypy did not recognize that that the implict self argument was acting as the first argument to the builtin open function. Unfortunately, to make mypy fully understand the Path.open function a full list of the open overloads is required. --- path/__init__.py | 94 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 89 insertions(+), 5 deletions(-) diff --git a/path/__init__.py b/path/__init__.py index 87b89a03..83cad9bb 100644 --- a/path/__init__.py +++ b/path/__init__.py @@ -41,6 +41,13 @@ import sys import tempfile import warnings +from io import ( + BufferedRandom, + BufferedReader, + BufferedWriter, + FileIO, + TextIOWrapper, +) from types import ModuleType with contextlib.suppress(ImportError): @@ -56,6 +63,7 @@ TYPE_CHECKING, IO, Any, + BinaryIO, Callable, Generator, Iterable, @@ -64,9 +72,12 @@ ) if TYPE_CHECKING: - from typing_extensions import Never, Self + from typing_extensions import Literal, Never, Self from _typeshed import ( OpenBinaryMode, + OpenBinaryModeReading, + OpenBinaryModeUpdating, + OpenBinaryModeWriting, OpenTextMode, ) @@ -661,7 +672,83 @@ def iglob(self, pattern: str) -> Iterator[Self]: # # --- Reading or writing an entire file at once. - @functools.wraps(open, assigned=()) + @overload + def open( + self, + mode: OpenTextMode = ..., + buffering: int = ..., + encoding: str | None = ..., + errors: str | None = ..., + newline: str | None = ..., + closefd: bool = True, + opener: Callable[[str, int], int] | None = ..., + ) -> TextIOWrapper: ... + @overload + def open( + self, + mode: OpenBinaryMode, + buffering: Literal[0], + encoding: None = ..., + errors: None = ..., + newline: None = ..., + closefd: bool = True, + opener: Callable[[str, int], int] | None = ..., + ) -> FileIO: ... + @overload + def open( + self, + mode: OpenBinaryModeUpdating, + buffering: Literal[-1, 1] = ..., + encoding: None = ..., + errors: None = ..., + newline: None = ..., + closefd: bool = True, + opener: Callable[[str, int], int] | None = ..., + ) -> BufferedRandom: ... + @overload + def open( + self, + mode: OpenBinaryModeWriting, + buffering: Literal[-1, 1] = ..., + encoding: None = ..., + errors: None = ..., + newline: None = ..., + closefd: bool = True, + opener: Callable[[str, int], int] | None = ..., + ) -> BufferedWriter: ... + @overload + def open( + self, + mode: OpenBinaryModeReading, + buffering: Literal[-1, 1] = ..., + encoding: None = ..., + errors: None = ..., + newline: None = ..., + closefd: bool = True, + opener: Callable[[str, int], int] | None = ..., + ) -> BufferedReader: ... + @overload + def open( + self, + mode: OpenBinaryMode, + buffering: int = ..., + encoding: None = ..., + errors: None = ..., + newline: None = ..., + closefd: bool = True, + opener: Callable[[str, int], int] | None = ..., + ) -> BinaryIO: ... + @overload + def open( + self, + mode: str, + buffering: int = ..., + encoding: str | None = ..., + errors: str | None = ..., + newline: str | None = ..., + closefd: bool = True, + opener: Callable[[str, int], int] | None = ..., + ) -> IO[Any]: ... def open(self, *args, **kwargs): """Open this file and return a corresponding file object. @@ -687,7 +774,6 @@ def chunks( closefd: bool = ..., opener: Callable[[str, int], int] | None = ..., ) -> Iterator[str]: ... - @overload def chunks( self, @@ -700,7 +786,6 @@ def chunks( closefd: bool = ..., opener: Callable[[str, int], int] | None = ..., ) -> Iterator[builtins.bytes]: ... - @overload def chunks( self, @@ -713,7 +798,6 @@ def chunks( closefd: bool = ..., opener: Callable[[str, int], int] | None = ..., ) -> Iterator[str | builtins.bytes]: ... - def chunks(self, size, *args, **kwargs): """Returns a generator yielding chunks of the file, so it can be read piece by piece with a simple for loop. From 3c8382fbdece42999ab1028b6fcda604d86d047d Mon Sep 17 00:00:00 2001 From: Seth Morton Date: Sun, 22 Dec 2024 22:03:04 -0800 Subject: [PATCH 13/24] Correct call to contextlib.suppress A tuple of exceptions was passed to contextlib.suppress, but it actually needs to be individual arguments of exceptions - this has been corrected. --- path/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/path/__init__.py b/path/__init__.py index 83cad9bb..266825e3 100644 --- a/path/__init__.py +++ b/path/__init__.py @@ -1404,7 +1404,7 @@ def rmdir_p(self) -> Self: """Like :meth:`rmdir`, but does not raise an exception if the directory is not empty or does not exist.""" suppressed = FileNotFoundError, FileExistsError, DirectoryNotEmpty - with contextlib.suppress(suppressed): + with contextlib.suppress(*suppressed): with DirectoryNotEmpty.translate(): self.rmdir() return self From 2a4adbf44e4141b8839ff7ef838fd23739b3fe21 Mon Sep 17 00:00:00 2001 From: Seth Morton Date: Sun, 22 Dec 2024 22:36:17 -0800 Subject: [PATCH 14/24] Ignore type errors due to Multi mixin mypy has a hard knowing what is being returned with the mixin design pattern, so instead of attempting some sort of refactoring these errors are simply being ignored. --- path/__init__.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/path/__init__.py b/path/__init__.py index 266825e3..78251a5a 100644 --- a/path/__init__.py +++ b/path/__init__.py @@ -1764,7 +1764,13 @@ def _validate(self) -> None: class SpecialResolver: + path_class: type + wrapper: ModuleType + class ResolverScope: + paths: SpecialResolver + scope: str + def __init__(self, paths: SpecialResolver, scope: str) -> None: self.paths = paths self.scope = scope @@ -1819,10 +1825,10 @@ def for_class(cls, path_cls: type) -> type[_MultiPathType]: def detect(cls, input: str) -> _MultiPathType: if os.pathsep not in input: cls = cls._next_class - return cls(input) + return cls(input) # type: ignore[return-value, call-arg] def __iter__(self) -> Iterator[Path]: - return iter(map(self._next_class, self.split(os.pathsep))) + return iter(map(self._next_class, self.split(os.pathsep))) # type: ignore[attr-defined] @classes.ClassProperty @classmethod @@ -1830,7 +1836,7 @@ def _next_class(cls) -> type[Path]: """ Multi-subclasses should use the parent class """ - return next(class_ for class_ in cls.__mro__ if not issubclass(class_, Multi)) + return next(class_ for class_ in cls.__mro__ if not issubclass(class_, Multi)) # type: ignore[return-value] class _MultiPathType(Multi, Path): From fc92f97b1e3aaf5fe3b6de2efc463ea7d667a76b Mon Sep 17 00:00:00 2001 From: Seth Morton Date: Mon, 23 Dec 2024 07:32:03 -0800 Subject: [PATCH 15/24] Use sys.platform to conditionally add methods mypy cannot use aribrary logic to conditionally run code during type checking - it mostly only uses isinstance, and checks against sys.version_info or sys.platform. The conditional inclusion of methods into Path have been changed from using hasattr or a globals check to sys.platform. Additionally, the conditional addition of shutil.move has been removed since shutil.move is always available. --- path/__init__.py | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/path/__init__.py b/path/__init__.py index 78251a5a..ebf27917 100644 --- a/path/__init__.py +++ b/path/__init__.py @@ -1217,13 +1217,14 @@ def __get_owner_unix(self) -> str: # pragma: nocover def __get_owner_not_implemented(self) -> Never: # pragma: nocover raise NotImplementedError("Ownership not available on this platform.") - get_owner = ( - __get_owner_windows - if 'win32security' in globals() - else __get_owner_unix - if 'pwd' in globals() - else __get_owner_not_implemented - ) + if sys.platform != "win32": + get_owner = __get_owner_unix + else: + get_owner = ( + __get_owner_windows + if "win32security" in globals() + else __get_owner_not_implemented + ) owner = property( get_owner, @@ -1234,7 +1235,7 @@ def __get_owner_not_implemented(self) -> Never: # pragma: nocover .. seealso:: :meth:`get_owner`""", ) - if 'grp' in globals(): # pragma: no cover + if sys.platform != "win32": # pragma: no cover def group(self, *, follow_symlinks: bool = True) -> str: """ @@ -1243,8 +1244,6 @@ def group(self, *, follow_symlinks: bool = True) -> str: gid = self.stat(follow_symlinks=follow_symlinks).st_gid return grp.getgrgid(gid).gr_name - if hasattr(os, 'statvfs'): - def statvfs(self) -> os.statvfs_result: """Perform a ``statvfs()`` system call on this path. @@ -1252,8 +1251,6 @@ def statvfs(self) -> os.statvfs_result: """ return os.statvfs(self) - if hasattr(os, 'pathconf'): - def pathconf(self, name: str | int) -> int: """.. seealso:: :func:`os.pathconf`""" return os.pathconf(self, name) @@ -1306,7 +1303,7 @@ def chmod(self, mode: str | int) -> Self: os.chmod(self, mode) return self - if hasattr(os, 'chown'): + if sys.platform != "win32": def chown(self, uid: str | int = -1, gid: str | int = -1) -> Self: """ @@ -1515,8 +1512,7 @@ def readlinkabs(self) -> Self: copy = shutil.copy copy2 = shutil.copy2 copytree = shutil.copytree - if hasattr(shutil, 'move'): - move = shutil.move + move = shutil.move rmtree = shutil.rmtree def rmtree_p(self) -> Self: @@ -1583,13 +1579,13 @@ def ignored(item): # # --- Special stuff from os - if hasattr(os, 'chroot'): + if sys.platform != "win32": def chroot(self) -> None: # pragma: nocover """.. seealso:: :func:`os.chroot`""" os.chroot(self) - if hasattr(os, 'startfile'): + if sys.platform == "win32": def startfile(self, operation: str | None = None) -> Self: # pragma: nocover """.. seealso:: :func:`os.startfile`""" From ae3c7db073edcc76fafe547cfc22d512f490f763 Mon Sep 17 00:00:00 2001 From: Seth Morton Date: Mon, 23 Dec 2024 08:31:53 -0800 Subject: [PATCH 16/24] Use property as a decorator to define properties When used in the functional form the typing system has a hard time determining the types of properties. Changing the definition to use the decorator form resolves this issue. --- path/__init__.py | 90 ++++++++++++++++++++++-------------------------- 1 file changed, 42 insertions(+), 48 deletions(-) diff --git a/path/__init__.py b/path/__init__.py index ebf27917..33895377 100644 --- a/path/__init__.py +++ b/path/__init__.py @@ -353,32 +353,28 @@ def drive(self) -> Self: drive, r = self.module.splitdrive(self) return self._next_class(drive) - parent = property( - dirname, - None, - None, - """ This path's parent directory, as a new Path object. + @property + def parent(self) -> Self: + """This path's parent directory, as a new Path object. For example, ``Path('/usr/local/lib/libpython.so').parent == Path('/usr/local/lib')`` .. seealso:: :meth:`dirname`, :func:`os.path.dirname` - """, - ) + """ + return self.dirname() - name = property( - basename, - None, - None, - """ The name of this file or directory without the full path. + @property + def name(self) -> Self: + """The name of this file or directory without the full path. For example, ``Path('/usr/local/lib/libpython.so').name == 'libpython.so'`` .. seealso:: :meth:`basename`, :func:`os.path.basename` - """, - ) + """ + return self.basename() def with_name(self, name: str) -> Self: """Return a new path with the name changed. @@ -1046,14 +1042,12 @@ def getatime(self) -> float: """.. seealso:: :attr:`atime`, :func:`os.path.getatime`""" return self.module.getatime(self) - def set_atime(self, value: float | datetime.datetime): + def set_atime(self, value: float | datetime.datetime) -> None: mtime_ns = self.stat().st_atime_ns self.utime(ns=(_make_timestamp_ns(value), mtime_ns)) - atime = property( - getatime, - set_atime, - None, + @property + def atime(self) -> float: """ Last access time of the file. @@ -1069,8 +1063,12 @@ def set_atime(self, value: float | datetime.datetime): 200336400.0 .. seealso:: :meth:`getatime`, :func:`os.path.getatime` - """, - ) + """ + return self.getatime() + + @atime.setter + def atime(self, value: float | datetime.datetime) -> None: + self.set_atime(value) def getmtime(self) -> float: """.. seealso:: :attr:`mtime`, :func:`os.path.getmtime`""" @@ -1080,10 +1078,8 @@ def set_mtime(self, value: float | datetime.datetime) -> None: atime_ns = self.stat().st_atime_ns self.utime(ns=(atime_ns, _make_timestamp_ns(value))) - mtime = property( - getmtime, - set_mtime, - None, + @property + def mtime(self) -> float: """ Last modified time of the file. @@ -1096,36 +1092,36 @@ def set_mtime(self, value: float | datetime.datetime) -> None: 200336400.0 .. seealso:: :meth:`getmtime`, :func:`os.path.getmtime` - """, - ) + """ + return self.getmtime() + + @mtime.setter + def mtime(self, value: float | datetime.datetime) -> None: + self.set_mtime(value) def getctime(self) -> float: """.. seealso:: :attr:`ctime`, :func:`os.path.getctime`""" return self.module.getctime(self) - ctime = property( - getctime, - None, - None, - """ Creation time of the file. + @property + def ctime(self) -> float: + """Creation time of the file. .. seealso:: :meth:`getctime`, :func:`os.path.getctime` - """, - ) + """ + return self.getctime() def getsize(self) -> int: """.. seealso:: :attr:`size`, :func:`os.path.getsize`""" return self.module.getsize(self) - size = property( - getsize, - None, - None, - """ Size of the file, in bytes. + @property + def size(self) -> int: + """Size of the file, in bytes. .. seealso:: :meth:`getsize`, :func:`os.path.getsize` - """, - ) + """ + return self.getsize() @property def permissions(self) -> masks.Permissions: @@ -1226,14 +1222,12 @@ def __get_owner_not_implemented(self) -> Never: # pragma: nocover else __get_owner_not_implemented ) - owner = property( - get_owner, - None, - None, - """ Name of the owner of this file or directory. + @property + def owner(self) -> str: + """Name of the owner of this file or directory. - .. seealso:: :meth:`get_owner`""", - ) + .. seealso:: :meth:`get_owner`""" + return self.get_owner() if sys.platform != "win32": # pragma: no cover From cde1eaf191de7ac47cc3494558c708f6a1be00b3 Mon Sep 17 00:00:00 2001 From: Seth Morton Date: Mon, 23 Dec 2024 12:54:20 -0800 Subject: [PATCH 17/24] Ensure shutil-derived functions are properly typed When assigning a shutil function directly into the Path namespace, the typing system seems to have trouble figuring out that the first argument is the implict self, and as a result the type checking fails for these functions. To solve, thin passthrough functions have been added so that the annotations are correct for the Path context, but the docstrings have been copied from shutil as-is. --- path/__init__.py | 142 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 127 insertions(+), 15 deletions(-) diff --git a/path/__init__.py b/path/__init__.py index 33895377..f98b8fcf 100644 --- a/path/__init__.py +++ b/path/__init__.py @@ -79,16 +79,20 @@ OpenBinaryModeUpdating, OpenBinaryModeWriting, OpenTextMode, + ExcInfo, ) + _Match = str | Callable[[str], bool] | None + _CopyFn = Callable[[str, str], object] + _OnErrorCallback = Callable[[Callable[..., Any], str, ExcInfo], object] + _OnExcCallback = Callable[[Callable[..., Any], str, BaseException], object] + + from . import classes, masks, matchers from .compat.py38 import removeprefix, removesuffix __all__ = ['Path', 'TempDir'] -# Type for the match argument for several methods -_Match = str | Callable[[str], bool] | None - LINESEPS = ['\r\n', '\r', '\n'] U_LINESEPS = LINESEPS + ['\u0085', '\u2028', '\u2029'] B_NEWLINE = re.compile('|'.join(LINESEPS).encode()) @@ -1496,18 +1500,126 @@ def readlinkabs(self) -> Self: p = self.readlink() return p if p.isabs() else (self.parent / p).absolute() - # High-level functions from shutil - # These functions will be bound to the instance such that - # Path(name).copy(target) will invoke shutil.copy(name, target) + # High-level functions from shutil. + + def copyfile(self, dst: str, *, follow_symlinks: bool = True) -> Self: + return self._next_class( + shutil.copyfile(self, dst, follow_symlinks=follow_symlinks) + ) + + def copymode(self, dst: str, *, follow_symlinks: bool = True) -> None: + shutil.copymode(self, dst, follow_symlinks=follow_symlinks) - copyfile = shutil.copyfile - copymode = shutil.copymode - copystat = shutil.copystat - copy = shutil.copy - copy2 = shutil.copy2 - copytree = shutil.copytree - move = shutil.move - rmtree = shutil.rmtree + def copystat(self, dst: str, *, follow_symlinks: bool = True) -> None: + shutil.copystat(self, dst, follow_symlinks=follow_symlinks) + + def copy(self, dst: str, *, follow_symlinks: bool = True) -> Self: + return self._next_class(shutil.copy(self, dst, follow_symlinks=follow_symlinks)) + + def copy2(self, dst: str, *, follow_symlinks: bool = True) -> Self: + return self._next_class( + shutil.copy2(self, dst, follow_symlinks=follow_symlinks) + ) + + def copytree( + self, + dst: str, + symlinks: bool = False, + ignore: None | Callable[[str, list[str]], Iterable[str]] = None, + copy_function: _CopyFn = shutil.copy2, + ignore_dangling_symlinks: bool = False, + dirs_exist_ok: bool = False, + ) -> Self: + return self._next_class( + shutil.copytree( + self, + dst, + symlinks=symlinks, + ignore=ignore, + copy_function=copy_function, + ignore_dangling_symlinks=ignore_dangling_symlinks, + dirs_exist_ok=dirs_exist_ok, + ) + ) + + def move(self, dst: str, copy_function: _CopyFn = shutil.copy2) -> Self: + retval = shutil.move(self, dst, copy_function=copy_function) + # shutil.move may return None if the src and dst are the same + return self._next_class(retval or dst) + + if sys.version_info >= (3, 12): + + @overload + def rmtree( + self, + ignore_errors: bool, + onerror: _OnErrorCallback, + *, + onexc: None = ..., + dir_fd: int | None = ..., + ) -> None: ... + @overload + def rmtree( + self, + ignore_errors: bool = ..., + *, + onerror: _OnErrorCallback, + onexc: None = ..., + dir_fd: int | None = ..., + ) -> None: ... + @overload + def rmtree( + self, + ignore_errors: bool = ..., + *, + onexc: _OnExcCallback | None = ..., + dir_fd: int | None = ..., + ) -> None: ... + + elif sys.version_info >= (3, 11): + # NOTE: Strictly speaking, an overload is not needed - this could + # be expressed in a single annotation. However, if overloads + # are used there must be a minimum of two, so this was split + # into two so that the body of rmtree need not be re-defined + # for each version. + @overload + def rmtree( + self, + onerror: _OnErrorCallback | None = None, + *, + dir_fd: int | None = None, + ) -> None: ... + @overload + def rmtree( + self, + ignore_errors: bool, + onerror: _OnErrorCallback | None = ..., + *, + dir_fd: int | None = ..., + ) -> None: ... + + else: + # NOTE: See note about overloads above. + @overload + def rmtree(self, onerror: _OnErrorCallback | None = ...) -> None: ... + @overload + def rmtree( + self, ignore_errors: bool, onerror: _OnErrorCallback | None = ... + ) -> None: ... + + def rmtree(self, *args, **kwargs): + shutil.rmtree(self, *args, **kwargs) + + # Copy the docstrings from shutil to these methods. + + copyfile.__doc__ = shutil.copyfile.__doc__ + copymode.__doc__ = shutil.copymode.__doc__ + copystat.__doc__ = shutil.copystat.__doc__ + copy.__doc__ = shutil.copy.__doc__ + copy2.__doc__ = shutil.copy2.__doc__ + copytree.__doc__ = shutil.copytree.__doc__ + move.__doc__ = shutil.move.__doc__ + rmtree.__doc__ = shutil.rmtree.__doc__ def rmtree_p(self) -> Self: """Like :meth:`rmtree`, but does not raise an exception if the @@ -1527,7 +1639,7 @@ def merge_tree( dst: str, symlinks: bool = False, *, - copy_function: Callable[[str, str], None] = shutil.copy2, + copy_function: _CopyFn = shutil.copy2, ignore: Callable[[Any, list[str]], list[str] | set[str]] = lambda dir, contents: [], ): From 58c650eb1b59966339f2e4de6f25bc7a2ef48a6e Mon Sep 17 00:00:00 2001 From: Seth Morton Date: Mon, 23 Dec 2024 12:58:00 -0800 Subject: [PATCH 18/24] Ignore unknown types for win32security --- path/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/path/__init__.py b/path/__init__.py index f98b8fcf..afbf9a6d 100644 --- a/path/__init__.py +++ b/path/__init__.py @@ -51,7 +51,7 @@ from types import ModuleType with contextlib.suppress(ImportError): - import win32security + import win32security # type: ignore[import-not-found] with contextlib.suppress(ImportError): import pwd From 26819d2be271a78b2b5ef7010e0230ba766f6ab2 Mon Sep 17 00:00:00 2001 From: Seth Morton Date: Mon, 23 Dec 2024 13:04:01 -0800 Subject: [PATCH 19/24] Correct the startfile annotation --- path/__init__.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/path/__init__.py b/path/__init__.py index afbf9a6d..854d7ab6 100644 --- a/path/__init__.py +++ b/path/__init__.py @@ -1692,10 +1692,34 @@ def chroot(self) -> None: # pragma: nocover os.chroot(self) if sys.platform == "win32": + if sys.version_info >= (3, 10): - def startfile(self, operation: str | None = None) -> Self: # pragma: nocover + @overload + def startfile( + self, + arguments: str = ..., + cwd: str | None = ..., + show_cmd: int = ..., + ) -> Self: ... + @overload + def startfile( + self, + operation: str, + arguments: str = ..., + cwd: str | None = ..., + show_cmd: int = ..., + ) -> Self: ... + + else: + + @overload + def startfile(self) -> Self: ... + @overload + def startfile(self, operation: str) -> Self: ... + + def startfile(self, *args, **kwargs) -> Self: # pragma: nocover """.. seealso:: :func:`os.startfile`""" - os.startfile(self, operation=operation) + os.startfile(self, *args, **kwargs) return self # in-place re-writing, courtesy of Martijn Pieters From d7e6e2085872142060c80d286df721d479e3d7fa Mon Sep 17 00:00:00 2001 From: Seth Morton Date: Mon, 23 Dec 2024 13:21:57 -0800 Subject: [PATCH 20/24] Add some clean-up annotations Added annotations to functions that did not strictly need them but adding them improves editor highlighing. --- path/__init__.py | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/path/__init__.py b/path/__init__.py index 854d7ab6..ef33c5b4 100644 --- a/path/__init__.py +++ b/path/__init__.py @@ -84,6 +84,7 @@ _Match = str | Callable[[str], bool] | None _CopyFn = Callable[[str, str], object] + _IgnoreFn = Callable[[str, list[str]], Iterable[str]] _OnErrorCallback = Callable[[Callable[..., Any], str, ExcInfo], object] _OnExcCallback = Callable[[Callable[..., Any], str, BaseException], object] @@ -174,13 +175,13 @@ class Path(str): the Path instance. """ - module: Any = os.path + module: ModuleType = os.path """ The path module to use for path operations. .. seealso:: :mod:`os.path` """ - def __new__(cls, other='.'): + def __new__(cls, other: Any = '.') -> Self: return super().__new__(cls, other) def __init__(self, other: Any = '.') -> None: @@ -312,7 +313,7 @@ def stem(self) -> str: >>> Path('/home/guido/python.tar.gz').stem 'python.tar' """ - base, ext = self.module.splitext(self.name) + base, _ = self.module.splitext(self.name) return base def with_stem(self, stem: str) -> Self: @@ -326,7 +327,7 @@ def with_stem(self, stem: str) -> Self: @property def suffix(self) -> Self: """The file extension, for example ``'.py'``.""" - f, suffix = self.module.splitext(self) + _, suffix = self.module.splitext(self) return suffix def with_suffix(self, suffix: str) -> Self: @@ -345,7 +346,6 @@ def with_suffix(self, suffix: str) -> Self: """ if not suffix.startswith('.'): raise ValueError(f"Invalid suffix {suffix!r}") - return self.stripext() + suffix @property @@ -354,7 +354,7 @@ def drive(self) -> Self: This is always empty on systems that don't use drive specifiers. """ - drive, r = self.module.splitdrive(self) + drive, _ = self.module.splitdrive(self) return self._next_class(drive) @property @@ -1310,10 +1310,10 @@ def chown(self, uid: str | int = -1, gid: str | int = -1) -> Self: .. seealso:: :func:`os.chown` """ - def resolve_uid(uid): + def resolve_uid(uid: str | int) -> int: return uid if isinstance(uid, int) else pwd.getpwnam(uid).pw_uid - def resolve_gid(gid): + def resolve_gid(gid: str | int) -> int: return gid if isinstance(gid, int) else grp.getgrnam(gid).gr_gid os.chown(self, resolve_uid(uid), resolve_gid(gid)) @@ -1525,7 +1525,7 @@ def copytree( self, dst: str, symlinks: bool = False, - ignore: None | Callable[[str, list[str]], Iterable[str]] = None, + ignore: _IgnoreFn | None = None, copy_function: _CopyFn = shutil.copy2, ignore_dangling_symlinks: bool = False, dirs_exist_ok: bool = False, @@ -1640,8 +1640,7 @@ def merge_tree( symlinks: bool = False, *, copy_function: _CopyFn = shutil.copy2, - ignore: Callable[[Any, list[str]], list[str] | set[str]] = lambda dir, - contents: [], + ignore: _IgnoreFn = lambda dir, contents: [], ): """ Copy entire contents of self to dst, overwriting existing @@ -1660,9 +1659,9 @@ def merge_tree( dst_path.makedirs_p() sources = list(self.iterdir()) - _ignored = ignore(self, [item.name for item in sources]) + _ignored = set(ignore(self, [item.name for item in sources])) - def ignored(item): + def ignored(item: Self) -> bool: return item.name in _ignored for source in itertools.filterfalse(ignored, sources): @@ -1844,18 +1843,20 @@ def translate() -> Iterator[None]: raise -def only_newer(copy_func: Callable[[str, str], None]) -> Callable[[str, str], None]: +def only_newer(copy_func: _CopyFn) -> _CopyFn: """ Wrap a copy function (like shutil.copy2) to return the dst if it's newer than the source. """ @functools.wraps(copy_func) - def wrapper(src, dst, *args, **kwargs): - is_newer_dst = dst.exists() and dst.getmtime() >= src.getmtime() + def wrapper(src: str, dst: str): + src_p = Path(src) + dst_p = Path(dst) + is_newer_dst = dst_p.exists() and dst_p.getmtime() >= src_p.getmtime() if is_newer_dst: return dst - return copy_func(src, dst, *args, **kwargs) + return copy_func(src, dst) return wrapper From 77b8af35f196d22cede8ac15c5fba74b2c996fa6 Mon Sep 17 00:00:00 2001 From: Seth Morton Date: Mon, 23 Dec 2024 13:29:55 -0800 Subject: [PATCH 21/24] Fix type hinting for .joinpath Type hinting was taking its cue from the multimethod decorator, so joinpath was always returning Any as the type. To resolve, the multimethod decorator has been annotated with generics so that the typing system can correctly infer the return type of joinpath. --- path/classes.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/path/classes.py b/path/classes.py index 2914996b..7aeb7f2f 100644 --- a/path/classes.py +++ b/path/classes.py @@ -1,5 +1,7 @@ +from __future__ import annotations + import functools -from typing import Any, Callable +from typing import Any, Callable, Generic, TypeVar class ClassProperty(property): @@ -8,18 +10,21 @@ def __get__(self, cls: Any, owner: type | None = None) -> Any: return self.fget.__get__(None, owner)() -class multimethod: +_T = TypeVar("_T") + + +class multimethod(Generic[_T]): """ Acts like a classmethod when invoked from the class and like an instancemethod when invoked from the instance. """ - func: Callable[..., Any] + func: Callable[..., _T] - def __init__(self, func: Callable[..., Any]): + def __init__(self, func: Callable[..., _T]): self.func = func - def __get__(self, instance: Any | None, owner: type | None) -> Any: + def __get__(self, instance: _T | None, owner: type[_T] | None) -> Callable[..., _T]: """ If called on an instance, pass the instance as the first argument. From 0f700b68c3dbca7a271d4afd82f06b76cdc0507a Mon Sep 17 00:00:00 2001 From: Seth Morton Date: Mon, 23 Dec 2024 13:43:10 -0800 Subject: [PATCH 22/24] Account for Python 3.9 when defining unions The union for _Match is not defined as part of a function annotation, so the "annotations" import from __future__ does not take effect and thus results in a syntax error on Python < 3.10. To fix, the Union annotation is used for this one type definition. --- path/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/path/__init__.py b/path/__init__.py index ef33c5b4..61f8ed44 100644 --- a/path/__init__.py +++ b/path/__init__.py @@ -72,7 +72,7 @@ ) if TYPE_CHECKING: - from typing_extensions import Literal, Never, Self + from typing_extensions import Literal, Never, Self, Union from _typeshed import ( OpenBinaryMode, OpenBinaryModeReading, @@ -82,7 +82,7 @@ ExcInfo, ) - _Match = str | Callable[[str], bool] | None + _Match = Union[str, Callable[[str], bool], None] _CopyFn = Callable[[str, str], object] _IgnoreFn = Callable[[str, list[str]], Iterable[str]] _OnErrorCallback = Callable[[Callable[..., Any], str, ExcInfo], object] From d63dc73ae2b1b28443d00ad754ea5721e281df88 Mon Sep 17 00:00:00 2001 From: Seth Morton Date: Mon, 23 Dec 2024 14:12:51 -0800 Subject: [PATCH 23/24] Directly implement get_owner The implementations for get_owner need to be hiddent behind sys.platform in order for mypy to not complain about non-existing functionality on some platforms. --- path/__init__.py | 57 ++++++++++++++++++++++-------------------------- 1 file changed, 26 insertions(+), 31 deletions(-) diff --git a/path/__init__.py b/path/__init__.py index 61f8ed44..d99bfa5e 100644 --- a/path/__init__.py +++ b/path/__init__.py @@ -1188,43 +1188,38 @@ def lstat(self) -> os.stat_result: """ return os.lstat(self) - def __get_owner_windows(self) -> str: # pragma: nocover - r""" - Return the name of the owner of this file or directory. Follow - symbolic links. + if sys.platform == "win32": - Return a name of the form ``DOMAIN\User Name``; may be a group. + def get_owner(self) -> str: # pragma: nocover + r""" + Return the name of the owner of this file or directory. Follow + symbolic links. - .. seealso:: :attr:`owner` - """ - desc = win32security.GetFileSecurity( - self, win32security.OWNER_SECURITY_INFORMATION - ) - sid = desc.GetSecurityDescriptorOwner() - account, domain, typecode = win32security.LookupAccountSid(None, sid) - return domain + '\\' + account + Return a name of the form ``DOMAIN\User Name``; may be a group. - def __get_owner_unix(self) -> str: # pragma: nocover - """ - Return the name of the owner of this file or directory. Follow - symbolic links. - - .. seealso:: :attr:`owner` - """ - st = self.stat() - return pwd.getpwuid(st.st_uid).pw_name + .. seealso:: :attr:`owner` + """ + if "win32security" not in globals(): + raise NotImplementedError("Ownership not available on this platform.") - def __get_owner_not_implemented(self) -> Never: # pragma: nocover - raise NotImplementedError("Ownership not available on this platform.") + desc = win32security.GetFileSecurity( + self, win32security.OWNER_SECURITY_INFORMATION + ) + sid = desc.GetSecurityDescriptorOwner() + account, domain, typecode = win32security.LookupAccountSid(None, sid) + return domain + '\\' + account - if sys.platform != "win32": - get_owner = __get_owner_unix else: - get_owner = ( - __get_owner_windows - if "win32security" in globals() - else __get_owner_not_implemented - ) + + def get_owner(self) -> str: # pragma: nocover + """ + Return the name of the owner of this file or directory. Follow + symbolic links. + + .. seealso:: :attr:`owner` + """ + st = self.stat() + return pwd.getpwuid(st.st_uid).pw_name @property def owner(self) -> str: From 27c406b8b0a5bdc0e2b4749e1d2ae407e806b3c8 Mon Sep 17 00:00:00 2001 From: Seth Morton Date: Wed, 25 Dec 2024 16:14:41 -0500 Subject: [PATCH 24/24] Limit docs analysis warnings for newly added types --- docs/conf.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 911c513e..fe85d77b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -49,6 +49,13 @@ ('py:class', 'OpenBinaryModeUpdating'), ('py:class', 'OpenBinaryModeWriting'), ('py:class', 'OpenTextMode'), + ('py:class', '_IgnoreFn'), + ('py:class', '_CopyFn'), + ('py:class', '_Match'), + ('py:class', '_OnErrorCallback'), + ('py:class', '_OnExcCallback'), + ('py:class', 'os.statvfs_result'), + ('py:class', 'ModuleType'), ] # Include Python intersphinx mapping to prevent failures