Skip to content

Strict type checking and re-enable mypy #16

New issue

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

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

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
from __future__ import annotations

import functools
import http.server
import io
import functools
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.
"""
Expand All @@ -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)
Expand Down
20 changes: 16 additions & 4 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'),
]
141 changes: 102 additions & 39 deletions jaraco/context/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import builtins
import contextlib
import errno
import functools
Expand All @@ -12,17 +13,43 @@
import sys
import tempfile
import urllib.request
from typing import Iterator
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

if sys.version_info < (3, 12):
from backports import tarfile
else:
import tarfile
_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):
Expand All @@ -39,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.

Expand Down Expand Up @@ -86,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.

Expand All @@ -112,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]
Comment on lines +158 to +162
Copy link
Contributor Author

Choose a reason for hiding this comment

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



tarball_cwd = _compose(pushd, tarball)
Expand All @@ -128,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.
"""
Expand All @@ -142,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.
Expand All @@ -173,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.

Expand All @@ -190,7 +241,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
Expand Down Expand Up @@ -230,37 +281,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
Comment on lines -253 to 312
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Alternatively:

    def __exit__(
        self,
        exc_type: builtins.type[BaseException] | None,
        exc: BaseException | None,
        exc_tb: TracebackType | None,
    ) -> builtins.type[BaseException] | None | bool:
        matches = exc_type and issubclass(exc_type, self.exceptions)
        if matches:
            self.exc_info = (exc_type, exc, exc_tb)  # 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).
Expand All @@ -280,14 +336,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).
Expand Down Expand Up @@ -341,16 +397,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'
10 changes: 9 additions & 1 deletion mypy.ini
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -13,3 +13,11 @@ explicit_package_bases = True
disable_error_code =
# Disable due to many false positives
overload-overlap,

# jaraco/backports.tarfile#1
Copy link
Contributor Author

Choose a reason for hiding this comment

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

[mypy-backports.*]
follow_untyped_imports = True

# jaraco/portend#17
Copy link
Contributor Author

Choose a reason for hiding this comment

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

[mypy-portend.*]
ignore_missing_imports = True
5 changes: 0 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,4 @@ type = [
# local
]


[tool.setuptools_scm]


[tool.pytest-enabler.mypy]
# Disabled due to jaraco/skeleton#143
Loading