From 8fe4d73971ab4d8fc1997fe9f29af3f4d6f01c28 Mon Sep 17 00:00:00 2001 From: Alyssa Coghlan Date: Thu, 23 May 2024 00:26:48 +1000 Subject: [PATCH] Fix typechecking with recent mypy releases (#59) * sync with latest typeshed stub file (closes #54) * publish `dev/mypy.allowlist` in sdist (closes #53) * drop Python 3.7 support due to positional-only arg syntax in the updated stub file --- .github/workflows/test.yml | 2 +- MANIFEST.in | 2 +- NEWS.rst | 22 ++++ contextlib2/__init__.py | 4 +- contextlib2/__init__.pyi | 230 ++++++++++++++++++++++++------------- contextlib2/_typeshed.py | 5 - dev/mypy.allowlist | 9 +- docs/index.rst | 2 +- tox.ini | 10 +- 9 files changed, 189 insertions(+), 97 deletions(-) delete mode 100644 contextlib2/_typeshed.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5949cda..6c2abe5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,7 @@ jobs: strategy: max-parallel: 5 matrix: - python-version: [3.7, 3.8, 3.9, '3.10', 3.11, 3.12, 'pypy-3.10'] + python-version: [3.8, 3.9, '3.10', 3.11, 3.12, 'pypy-3.10'] # Check https://github.com/actions/action-versions/tree/main/config/actions # for latest versions if the standard actions start emitting warnings diff --git a/MANIFEST.in b/MANIFEST.in index 46fdec8..d67e69f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ -include *.py *.cfg *.txt *.rst *.md *.ini MANIFEST.in +include *.py *.cfg *.txt *.rst *.md *.ini MANIFEST.in dev/mypy.allowlist recursive-include contextlib2 *.py *.pyi py.typed recursive-include docs *.rst *.py make.bat Makefile recursive-include test *.py diff --git a/NEWS.rst b/NEWS.rst index 94f1d3d..1d56854 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,6 +1,28 @@ Release History --------------- +24.6.0 (2024-06-??) +^^^^^^^^^^^^^^^^^^^ + +* Due to the use of positional-only argument syntax, the minimum supported + Python version is now Python 3.8. +* Update ``mypy stubtest`` to work with recent mypy versions (mypy 1.8.0 tested) + (`#54 `__) +* The ``dev/mypy.allowlist`` file needed for the ``mypy stubtest`` step in the + ``tox`` test configuration is now included in the published sdist + (`#53 `__) +* Type hints have been updated to include ``nullcontext`` (3.10 API added in + 21.6.0) (`#41 `__) +* Test suite updated to pass on Python 3.11 and 3.12 (21.6.0 works on these + versions, the test suite just failed due to no longer valid assumptions) + (`#51 `__) +* Updates to the default compatibility testing matrix: + + * Added: CPython 3.11, CPython 3.12 + * Dropped: CPython 3.6, CPython 3.7 + +python -m mypy.stubtest --allowlist dev/mypy.allowlist contextlib2 + 21.6.0 (2021-06-27) ^^^^^^^^^^^^^^^^^^^ diff --git a/contextlib2/__init__.py b/contextlib2/__init__.py index d6c0c4a..33b3b47 100644 --- a/contextlib2/__init__.py +++ b/contextlib2/__init__.py @@ -8,7 +8,7 @@ from functools import wraps from types import MethodType -# Python 3.6/3.7/3.8 compatibility: GenericAlias may not be defined +# Python 3.7/3.8 compatibility: GenericAlias may not be defined try: from types import GenericAlias except ImportError: @@ -23,8 +23,6 @@ class GenericAlias: "AsyncExitStack", "ContextDecorator", "ExitStack", "redirect_stdout", "redirect_stderr", "suppress", "aclosing"] -# Backwards compatibility -__all__ += ["ContextStack"] class AbstractContextManager(abc.ABC): """An abstract base class for context managers.""" diff --git a/contextlib2/__init__.pyi b/contextlib2/__init__.pyi index 30ab561..1f2dd67 100644 --- a/contextlib2/__init__.pyi +++ b/contextlib2/__init__.pyi @@ -1,132 +1,204 @@ # Type hints copied from the typeshed project under the Apache License 2.0 # https://github.com/python/typeshed/blob/64c85cdd449ccaff90b546676220c9ecfa6e697f/LICENSE -import sys -from ._typeshed import Self -from types import TracebackType -from typing import ( - IO, - Any, - AsyncContextManager, - AsyncIterator, - Awaitable, - Callable, - ContextManager, - Iterator, - Optional, - Type, - TypeVar, - overload, -) -from typing_extensions import ParamSpec, Protocol +# For updates: https://github.com/python/typeshed/blob/main/stdlib/contextlib.pyi + +# Last updated: 2024-05-22 +# Updated from: https://github.com/python/typeshed/blob/aa2d33df211e1e4f70883388febf750ac524d2bb/stdlib/contextlib.pyi # contextlib2 API adaptation notes: # * the various 'if True:' guards replace sys.version checks in the original # typeshed file (those APIs are available on all supported versions) +# * any commented out 'if True:' guards replace sys.version checks in the original +# typeshed file where the affected APIs haven't been backported yet # * deliberately omitted APIs are listed in `dev/mypy.allowlist` # (e.g. deprecated experimental APIs that never graduated to the stdlib) -AbstractContextManager = ContextManager +import abc +import sys +from _typeshed import FileDescriptorOrPath, Unused +from abc import abstractmethod +from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Generator, Iterator +from types import TracebackType +from typing import IO, Any, Generic, Protocol, TypeVar, overload, runtime_checkable +from typing_extensions import ParamSpec, Self, TypeAlias + +__all__ = [ + "contextmanager", + "closing", + "AbstractContextManager", + "ContextDecorator", + "ExitStack", + "redirect_stdout", + "redirect_stderr", + "suppress", + "AbstractAsyncContextManager", + "AsyncExitStack", + "asynccontextmanager", + "nullcontext", +] + if True: - AbstractAsyncContextManager = AsyncContextManager + __all__ += ["aclosing"] + +# if True: +# __all__ += ["chdir"] _T = TypeVar("_T") _T_co = TypeVar("_T_co", covariant=True) -_T_io = TypeVar("_T_io", bound=Optional[IO[str]]) +_T_io = TypeVar("_T_io", bound=IO[str] | None) +_ExitT_co = TypeVar("_ExitT_co", covariant=True, bound=bool | None, default=bool | None) _F = TypeVar("_F", bound=Callable[..., Any]) _P = ParamSpec("_P") -_ExitFunc = Callable[[Optional[Type[BaseException]], Optional[BaseException], Optional[TracebackType]], bool] -_CM_EF = TypeVar("_CM_EF", ContextManager[Any], _ExitFunc) +_ExitFunc: TypeAlias = Callable[[type[BaseException] | None, BaseException | None, TracebackType | None], bool | None] +_CM_EF = TypeVar("_CM_EF", bound=AbstractContextManager[Any, Any] | _ExitFunc) + +@runtime_checkable +class AbstractContextManager(Protocol[_T_co, _ExitT_co]): + def __enter__(self) -> _T_co: ... + @abstractmethod + def __exit__( + self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, / + ) -> _ExitT_co: ... -class _GeneratorContextManager(ContextManager[_T_co]): +@runtime_checkable +class AbstractAsyncContextManager(Protocol[_T_co, _ExitT_co]): + async def __aenter__(self) -> _T_co: ... + @abstractmethod + async def __aexit__( + self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, / + ) -> _ExitT_co: ... + +class ContextDecorator: def __call__(self, func: _F) -> _F: ... -# type ignore to deal with incomplete ParamSpec support in mypy -def contextmanager(func: Callable[_P, Iterator[_T]]) -> Callable[_P, _GeneratorContextManager[_T]]: ... # type: ignore +class _GeneratorContextManager(AbstractContextManager[_T_co, bool | None], ContextDecorator): + # __init__ and all instance attributes are actually inherited from _GeneratorContextManagerBase + # _GeneratorContextManagerBase is more trouble than it's worth to include in the stub; see #6676 + def __init__(self, func: Callable[..., Iterator[_T_co]], args: tuple[Any, ...], kwds: dict[str, Any]) -> None: ... + gen: Generator[_T_co, Any, Any] + func: Callable[..., Generator[_T_co, Any, Any]] + args: tuple[Any, ...] + kwds: dict[str, Any] + if False: + def __exit__( + self, typ: type[BaseException] | None, value: BaseException | None, traceback: TracebackType | None + ) -> bool | None: ... + else: + def __exit__( + self, type: type[BaseException] | None, value: BaseException | None, traceback: TracebackType | None + ) -> bool | None: ... + +def contextmanager(func: Callable[_P, Iterator[_T_co]]) -> Callable[_P, _GeneratorContextManager[_T_co]]: ... if True: - def asynccontextmanager(func: Callable[_P, AsyncIterator[_T]]) -> Callable[_P, AsyncContextManager[_T]]: ... # type: ignore + _AF = TypeVar("_AF", bound=Callable[..., Awaitable[Any]]) + + class AsyncContextDecorator: + def __call__(self, func: _AF) -> _AF: ... + + class _AsyncGeneratorContextManager(AbstractAsyncContextManager[_T_co, bool | None], AsyncContextDecorator): + # __init__ and these attributes are actually defined in the base class _GeneratorContextManagerBase, + # which is more trouble than it's worth to include in the stub (see #6676) + def __init__(self, func: Callable[..., AsyncIterator[_T_co]], args: tuple[Any, ...], kwds: dict[str, Any]) -> None: ... + gen: AsyncGenerator[_T_co, Any] + func: Callable[..., AsyncGenerator[_T_co, Any]] + args: tuple[Any, ...] + kwds: dict[str, Any] + async def __aexit__( + self, typ: type[BaseException] | None, value: BaseException | None, traceback: TracebackType | None + ) -> bool | None: ... + +def asynccontextmanager(func: Callable[_P, AsyncIterator[_T_co]]) -> Callable[_P, _AsyncGeneratorContextManager[_T_co]]: ... class _SupportsClose(Protocol): def close(self) -> object: ... _SupportsCloseT = TypeVar("_SupportsCloseT", bound=_SupportsClose) -class closing(ContextManager[_SupportsCloseT]): +class closing(AbstractContextManager[_SupportsCloseT, None]): def __init__(self, thing: _SupportsCloseT) -> None: ... + def __exit__(self, *exc_info: Unused) -> None: ... if True: class _SupportsAclose(Protocol): def aclose(self) -> Awaitable[object]: ... + _SupportsAcloseT = TypeVar("_SupportsAcloseT", bound=_SupportsAclose) - class aclosing(AsyncContextManager[_SupportsAcloseT]): + + class aclosing(AbstractAsyncContextManager[_SupportsAcloseT, None]): def __init__(self, thing: _SupportsAcloseT) -> None: ... - _AF = TypeVar("_AF", bound=Callable[..., Awaitable[Any]]) - class AsyncContextDecorator: - def __call__(self, func: _AF) -> _AF: ... + async def __aexit__(self, *exc_info: Unused) -> None: ... -class suppress(ContextManager[None]): - def __init__(self, *exceptions: Type[BaseException]) -> None: ... +class suppress(AbstractContextManager[None, bool]): + def __init__(self, *exceptions: type[BaseException]) -> None: ... def __exit__( - self, exctype: Optional[Type[BaseException]], excinst: Optional[BaseException], exctb: Optional[TracebackType] + self, exctype: type[BaseException] | None, excinst: BaseException | None, exctb: TracebackType | None ) -> bool: ... -class redirect_stdout(ContextManager[_T_io]): - def __init__(self, new_target: _T_io) -> None: ... - -class redirect_stderr(ContextManager[_T_io]): +class _RedirectStream(AbstractContextManager[_T_io, None]): def __init__(self, new_target: _T_io) -> None: ... + def __exit__( + self, exctype: type[BaseException] | None, excinst: BaseException | None, exctb: TracebackType | None + ) -> None: ... -class ContextDecorator: - def __call__(self, func: _F) -> _F: ... +class redirect_stdout(_RedirectStream[_T_io]): ... +class redirect_stderr(_RedirectStream[_T_io]): ... -class ExitStack(ContextManager[ExitStack]): - def __init__(self) -> None: ... - def enter_context(self, cm: ContextManager[_T]) -> _T: ... +# In reality this is a subclass of `AbstractContextManager`; +# see #7961 for why we don't do that in the stub +class ExitStack(Generic[_ExitT_co], metaclass=abc.ABCMeta): + def enter_context(self, cm: AbstractContextManager[_T, _ExitT_co]) -> _T: ... def push(self, exit: _CM_EF) -> _CM_EF: ... - def callback(self, callback: Callable[..., Any], *args: Any, **kwds: Any) -> Callable[..., Any]: ... - def pop_all(self: Self) -> Self: ... + def callback(self, callback: Callable[_P, _T], /, *args: _P.args, **kwds: _P.kwargs) -> Callable[_P, _T]: ... + def pop_all(self) -> Self: ... def close(self) -> None: ... - def __enter__(self: Self) -> Self: ... + def __enter__(self) -> Self: ... def __exit__( - self, - __exc_type: Optional[Type[BaseException]], - __exc_value: Optional[BaseException], - __traceback: Optional[TracebackType], + self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, / + ) -> _ExitT_co: ... + +_ExitCoroFunc: TypeAlias = Callable[ + [type[BaseException] | None, BaseException | None, TracebackType | None], Awaitable[bool | None] +] +_ACM_EF = TypeVar("_ACM_EF", bound=AbstractAsyncContextManager[Any, Any] | _ExitCoroFunc) + +# In reality this is a subclass of `AbstractAsyncContextManager`; +# see #7961 for why we don't do that in the stub +class AsyncExitStack(Generic[_ExitT_co], metaclass=abc.ABCMeta): + def enter_context(self, cm: AbstractContextManager[_T, _ExitT_co]) -> _T: ... + async def enter_async_context(self, cm: AbstractAsyncContextManager[_T, _ExitT_co]) -> _T: ... + def push(self, exit: _CM_EF) -> _CM_EF: ... + def push_async_exit(self, exit: _ACM_EF) -> _ACM_EF: ... + def callback(self, callback: Callable[_P, _T], /, *args: _P.args, **kwds: _P.kwargs) -> Callable[_P, _T]: ... + def push_async_callback( + self, callback: Callable[_P, Awaitable[_T]], /, *args: _P.args, **kwds: _P.kwargs + ) -> Callable[_P, Awaitable[_T]]: ... + def pop_all(self) -> Self: ... + async def aclose(self) -> None: ... + async def __aenter__(self) -> Self: ... + async def __aexit__( + self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, / ) -> bool: ... if True: - _ExitCoroFunc = Callable[[Optional[Type[BaseException]], Optional[BaseException], Optional[TracebackType]], Awaitable[bool]] - _CallbackCoroFunc = Callable[..., Awaitable[Any]] - _ACM_EF = TypeVar("_ACM_EF", AsyncContextManager[Any], _ExitCoroFunc) - class AsyncExitStack(AsyncContextManager[AsyncExitStack]): - def __init__(self) -> None: ... - def enter_context(self, cm: ContextManager[_T]) -> _T: ... - def enter_async_context(self, cm: AsyncContextManager[_T]) -> Awaitable[_T]: ... - def push(self, exit: _CM_EF) -> _CM_EF: ... - def push_async_exit(self, exit: _ACM_EF) -> _ACM_EF: ... - def callback(self, callback: Callable[..., Any], *args: Any, **kwds: Any) -> Callable[..., Any]: ... - def push_async_callback(self, callback: _CallbackCoroFunc, *args: Any, **kwds: Any) -> _CallbackCoroFunc: ... - def pop_all(self: Self) -> Self: ... - def aclose(self) -> Awaitable[None]: ... - def __aenter__(self: Self) -> Awaitable[Self]: ... - def __aexit__( - self, - __exc_type: Optional[Type[BaseException]], - __exc_value: Optional[BaseException], - __traceback: Optional[TracebackType], - ) -> Awaitable[bool]: ... - - -if True: - class nullcontext(AbstractContextManager[_T], AbstractAsyncContextManager[_T]): + class nullcontext(AbstractContextManager[_T, None], AbstractAsyncContextManager[_T, None]): enter_result: _T @overload - def __init__(self: nullcontext[None], enter_result: None = ...) -> None: ... + def __init__(self: nullcontext[None], enter_result: None = None) -> None: ... @overload - def __init__(self: nullcontext[_T], enter_result: _T) -> None: ... + def __init__(self: nullcontext[_T], enter_result: _T) -> None: ... # pyright: ignore[reportInvalidTypeVarUse] #11780 def __enter__(self) -> _T: ... - def __exit__(self, *exctype: Any) -> None: ... + def __exit__(self, *exctype: Unused) -> None: ... async def __aenter__(self) -> _T: ... - async def __aexit__(self, *exctype: Any) -> None: ... + async def __aexit__(self, *exctype: Unused) -> None: ... + +# if True: +# _T_fd_or_any_path = TypeVar("_T_fd_or_any_path", bound=FileDescriptorOrPath) + +# class chdir(AbstractContextManager[None, None], Generic[_T_fd_or_any_path]): +# path: _T_fd_or_any_path +# def __init__(self, path: _T_fd_or_any_path) -> None: ... +# def __enter__(self) -> None: ... +# def __exit__(self, *excinfo: Unused) -> None: ... diff --git a/contextlib2/_typeshed.py b/contextlib2/_typeshed.py deleted file mode 100644 index 9e065ba..0000000 --- a/contextlib2/_typeshed.py +++ /dev/null @@ -1,5 +0,0 @@ -from typing import TypeVar # pragma: no cover - -# Use for "self" annotations: -# def __enter__(self: Self) -> Self: ... -Self = TypeVar("Self") # pragma: no cover diff --git a/dev/mypy.allowlist b/dev/mypy.allowlist index e9e26a9..b812112 100644 --- a/dev/mypy.allowlist +++ b/dev/mypy.allowlist @@ -1,3 +1,10 @@ # Deprecated APIs that never graduated to the standard library contextlib2.ContextDecorator.refresh_cm -contextlib2.ContextStack + +# stubcheck no longer complains about this one for some reason +# (but it does complain about the unused allowlist entry) +# contextlib2.ContextStack + +# mypy seems to be confused by the GenericAlias compatibility hack +contextlib2.AbstractAsyncContextManager.__class_getitem__ +contextlib2.AbstractContextManager.__class_getitem__ diff --git a/docs/index.rst b/docs/index.rst index b01e854..2facd28 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -55,7 +55,7 @@ PyPI page`_. There are no operating system or distribution specific versions of this module - it is a pure Python module that should work on all platforms. -Supported Python versions are currently 3.6+. +Supported Python versions are currently 3.8+. .. _Python Package Index: http://pypi.python.org .. _pip: http://www.pip-installer.org diff --git a/tox.ini b/tox.ini index db76a0c..67e55b9 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] -# No Python 3.6 available on current generation GitHub test runners -envlist = py{37,38,39,3.10,3.11,3.12,py3} +# Python 3.8 is the first version with positional-only argument syntax support +envlist = py{38,39,3.10,3.11,3.12,py3} skip_missing_interpreters = True [testenv] @@ -9,18 +9,16 @@ commands = coverage report coverage xml # mypy won't install on PyPy, so only run the typechecking on CPython - # Typechecking is currently failing: https://github.com/jazzband/contextlib2/issues/54 - # !pypy3: python -m mypy.stubtest --allowlist dev/mypy.allowlist contextlib2 + !pypy3: python -m mypy.stubtest --allowlist dev/mypy.allowlist contextlib2 deps = coverage !pypy3: mypy [gh-actions] python = - 3.7: py37 3.8: py38 3.9: py39 3.10: py3.10 3.11: py3.11 3.12: py3.12 - pypy-3.8: pypy3 + pypy-3.10: pypy3