From 208d1e5d78beb22261b05baf068dbd4afe6d1665 Mon Sep 17 00:00:00 2001 From: Avasam Date: Wed, 24 Dec 2025 14:53:32 -0500 Subject: [PATCH 1/6] Strict type checking and re-enable mypy --- conftest.py | 10 ++- docs/conf.py | 20 ++++-- jaraco/context/__init__.py | 142 +++++++++++++++++++++++++++---------- mypy.ini | 6 +- pyproject.toml | 5 -- 5 files changed, 131 insertions(+), 52 deletions(-) diff --git a/conftest.py b/conftest.py index 840f3eb..58d98f0 100644 --- a/conftest.py +++ b/conftest.py @@ -1,20 +1,24 @@ +from __future__ import annotations + import functools import http.server import io import tarfile import threading +from collections.abc import Generator +from pathlib import Path import portend import pytest class QuietHTTPRequestHandler(http.server.SimpleHTTPRequestHandler): - def log_message(self, format, *args): + def log_message(self, format: object, *args: object) -> None: pass @pytest.fixture -def tarfile_served(tmp_path_factory): +def tarfile_served(tmp_path_factory: pytest.TempPathFactory) -> Generator[str]: """ Start an HTTP server serving a tarfile. """ @@ -29,7 +33,7 @@ def tarfile_served(tmp_path_factory): yield url + '/served.tgz' -def start_server(path): +def start_server(path: Path) -> tuple[http.server.HTTPServer, str]: _host, port = addr = ('', portend.find_available_local_port()) Handler = functools.partial(QuietHTTPRequestHandler, directory=path) httpd = http.server.HTTPServer(addr, Handler) diff --git a/docs/conf.py b/docs/conf.py index a8db954..46ef1f4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -34,10 +34,6 @@ nitpicky = True nitpick_ignore: list[tuple[str, str]] = [] -nitpick_ignore = [ - ('py:class', 'contextlib.suppress'), - ('py:class', 'jaraco.context.T'), -] # Include Python intersphinx mapping to prevent failures # jaraco/skeleton#51 @@ -62,3 +58,19 @@ # local extensions += ['jaraco.tidelift'] + +nitpick_ignore = [ + ('py:class', 'OptExcInfo'), + ('py:class', 'StrPath'), + ('py:class', '_FileDescriptorOrPathT'), + ('py:class', '_P'), + ('py:class', '_P.args'), + ('py:class', '_P.kwargs'), + ('py:class', '_R'), + ('py:class', '_T2_co'), + ('py:class', 'contextlib.suppress'), + ('py:class', 'functools._Wrapped'), + ('py:class', 'jaraco.context.T'), + ('py:class', 'jaraco.context._T2_co'), + ('py:class', 'shutil._RmtreeType'), +] diff --git a/jaraco/context/__init__.py b/jaraco/context/__init__.py index 3551b2c..1a41abc 100644 --- a/jaraco/context/__init__.py +++ b/jaraco/context/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +import builtins import contextlib import errno import functools @@ -12,16 +13,43 @@ import sys import tempfile import urllib.request -from collections.abc import Iterator - -if sys.version_info < (3, 12): - from backports import tarfile -else: +from collections.abc import Callable, Generator, Iterator +from types import TracebackType +from typing import ( + TYPE_CHECKING, + Literal, + TypeVar, + Union, + cast, +) + +# jaraco/backports.tarfile#1 +if TYPE_CHECKING or sys.version_info >= (3, 12): import tarfile +else: + from backports import tarfile + +if TYPE_CHECKING: + from _typeshed import FileDescriptorOrPath, OptExcInfo, StrPath + from typing_extensions import ParamSpec, Self, TypeAlias, Unpack + + _FileDescriptorOrPathT = TypeVar( + "_FileDescriptorOrPathT", bound=FileDescriptorOrPath + ) + _P = ParamSpec("_P") + +_UnpackableOptExcInfo: TypeAlias = tuple[ + Union[type[BaseException], None], + Union[BaseException, None], + Union[TracebackType, None], +] +_R = TypeVar("_R") +_T1_co = TypeVar("_T1_co", covariant=True) +_T2_co = TypeVar("_T2_co", covariant=True) @contextlib.contextmanager -def pushd(dir: str | os.PathLike) -> Iterator[str | os.PathLike]: +def pushd(dir: StrPath) -> Iterator[StrPath]: """ >>> tmp_path = getfixture('tmp_path') >>> with pushd(tmp_path): @@ -38,9 +66,7 @@ def pushd(dir: str | os.PathLike) -> Iterator[str | os.PathLike]: @contextlib.contextmanager -def tarball( - url, target_dir: str | os.PathLike | None = None -) -> Iterator[str | os.PathLike]: +def tarball(url: str, target_dir: StrPath | None = None) -> Iterator[StrPath]: """ Get a URL to a tarball, download, extract, yield, then clean up. @@ -85,13 +111,21 @@ def tarball( def strip_first_component( member: tarfile.TarInfo, - path, + path: object, ) -> tarfile.TarInfo: _, member.name = member.name.split('/', 1) return member -def _compose(*cmgrs): +def _compose( + *cmgrs: Unpack[ + tuple[ + # Flipped from compose_two because of reverse + Callable[[_T1_co], contextlib.AbstractContextManager[_T2_co]], + Callable[_P, contextlib.AbstractContextManager[_T1_co]], + ] + ], +) -> Callable[_P, contextlib._GeneratorContextManager[_T2_co]]: """ Compose any number of dependent context managers into a single one. @@ -111,14 +145,21 @@ def _compose(*cmgrs): ... assert os.path.samefile(os.getcwd(), dir) """ - def compose_two(inner, outer): - def composed(*args, **kwargs): + def compose_two( + inner: Callable[_P, contextlib.AbstractContextManager[_T1_co]], + outer: Callable[[_T1_co], contextlib.AbstractContextManager[_T2_co]], + ) -> Callable[_P, contextlib._GeneratorContextManager[_T2_co]]: + def composed(*args: _P.args, **kwargs: _P.kwargs) -> Generator[_T2_co]: with inner(*args, **kwargs) as saved, outer(saved) as res: yield res return contextlib.contextmanager(composed) - return functools.reduce(compose_two, reversed(cmgrs)) + # reversed makes cmgrs no longer variadic, breaking type validation + # Mypy infers compose_two as Callable[[function, function], function]. See: + # - https://github.com/python/typeshed/issues/7580 + # - https://github.com/python/mypy/issues/8240 + return functools.reduce(compose_two, reversed(cmgrs)) # type: ignore[return-value, arg-type] tarball_cwd = _compose(pushd, tarball) @@ -127,7 +168,11 @@ def composed(*args, **kwargs): """ -def remove_readonly(func, path, exc_info): +def remove_readonly( + func: Callable[[_FileDescriptorOrPathT], object], + path: _FileDescriptorOrPathT, + exc_info: tuple[object, OSError, object], +) -> None: """ Add support for removing read-only files on Windows. """ @@ -141,16 +186,20 @@ def remove_readonly(func, path, exc_info): raise -def robust_remover(): +def robust_remover() -> Callable[..., None]: return ( - functools.partial(shutil.rmtree, onerror=remove_readonly) + functools.partial( + # cast for python/mypy#18637 / python/mypy#17585 + cast("Callable[..., None]", shutil.rmtree), + onerror=remove_readonly, + ) if platform.system() == 'Windows' else shutil.rmtree ) @contextlib.contextmanager -def temp_dir(remover=shutil.rmtree): +def temp_dir(remover: Callable[[str], object] = shutil.rmtree) -> Generator[str]: """ Create a temporary directory context. Pass a custom remover to override the removal behavior. @@ -172,8 +221,11 @@ def temp_dir(remover=shutil.rmtree): @contextlib.contextmanager def repo_context( - url, branch: str | None = None, quiet: bool = True, dest_ctx=robust_temp_dir -): + url: str, + branch: str | None = None, + quiet: bool = True, + dest_ctx: Callable[[], contextlib.AbstractContextManager[str]] = robust_temp_dir, +) -> Generator[str]: """ Check out the repo indicated by url. @@ -191,7 +243,7 @@ def repo_context( exe = 'git' if 'git' in url else 'hg' with dest_ctx() as repo_dir: cmd = [exe, 'clone', url, repo_dir] - cmd.extend(['--branch', branch] * bool(branch)) + cmd.extend(['--branch', branch] if branch else []) stream = subprocess.DEVNULL if quiet else None subprocess.check_call(cmd, stdout=stream, stderr=stream) yield repo_dir @@ -231,37 +283,42 @@ class ExceptionTrap: False """ - exc_info = None, None, None + exc_info: OptExcInfo = None, None, None - def __init__(self, exceptions=(Exception,)): + def __init__(self, exceptions: tuple[type[BaseException], ...] = (Exception,)): self.exceptions = exceptions - def __enter__(self): + def __enter__(self) -> Self: return self @property - def type(self): + def type(self) -> type[BaseException] | None: return self.exc_info[0] @property - def value(self): + def value(self) -> BaseException | None: return self.exc_info[1] @property - def tb(self): + def tb(self) -> TracebackType | None: return self.exc_info[2] - def __exit__(self, *exc_info): - type = exc_info[0] - matches = type and issubclass(type, self.exceptions) + def __exit__( + self, + *exc_info: Unpack[_UnpackableOptExcInfo], # noqa: PYI036 # We can do better than object + ) -> builtins.type[BaseException] | None | bool: + exc_type = exc_info[0] + matches = exc_type and issubclass(exc_type, self.exceptions) if matches: - self.exc_info = exc_info + self.exc_info = exc_info # type: ignore[assignment] return matches - def __bool__(self): + def __bool__(self) -> bool: return bool(self.type) - def raises(self, func, *, _test=bool): + def raises( + self, func: Callable[_P, _R], *, _test: Callable[[ExceptionTrap], bool] = bool + ) -> functools._Wrapped[_P, _R, _P, bool]: """ Wrap func and replace the result with the truth value of the trap (True if an exception occurred). @@ -281,14 +338,14 @@ def raises(self, func, *, _test=bool): """ @functools.wraps(func) - def wrapper(*args, **kwargs): + def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> bool: with ExceptionTrap(self.exceptions) as trap: func(*args, **kwargs) return _test(trap) return wrapper - def passes(self, func): + def passes(self, func: Callable[_P, _R]) -> functools._Wrapped[_P, _R, _P, bool]: """ Wrap func and replace the result with the truth value of the trap (True if no exception). @@ -342,16 +399,23 @@ class on_interrupt(contextlib.ContextDecorator): ... on_interrupt('ignore')(do_interrupt)() """ - def __init__(self, action='error', /, code=1): + def __init__( + self, action: Literal['ignore', 'suppress', 'error'] = 'error', /, code: int = 1 + ): self.action = action self.code = code - def __enter__(self): + def __enter__(self) -> Self: return self - def __exit__(self, exctype, excinst, exctb): + def __exit__( + self, + exctype: type[BaseException] | None, + excinst: BaseException | None, + exctb: TracebackType | None, + ) -> None | bool: if exctype is not KeyboardInterrupt or self.action == 'ignore': - return + return None elif self.action == 'error': raise SystemExit(self.code) from excinst return self.action == 'suppress' diff --git a/mypy.ini b/mypy.ini index 70d27c9..03941de 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,6 +1,6 @@ [mypy] # Is the project well-typed? -strict = False +strict = True # Early opt-in even when strict = False warn_unused_ignores = True @@ -14,6 +14,10 @@ disable_error_code = # Disable due to many false positives overload-overlap, +# jaraco/backports.tarfile#1 +[mypy-backports.*] +follow_untyped_imports = True + # jaraco/portend#17 [mypy-portend.*] ignore_missing_imports = True diff --git a/pyproject.toml b/pyproject.toml index 21e2169..d8d842f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,9 +77,4 @@ type = [ # local ] - [tool.setuptools_scm] - - -[tool.pytest-enabler.mypy] -# Disabled due to jaraco/skeleton#143 From 7df1443e0d81d0f7a781231ab05e00fc4173672e Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Fri, 20 Mar 2026 16:38:09 -0400 Subject: [PATCH 2/6] Restore expression algebra. --- jaraco/context/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jaraco/context/__init__.py b/jaraco/context/__init__.py index 1a41abc..8738f03 100644 --- a/jaraco/context/__init__.py +++ b/jaraco/context/__init__.py @@ -243,7 +243,7 @@ def repo_context( exe = 'git' if 'git' in url else 'hg' with dest_ctx() as repo_dir: cmd = [exe, 'clone', url, repo_dir] - cmd.extend(['--branch', branch] if branch else []) + cmd.extend(['--branch', branch] * bool(branch)) # type: ignore[list-item] stream = subprocess.DEVNULL if quiet else None subprocess.check_call(cmd, stdout=stream, stderr=stream) yield repo_dir From 8eee4d0bbf99ee75163865b150e638378e5a0b46 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Fri, 20 Mar 2026 16:46:23 -0400 Subject: [PATCH 3/6] Modernize with ruff --- jaraco/context/__init__.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/jaraco/context/__init__.py b/jaraco/context/__init__.py index 8e410ae..208fbd6 100644 --- a/jaraco/context/__init__.py +++ b/jaraco/context/__init__.py @@ -19,7 +19,6 @@ TYPE_CHECKING, Literal, TypeVar, - Union, cast, ) @@ -30,8 +29,10 @@ from backports import tarfile if TYPE_CHECKING: + from typing import TypeAlias + from _typeshed import FileDescriptorOrPath, OptExcInfo, StrPath - from typing_extensions import ParamSpec, Self, TypeAlias, Unpack + from typing_extensions import ParamSpec, Self, Unpack _FileDescriptorOrPathT = TypeVar( "_FileDescriptorOrPathT", bound=FileDescriptorOrPath @@ -39,9 +40,9 @@ _P = ParamSpec("_P") _UnpackableOptExcInfo: TypeAlias = tuple[ - Union[type[BaseException], None], - Union[BaseException, None], - Union[TracebackType, None], + type[BaseException] | None, + BaseException | None, + TracebackType | None, ] _R = TypeVar("_R") _T1_co = TypeVar("_T1_co", covariant=True) From bc0dc8a641a0ef9539bd7b193c961f8de05344e0 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Fri, 20 Mar 2026 16:49:01 -0400 Subject: [PATCH 4/6] Suppress type errors with new property. --- jaraco/context/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/jaraco/context/__init__.py b/jaraco/context/__init__.py index 208fbd6..9f14849 100644 --- a/jaraco/context/__init__.py +++ b/jaraco/context/__init__.py @@ -110,8 +110,8 @@ def tarball(url: str, target_dir: StrPath | None = None) -> Iterator[StrPath]: shutil.rmtree(target_dir) -def _compose_tarfile_filters(*filters): - def compose_two(f1, f2): +def _compose_tarfile_filters(*filters): # type: ignore[no-untyped-def] + def compose_two(f1, f2): # type: ignore[no-untyped-def] return lambda member, path: f1(f2(member, path), path) return functools.reduce(compose_two, filters, lambda member, path: member) @@ -125,7 +125,7 @@ def strip_first_component( return member -_default_filter = _compose_tarfile_filters(tarfile.data_filter, strip_first_component) +_default_filter = _compose_tarfile_filters(tarfile.data_filter, strip_first_component) # type: ignore[no-untyped-call] def _compose( From 0190667b24bd7b7f5f199ec36dc6e974fa31aab2 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Fri, 20 Mar 2026 16:50:35 -0400 Subject: [PATCH 5/6] Suppress type errors in test_safety. --- tests/test_safety.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_safety.py b/tests/test_safety.py index e407115..a168a16 100644 --- a/tests/test_safety.py +++ b/tests/test_safety.py @@ -1,3 +1,5 @@ +# type: ignore + import io import sys import types From 256995fa4d2bcaf5513d7d14204045de94b4957d Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Fri, 20 Mar 2026 16:52:34 -0400 Subject: [PATCH 6/6] Suppress coverage errors. --- jaraco/context/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jaraco/context/__init__.py b/jaraco/context/__init__.py index 9f14849..42f91d1 100644 --- a/jaraco/context/__init__.py +++ b/jaraco/context/__init__.py @@ -24,9 +24,9 @@ # jaraco/backports.tarfile#1 if TYPE_CHECKING or sys.version_info >= (3, 12): - import tarfile + import tarfile # pragma: no cover else: - from backports import tarfile + from backports import tarfile # pragma: no cover if TYPE_CHECKING: from typing import TypeAlias