diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml index 212f3e8d70c836..7b0e2c54c8d03b 100644 --- a/.github/workflows/mypy.yml +++ b/.github/workflows/mypy.yml @@ -41,6 +41,7 @@ jobs: matrix: target: [ "Lib/_pyrepl", + "Lib/pathlib", "Lib/test/libregrtest", "Tools/build", "Tools/cases_generator", diff --git a/Lib/pathlib/__init__.py b/Lib/pathlib/__init__.py index 12cf9f579cb32d..27f3435d4c37b2 100644 --- a/Lib/pathlib/__init__.py +++ b/Lib/pathlib/__init__.py @@ -12,11 +12,18 @@ import posixpath import sys from errno import * -from glob import _StringGlobber, _no_recurse_symlinks +from glob import _StringGlobber, _no_recurse_symlinks # type: ignore[attr-defined] from itertools import chain from stat import S_ISDIR, S_ISREG, S_ISSOCK, S_ISBLK, S_ISCHR, S_ISFIFO from _collections_abc import Sequence +# types +if False: + from types import ModuleType + + pwd: ModuleType | None + grp: ModuleType | None + try: import pwd except ImportError: @@ -26,7 +33,7 @@ except ImportError: grp = None -from pathlib._os import ( +from ._os import ( PathInfo, DirEntryInfo, ensure_different_files, ensure_distinct_paths, copyfile2, copyfileobj, magic_open, copy_info, @@ -46,7 +53,7 @@ class UnsupportedOperation(NotImplementedError): pass -class _PathParents(Sequence): +class _PathParents(Sequence["PurePath"]): """This object provides sequence-like access to the logical ancestors of a path. Don't try to construct it yourself.""" __slots__ = ('_path', '_drv', '_root', '_tail') diff --git a/Lib/pathlib/_os.py b/Lib/pathlib/_os.py index e3751bbcb62377..a9b64ba5ebe77e 100644 --- a/Lib/pathlib/_os.py +++ b/Lib/pathlib/_os.py @@ -7,6 +7,23 @@ import io import os import sys + +# typing +if False: + from collections.abc import Callable + from types import ModuleType + + type CopyFunc = Callable[[int, int], None] + + fcntl: ModuleType | None + posix: ModuleType | None + _winapi: ModuleType | None + _ficlone: CopyFunc | None + _fcopyfile: CopyFunc | None + _copy_file_range: CopyFunc | None + _sendfile: CopyFunc | None + copyfile2: CopyFunc | None + try: import fcntl except ImportError: diff --git a/Lib/pathlib/mypy.ini b/Lib/pathlib/mypy.ini new file mode 100644 index 00000000000000..ce804ca1bb6f1f --- /dev/null +++ b/Lib/pathlib/mypy.ini @@ -0,0 +1,25 @@ +# Config file for running mypy on pathlib. +# Run mypy by invoking `mypy --config-file Lib/pathlib/mypy.ini` +# on the command-line from the repo root + +[mypy] +files = Lib/pathlib +mypy_path = $MYPY_CONFIG_FILE_DIR/../../Misc/mypy +explicit_package_bases = True +python_version = 3.13 +platform = linux +pretty = True + +# ... Enable most stricter settings +enable_error_code = ignore-without-code,redundant-expr +strict = True + +# can't enable before glob isn't typed ... +warn_return_any = False +disable_error_code = attr-defined + +# Various stricter settings that we can't yet enable +# Try to enable these in the following order: +disallow_untyped_calls = False +disallow_untyped_defs = False +check_untyped_defs = False diff --git a/Lib/pathlib/types.py b/Lib/pathlib/types.py index d1bb8701b887c8..d02c0258827777 100644 --- a/Lib/pathlib/types.py +++ b/Lib/pathlib/types.py @@ -11,25 +11,16 @@ from abc import ABC, abstractmethod +from collections.abc import Callable, Iterator, Sequence from glob import _PathGlobber -from pathlib._os import magic_open, ensure_distinct_paths, ensure_different_files, copyfileobj -from pathlib import PurePath, Path -from typing import Optional, Protocol, runtime_checkable +from typing import Protocol, runtime_checkable +from . import PurePath, Path +from ._os import magic_open, ensure_distinct_paths, ensure_different_files, copyfileobj -def _explode_path(path, split): - """ - Split the path into a 2-tuple (anchor, parts), where *anchor* is the - uppermost parent of the path (equivalent to path.parents[-1]), and - *parts* is a reversed list of parts following the anchor. - """ - parent, name = split(path) - names = [] - while path != parent: - names.append(name) - path = parent - parent, name = split(path) - return path, names +# typing +if False: + from typing import Any, BinaryIO, Literal, Self @runtime_checkable @@ -42,12 +33,28 @@ class _PathParser(Protocol): """ sep: str - altsep: Optional[str] + altsep: str | None def split(self, path: str) -> tuple[str, str]: ... def splitext(self, path: str) -> tuple[str, str]: ... def normcase(self, path: str) -> str: ... +def _explode_path(path: str, parser: _PathParser) -> tuple[str, list[str]]: + """ + Split the path into a 2-tuple (anchor, parts), where *anchor* is the + uppermost parent of the path (equivalent to path.parents[-1]), and + *parts* is a reversed list of parts following the anchor. + """ + split = parser.split + parent, name = split(path) + names = [] + while path != parent: + names.append(name) + path = parent + parent, name = split(path) + return path, names + + @runtime_checkable class PathInfo(Protocol): """Protocol for path info objects, which support querying the file type. @@ -70,14 +77,14 @@ class _JoinablePath(ABC): @property @abstractmethod - def parser(self): + def parser(self) -> _PathParser: """Implementation of pathlib._types.Parser used for low-level path parsing and manipulation. """ raise NotImplementedError @abstractmethod - def with_segments(self, *pathsegments): + def with_segments(self, *pathsegments: str) -> Self: """Construct a new path object from any number of path-like objects. Subclasses may override this method to customize how new path objects are created from methods like `iterdir()`. @@ -85,23 +92,23 @@ def with_segments(self, *pathsegments): raise NotImplementedError @abstractmethod - def __str__(self): + def __str__(self) -> str: """Return the string representation of the path, suitable for passing to system calls.""" raise NotImplementedError @property - def anchor(self): + def anchor(self) -> str: """The concatenation of the drive and root, or ''.""" - return _explode_path(str(self), self.parser.split)[0] + return _explode_path(str(self), self.parser)[0] @property - def name(self): + def name(self) -> str: """The final path component, if any.""" return self.parser.split(str(self))[1] @property - def suffix(self): + def suffix(self) -> str: """ The final component's last suffix, if any. @@ -110,7 +117,7 @@ def suffix(self): return self.parser.splitext(self.name)[1] @property - def suffixes(self): + def suffixes(self) -> Sequence[str]: """ A list of the final component's suffixes, if any. @@ -125,11 +132,11 @@ def suffixes(self): return suffixes[::-1] @property - def stem(self): + def stem(self) -> str: """The final path component, minus its last suffix.""" return self.parser.splitext(self.name)[0] - def with_name(self, name): + def with_name(self, name: str) -> Self: """Return a new path with the file name changed.""" split = self.parser.split if split(name)[0]: @@ -138,7 +145,7 @@ def with_name(self, name): path = path.removesuffix(split(path)[1]) + name return self.with_segments(path) - def with_stem(self, stem): + def with_stem(self, stem: str) -> Self: """Return a new path with the stem changed.""" suffix = self.suffix if not suffix: @@ -149,7 +156,7 @@ def with_stem(self, stem): else: return self.with_name(stem + suffix) - def with_suffix(self, suffix): + def with_suffix(self, suffix: str) -> Self: """Return a new path with the file suffix changed. If the path has no suffix, add given suffix. If the given suffix is an empty string, remove the suffix from the path. @@ -164,15 +171,15 @@ def with_suffix(self, suffix): return self.with_name(stem + suffix) @property - def parts(self): + def parts(self) -> Sequence[str]: """An object providing sequence-like access to the components in the filesystem path.""" - anchor, parts = _explode_path(str(self), self.parser.split) + anchor, parts = _explode_path(str(self), self.parser) if anchor: parts.append(anchor) return tuple(reversed(parts)) - def joinpath(self, *pathsegments): + def joinpath(self, *pathsegments: str) -> Self: """Combine this path with one or several arguments, and return a new path representing either a subpath (if all arguments are relative paths) or a totally different path (if one of the arguments is @@ -180,20 +187,20 @@ def joinpath(self, *pathsegments): """ return self.with_segments(str(self), *pathsegments) - def __truediv__(self, key): + def __truediv__(self, key: str) -> Self: try: return self.with_segments(str(self), key) except TypeError: return NotImplemented - def __rtruediv__(self, key): + def __rtruediv__(self, key: str) -> Self: try: return self.with_segments(key, str(self)) except TypeError: return NotImplemented @property - def parent(self): + def parent(self) -> Self: """The logical parent of the path.""" path = str(self) parent = self.parser.split(path)[0] @@ -202,7 +209,7 @@ def parent(self): return self @property - def parents(self): + def parents(self) -> Sequence[Self]: """A sequence of this path's logical parents.""" split = self.parser.split path = str(self) @@ -214,7 +221,7 @@ def parents(self): parent = split(path)[0] return tuple(parents) - def full_match(self, pattern): + def full_match(self, pattern: str) -> bool: """ Return True if this path matches the given glob-style pattern. The pattern is matched against the entire path. @@ -236,7 +243,7 @@ class _ReadablePath(_JoinablePath): @property @abstractmethod - def info(self): + def info(self) -> PathInfo: """ A PathInfo object that exposes the file type and other file attributes of this path. @@ -244,21 +251,26 @@ def info(self): raise NotImplementedError @abstractmethod - def __open_rb__(self, buffering=-1): + def __open_rb__(self, buffering: int = -1) -> BinaryIO: """ Open the file pointed to by this path for reading in binary mode and return a file object, like open(mode='rb'). """ raise NotImplementedError - def read_bytes(self): + def read_bytes(self) -> bytes: """ Open the file in bytes mode, read it, and close the file. """ with magic_open(self, mode='rb', buffering=0) as f: return f.read() - def read_text(self, encoding=None, errors=None, newline=None): + def read_text( + self, + encoding: str | None = None, + errors: str | None = None, + newline: str | None = None, + ) -> str: """ Open the file in text mode, read it, and close the file. """ @@ -266,7 +278,7 @@ def read_text(self, encoding=None, errors=None, newline=None): return f.read() @abstractmethod - def iterdir(self): + def iterdir(self) -> Iterator[Self]: """Yield path objects of the directory contents. The children are yielded in arbitrary order, and the @@ -274,11 +286,11 @@ def iterdir(self): """ raise NotImplementedError - def glob(self, pattern, *, recurse_symlinks=True): + def glob(self, pattern: str, *, recurse_symlinks: Literal[True] = True) -> Iterator[Self]: """Iterate over this subtree and yield all existing files (of any kind, including directories) matching the given relative pattern. """ - anchor, parts = _explode_path(pattern, self.parser.split) + anchor, parts = _explode_path(pattern, self.parser) if anchor: raise NotImplementedError("Non-relative patterns are unsupported") elif not parts: @@ -290,9 +302,16 @@ def glob(self, pattern, *, recurse_symlinks=True): select = globber.selector(parts) return select(self.joinpath('')) - def walk(self, top_down=True, on_error=None, follow_symlinks=False): + def walk( + self, + top_down: bool = True, + on_error: Callable[[Exception], None] | None = None, + follow_symlinks: bool = False, + ) -> Iterator[tuple[Self, list[str], list[str]]]: """Walk the directory tree from this directory, similar to os.walk().""" - paths = [self] + dirnames: list[str] + filenames: list[str] + paths: list[Self | tuple[Self, list[str], list[str]]] = [self] while paths: path = paths.pop() if isinstance(path, tuple): @@ -322,13 +341,13 @@ def walk(self, top_down=True, on_error=None, follow_symlinks=False): paths += [path.joinpath(d) for d in reversed(dirnames)] @abstractmethod - def readlink(self): + def readlink(self) -> Self: """ Return the path to which the symbolic link points. """ raise NotImplementedError - def copy(self, target, **kwargs): + def copy[T: _WritablePath](self, target: T, **kwargs: Any) -> T: """ Recursively copy this file or directory tree to the given destination. """ @@ -336,7 +355,7 @@ def copy(self, target, **kwargs): target._copy_from(self, **kwargs) return target.joinpath() # Empty join to ensure fresh metadata. - def copy_into(self, target_dir, **kwargs): + def copy_into[T: _WritablePath](self, target_dir: T, **kwargs: Any) -> T: """ Copy this file or directory tree into the given existing directory. """ @@ -356,7 +375,7 @@ class _WritablePath(_JoinablePath): __slots__ = () @abstractmethod - def symlink_to(self, target, target_is_directory=False): + def symlink_to(self, target: str, target_is_directory: bool = False) -> None: """ Make this path a symlink pointing to the target path. Note the order of arguments (link, target) is the reverse of os.symlink. @@ -364,21 +383,21 @@ def symlink_to(self, target, target_is_directory=False): raise NotImplementedError @abstractmethod - def mkdir(self): + def mkdir(self) -> None: """ Create a new directory at this given path. """ raise NotImplementedError @abstractmethod - def __open_wb__(self, buffering=-1): + def __open_wb__(self, buffering: int = -1) -> BinaryIO: """ Open the file pointed to by this path for writing in binary mode and return a file object, like open(mode='wb'). """ raise NotImplementedError - def write_bytes(self, data): + def write_bytes(self, data: bytes) -> int: """ Open the file in bytes mode, write to it, and close the file. """ @@ -387,7 +406,13 @@ def write_bytes(self, data): with magic_open(self, mode='wb') as f: return f.write(view) - def write_text(self, data, encoding=None, errors=None, newline=None): + def write_text( + self, + data: str, + encoding: str | None = None, + errors: str | None = None, + newline: str | None = None, + ) -> int: """ Open the file in text mode, write to it, and close the file. """ @@ -397,7 +422,7 @@ def write_text(self, data, encoding=None, errors=None, newline=None): with magic_open(self, mode='w', encoding=encoding, errors=errors, newline=newline) as f: return f.write(data) - def _copy_from(self, source, follow_symlinks=True): + def _copy_from(self, source: _ReadablePath, *, follow_symlinks: bool = True) -> None: """ Recursively copy the given path to this path. """ diff --git a/Misc/mypy/pathlib b/Misc/mypy/pathlib new file mode 120000 index 00000000000000..d4940a89e63c24 --- /dev/null +++ b/Misc/mypy/pathlib @@ -0,0 +1 @@ +../../Lib/pathlib \ No newline at end of file diff --git a/Misc/mypy/typed-stdlib.txt b/Misc/mypy/typed-stdlib.txt index 8cd6858b4e591e..19338a29b3f0fa 100644 --- a/Misc/mypy/typed-stdlib.txt +++ b/Misc/mypy/typed-stdlib.txt @@ -2,3 +2,4 @@ _colorize.py _pyrepl +pathlib