From 330f34d826f3c7f9434de0188a9b5d718ab24d4e Mon Sep 17 00:00:00 2001 From: Max Fischer Date: Sun, 4 Aug 2024 22:57:40 +0200 Subject: [PATCH 01/12] update docs and types --- unittests/utility.py | 64 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/unittests/utility.py b/unittests/utility.py index 3beb830..de85d53 100644 --- a/unittests/utility.py +++ b/unittests/utility.py @@ -1,6 +1,7 @@ from typing import ( Callable, Coroutine, + Generator, Iterable, AsyncIterator, TypeVar, @@ -17,32 +18,52 @@ async def asyncify(iterable: Iterable[T]) -> AsyncIterator[T]: - """Convert an iterable to async iterable""" + """ + Convert an iterable into an async iterable + + This is intended to sequence literals like lists to `async` iterators + in order to force usage of `async` code paths. There is no functional + or other advantage otherwise. + """ for value in iterable: yield value def awaitify(call: Callable[..., T]) -> Callable[..., Awaitable[T]]: - async def await_wrapper(*args, **kwargs): + """ + Convert a callable (`foo()`) into an async callable (`await foo()`) + + This is intended to convert `lambda` expressions to `async` functions + in order to force usage of `async` code paths. There is no functional + or other advantage otherwise. + """ + + async def await_wrapper(*args: Any, **kwargs: Any) -> T: return call(*args, **kwargs) return await_wrapper class PingPong: - """Signal to the event loop which gets returned unchanged""" + """ + Signal to the event loop which gets returned unchanged - def __await__(self): + The coroutine yields to the event loop but is resumed + immediately, without running others in the meantime. + This is mainly useful for debugging the event loop. + """ + + def __await__(self) -> "Generator[PingPong, Any, Any]": return (yield self) -async def inside_loop(): +async def inside_loop() -> bool: """Test whether there is an active event loop available""" signal = PingPong() return await signal is signal -def sync(test_case: Callable[..., Coroutine[T, Any, Any]]) -> Callable[..., T]: +def sync(test_case: Callable[..., Coroutine[None, Any, Any]]) -> Callable[..., None]: """ Mark an ``async def`` test case to be run synchronously @@ -51,7 +72,7 @@ def sync(test_case: Callable[..., Coroutine[T, Any, Any]]) -> Callable[..., T]: """ @wraps(test_case) - def run_sync(*args: Any, **kwargs: Any) -> T: + def run_sync(*args: Any, **kwargs: Any) -> None: coro = test_case(*args, **kwargs) try: event = None @@ -63,13 +84,21 @@ def run_sync(*args: Any, **kwargs: Any) -> T: ) except StopIteration as e: result = e.args[0] if e.args else None + assert result is None, f"got '{result!r}' expected 'None'" return result return run_sync class Schedule: - """Signal to the event loop to adopt and run a new coroutine""" + r""" + Signal to the event loop to adopt and run new coroutines + + :param coros: The coroutines to start running + + In order to communicate with the event loop and start the coroutines, + the :py:class:`Schedule` must be `await`\ ed. + """ def __init__(self, *coros: Coroutine[Any, Any, Any]): self.coros = coros @@ -79,13 +108,22 @@ def __await__(self): class Switch: - """Signal to the event loop to run another coroutine""" + """ + Signal to the event loop to run another coroutine + + Pauses the coroutine but immediately continues after + all other runnable coroutines of the event loop. + This is similar to the common ``sleep(0)`` function + of regular event loop frameworks. + """ def __await__(self): yield self class Lock: + """Simple lock for exclusive access""" + def __init__(self): self._owned = False self._waiting: list[object] = [] @@ -95,10 +133,10 @@ async def __aenter__(self): # wait until it is our turn to take the lock token = object() self._waiting.append(token) + # a spin-lock should be fine since tests are short anyways while self._owned or self._waiting[0] is not token: await Switch() - # take the lock and remove our wait claim - self._owned = True + # we will take the lock now, remove our wait claim self._waiting.pop(0) self._owned = True @@ -106,7 +144,9 @@ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any): self._owned = False -def multi_sync(test_case: Callable[..., Coroutine[T, Any, Any]]) -> Callable[..., T]: +def multi_sync( + test_case: Callable[..., Coroutine[None, Any, Any]] +) -> Callable[..., None]: """ Mark an ``async def`` test case to be run synchronously with children From a416bfbba1c80b6bee39d3e3e581c7502232ddf1 Mon Sep 17 00:00:00 2001 From: Max Fischer Date: Sun, 4 Aug 2024 23:30:09 +0200 Subject: [PATCH 02/12] move devel pages to new folder --- docs/index.rst | 4 ++-- docs/source/{ => devel}/contributing.rst | 0 docs/source/{ => devel}/publishing.rst | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename docs/source/{ => devel}/contributing.rst (100%) rename docs/source/{ => devel}/publishing.rst (100%) diff --git a/docs/index.rst b/docs/index.rst index 44bd346..fdb447b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -56,8 +56,8 @@ The missing ``async`` toolbox :caption: Development & Maintenance :hidden: - source/contributing - source/publishing + source/devel/contributing + source/devel/publishing The ``asyncstdlib`` library re-implements functions and classes of the Python standard library to make them compatible with ``async`` callables, iterables diff --git a/docs/source/contributing.rst b/docs/source/devel/contributing.rst similarity index 100% rename from docs/source/contributing.rst rename to docs/source/devel/contributing.rst diff --git a/docs/source/publishing.rst b/docs/source/devel/publishing.rst similarity index 100% rename from docs/source/publishing.rst rename to docs/source/devel/publishing.rst From ad6aafc1c53110ee78b9ed24945f61cf72c0858e Mon Sep 17 00:00:00 2001 From: Max Fischer Date: Sun, 4 Aug 2024 23:52:47 +0200 Subject: [PATCH 03/12] update copyright range --- LICENSE | 2 +- docs/conf.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/LICENSE b/LICENSE index 8f8cbb9..ef7cf27 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 - 2020 Max Fischer +Copyright (c) 2019 - 2024 Max Fischer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/docs/conf.py b/docs/conf.py index ab9f0e9..6500c39 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -26,7 +26,7 @@ project = "asyncstdlib" author = "Max Fischer" -copyright = f"2019 {author}" +copyright = f"2019-2024 {author}" # The short X.Y version version = __version__ From 346c6dc222424c6a182769d5f489c8607174ebe8 Mon Sep 17 00:00:00 2001 From: Max Fischer Date: Mon, 5 Aug 2024 00:12:17 +0200 Subject: [PATCH 04/12] document test loop --- docs/index.rst | 1 + docs/source/devel/testloop.rst | 32 ++++++++++++++++++++++++++++++++ unittests/utility.py | 18 +++++++++++++----- 3 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 docs/source/devel/testloop.rst diff --git a/docs/index.rst b/docs/index.rst index fdb447b..4e3496b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -57,6 +57,7 @@ The missing ``async`` toolbox :hidden: source/devel/contributing + source/devel/testloop source/devel/publishing The ``asyncstdlib`` library re-implements functions and classes of the Python diff --git a/docs/source/devel/testloop.rst b/docs/source/devel/testloop.rst new file mode 100644 index 0000000..e88d6f5 --- /dev/null +++ b/docs/source/devel/testloop.rst @@ -0,0 +1,32 @@ +=============== +Test Event Loop +=============== + +.. py:module:: unittests.utility + :synopsis: testing utilities + +To facilitate event loop agnostic features, :py:mod:`asyncstdlib` includes +its own custom event loop implementation for testing. +This is provided as a simple decorator that is compatible with :py:mod:`pytest`, +as well as a number of `async` commands specific to the event loop. + +Event Loops +=========== + +There are currently two event loops available for either simplicity or concurrency. +In case of doubt, prefer :py:func:`~.multi_sync`. + +.. autofunction:: sync(test_case: (...) -> (await) None) -> (...) -> None + +.. autofunction:: multi_sync(test_case: (...) -> (await) None) -> (...) -> None + +Async commands +============== + +.. autoclass:: PingPong + +.. autoclass:: Schedule(*await Any) + +.. autoclass:: Switch + +.. autoclass:: Lock diff --git a/unittests/utility.py b/unittests/utility.py index de85d53..1ef9b2b 100644 --- a/unittests/utility.py +++ b/unittests/utility.py @@ -50,7 +50,7 @@ class PingPong: The coroutine yields to the event loop but is resumed immediately, without running others in the meantime. - This is mainly useful for debugging the event loop. + This is mainly useful for ensuring the event loop is used. """ def __await__(self) -> "Generator[PingPong, Any, Any]": @@ -68,7 +68,8 @@ def sync(test_case: Callable[..., Coroutine[None, Any, Any]]) -> Callable[..., N Mark an ``async def`` test case to be run synchronously This emulates a primitive "event loop" which only responds - to the :py:class:`PingPong` by sending it back. + to the :py:class:`PingPong` by sending it back. This loop + is appropriate for tests that do not check concurrency. """ @wraps(test_case) @@ -145,14 +146,21 @@ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any): def multi_sync( - test_case: Callable[..., Coroutine[None, Any, Any]] + test_case: Callable[..., Coroutine[None, Any, Any]], / ) -> Callable[..., None]: """ Mark an ``async def`` test case to be run synchronously with children - This emulates a primitive "event loop" which only responds + This provides a primitive "event loop" which only responds to the :py:class:`PingPong`, :py:class:`Schedule`, :py:class:`Switch` - and :py:class:`Lock`. + and :py:class:`Lock`. This loop is appropriate for tests that need + to check concurrency. + + It should be applied as a decorator on an ``async def`` function, which + is then turned into a synchronous callable that will run the ``async def`` + function and all tasks it spawns. + Other decorators, most prominently :py:func:`pytest.mark.parametrize`, + can be applied around it. """ @wraps(test_case) From 85ff33d158c17739c87bd8050b41ba6d7376c53b Mon Sep 17 00:00:00 2001 From: Max Fischer Date: Mon, 5 Aug 2024 00:23:15 +0200 Subject: [PATCH 05/12] fix typo --- docs/source/api/contextlib.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/api/contextlib.rst b/docs/source/api/contextlib.rst index bdf1088..9bb1c6b 100644 --- a/docs/source/api/contextlib.rst +++ b/docs/source/api/contextlib.rst @@ -16,7 +16,7 @@ Context Managers An :term:`abstract base class` for asynchronous context managers This class can be used to check whether some object is an - asynchronous context manager. If a class may inherit from + asynchronous context manager. A class may inherit from ``AbstractContextManager``, in which case it must implement an ``__aenter__`` method; the default ``__aenter__`` returns the asynchronous context manager itself. From caf3bbc46c720fe5bbd97f764256131bb7b3df3f Mon Sep 17 00:00:00 2001 From: Max Fischer Date: Mon, 5 Aug 2024 21:50:09 +0200 Subject: [PATCH 06/12] allow switching multiple times --- docs/source/devel/testloop.rst | 8 +++++++- unittests/test_itertools.py | 6 ++---- unittests/utility.py | 14 ++++++++++++++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/docs/source/devel/testloop.rst b/docs/source/devel/testloop.rst index e88d6f5..05ae498 100644 --- a/docs/source/devel/testloop.rst +++ b/docs/source/devel/testloop.rst @@ -27,6 +27,12 @@ Async commands .. autoclass:: Schedule(*await Any) -.. autoclass:: Switch +.. py:class:: Switch(skip: int, /) + :no-index: + +.. py:class:: Switch(min: int, max: int, /) + :no-index: + +.. autoclass:: Switch() .. autoclass:: Lock diff --git a/unittests/test_itertools.py b/unittests/test_itertools.py index d58b019..b9386ab 100644 --- a/unittests/test_itertools.py +++ b/unittests/test_itertools.py @@ -322,8 +322,7 @@ async def test_tee_concurrent_locked(): async def iter_values(): for item in items: # switch to other tasks a few times to guarantees another runs - for _ in range(5): - await Switch() + await Switch(5) yield item async def test_peer(peer_tee): @@ -354,8 +353,7 @@ async def test_tee_concurrent_unlocked(): async def iter_values(): for item in items: # switch to other tasks a few times to guarantee another runs - for _ in range(5): - await Switch() + await Switch(5) yield item async def test_peer(peer_tee): diff --git a/unittests/utility.py b/unittests/utility.py index 1ef9b2b..ff88344 100644 --- a/unittests/utility.py +++ b/unittests/utility.py @@ -12,6 +12,7 @@ ) from functools import wraps from collections import deque +from random import randint T = TypeVar("T") @@ -116,10 +117,23 @@ class Switch: all other runnable coroutines of the event loop. This is similar to the common ``sleep(0)`` function of regular event loop frameworks. + + If a single argument is given, this specifies how many + turns should be skipped. The default corresponds to `0`. + If two arguments are given, this is interpreted as an + inclusive interval to randomly select the skip count. """ + def __init__(self, skip: int = 0, limit: int = 0, /) -> None: + if limit <= 0: + self._idle_count = skip + else: + self._idle_count = randint(skip, limit) + def __await__(self): yield self + for _ in range(self._idle_count): + yield self class Lock: From 9123c07e1aad7bb3dfde01ccefce1ef28e4c11da Mon Sep 17 00:00:00 2001 From: Max Fischer Date: Thu, 5 Sep 2024 21:50:09 +0200 Subject: [PATCH 07/12] remove simple test loop --- unittests/test_functools.py | 10 +++++----- unittests/test_itertools.py | 6 +++--- unittests/utility.py | 32 +------------------------------- 3 files changed, 9 insertions(+), 39 deletions(-) diff --git a/unittests/test_functools.py b/unittests/test_functools.py index f0efa65..45a26d7 100644 --- a/unittests/test_functools.py +++ b/unittests/test_functools.py @@ -5,7 +5,7 @@ import asyncstdlib as a from asyncstdlib.functools import CachedProperty -from .utility import Lock, Schedule, Switch, asyncify, multi_sync, sync +from .utility import Lock, Schedule, Switch, asyncify, sync @sync @@ -44,7 +44,7 @@ async def bar(self): Foo().bar -@multi_sync +@sync async def test_cache_property_order(): class Value: def __init__(self, value): @@ -66,7 +66,7 @@ async def check_increment(to): assert (await val.cached) == 1337 # last value fetched -@multi_sync +@sync async def test_cache_property_lock_order(): class Value: def __init__(self, value): @@ -87,7 +87,7 @@ async def check_cached(to, expected): assert (await val.cached) == 5 # first value fetched -@multi_sync +@sync async def test_cache_property_lock_deletion(): class Value: def __init__(self, value): @@ -300,7 +300,7 @@ async def pingpong(arg): @pytest.mark.parametrize("size", [16, None]) -@multi_sync +@sync async def test_lru_cache_concurrent(size): current = 0 diff --git a/unittests/test_itertools.py b/unittests/test_itertools.py index b9386ab..8fa8349 100644 --- a/unittests/test_itertools.py +++ b/unittests/test_itertools.py @@ -6,7 +6,7 @@ import asyncstdlib as a -from .utility import sync, asyncify, awaitify, multi_sync, Schedule, Switch, Lock +from .utility import sync, asyncify, awaitify, Schedule, Switch, Lock @sync @@ -314,7 +314,7 @@ async def test_tee(): assert await a.list(iterator) == iterable -@multi_sync +@sync async def test_tee_concurrent_locked(): """Test that properly uses a lock for synchronisation""" items = [1, 2, 3, -5, 12, 78, -1, 111] @@ -344,7 +344,7 @@ async def test_peer(peer_tee): platform.python_implementation() != "CPython", reason="async generators only protect against concurrent access on CPython", ) -@multi_sync +@sync async def test_tee_concurrent_unlocked(): """Test that tee does not prevent concurrency without a lock""" items = list(range(12)) diff --git a/unittests/utility.py b/unittests/utility.py index ff88344..c901a3f 100644 --- a/unittests/utility.py +++ b/unittests/utility.py @@ -64,34 +64,6 @@ async def inside_loop() -> bool: return await signal is signal -def sync(test_case: Callable[..., Coroutine[None, Any, Any]]) -> Callable[..., None]: - """ - Mark an ``async def`` test case to be run synchronously - - This emulates a primitive "event loop" which only responds - to the :py:class:`PingPong` by sending it back. This loop - is appropriate for tests that do not check concurrency. - """ - - @wraps(test_case) - def run_sync(*args: Any, **kwargs: Any) -> None: - coro = test_case(*args, **kwargs) - try: - event = None - while True: - event = coro.send(event) - if not isinstance(event, PingPong): # pragma: no cover - raise RuntimeError( - f"test case {test_case} yielded an unexpected event {event}" - ) - except StopIteration as e: - result = e.args[0] if e.args else None - assert result is None, f"got '{result!r}' expected 'None'" - return result - - return run_sync - - class Schedule: r""" Signal to the event loop to adopt and run new coroutines @@ -159,9 +131,7 @@ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any): self._owned = False -def multi_sync( - test_case: Callable[..., Coroutine[None, Any, Any]], / -) -> Callable[..., None]: +def sync(test_case: Callable[..., Coroutine[None, Any, Any]], /) -> Callable[..., None]: """ Mark an ``async def`` test case to be run synchronously with children From 36958b9240a9d4e899f371ee560140dd181e6857 Mon Sep 17 00:00:00 2001 From: Max Fischer Date: Thu, 5 Sep 2024 21:53:03 +0200 Subject: [PATCH 08/12] remove deprecated loop docs --- docs/source/devel/testloop.rst | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/source/devel/testloop.rst b/docs/source/devel/testloop.rst index 05ae498..3c20a26 100644 --- a/docs/source/devel/testloop.rst +++ b/docs/source/devel/testloop.rst @@ -13,13 +13,11 @@ as well as a number of `async` commands specific to the event loop. Event Loops =========== -There are currently two event loops available for either simplicity or concurrency. -In case of doubt, prefer :py:func:`~.multi_sync`. +The test event loop is available via a decorator that should be directly applied +to an ``async def`` test case. .. autofunction:: sync(test_case: (...) -> (await) None) -> (...) -> None -.. autofunction:: multi_sync(test_case: (...) -> (await) None) -> (...) -> None - Async commands ============== From 0c95e71f3c0acf8defcf121c8f9dba57c01905cd Mon Sep 17 00:00:00 2001 From: Max Fischer Date: Thu, 5 Sep 2024 22:29:36 +0200 Subject: [PATCH 09/12] remove event loop detection --- docs/source/devel/testloop.rst | 2 -- unittests/test_builtins.py | 5 ++--- unittests/utility.py | 28 ++-------------------------- 3 files changed, 4 insertions(+), 31 deletions(-) diff --git a/docs/source/devel/testloop.rst b/docs/source/devel/testloop.rst index 3c20a26..9df88b4 100644 --- a/docs/source/devel/testloop.rst +++ b/docs/source/devel/testloop.rst @@ -21,8 +21,6 @@ to an ``async def`` test case. Async commands ============== -.. autoclass:: PingPong - .. autoclass:: Schedule(*await Any) .. py:class:: Switch(skip: int, /) diff --git a/unittests/test_builtins.py b/unittests/test_builtins.py index c1a64d7..6231b4b 100644 --- a/unittests/test_builtins.py +++ b/unittests/test_builtins.py @@ -4,7 +4,7 @@ import asyncstdlib as a -from .utility import sync, asyncify, awaitify, inside_loop +from .utility import sync, asyncify, awaitify def hide_coroutine(corofunc): @@ -83,8 +83,7 @@ async def __aiter__(self): yield 1 finally: nonlocal closed - if await inside_loop(): - closed = True + closed = True zip_iter = a.zip(asyncify(range(-5, 0)), SomeIterable()) async for va, vb in zip_iter: diff --git a/unittests/utility.py b/unittests/utility.py index c901a3f..479278d 100644 --- a/unittests/utility.py +++ b/unittests/utility.py @@ -1,7 +1,6 @@ from typing import ( Callable, Coroutine, - Generator, Iterable, AsyncIterator, TypeVar, @@ -45,25 +44,6 @@ async def await_wrapper(*args: Any, **kwargs: Any) -> T: return await_wrapper -class PingPong: - """ - Signal to the event loop which gets returned unchanged - - The coroutine yields to the event loop but is resumed - immediately, without running others in the meantime. - This is mainly useful for ensuring the event loop is used. - """ - - def __await__(self) -> "Generator[PingPong, Any, Any]": - return (yield self) - - -async def inside_loop() -> bool: - """Test whether there is an active event loop available""" - signal = PingPong() - return await signal is signal - - class Schedule: r""" Signal to the event loop to adopt and run new coroutines @@ -136,9 +116,7 @@ def sync(test_case: Callable[..., Coroutine[None, Any, Any]], /) -> Callable[... Mark an ``async def`` test case to be run synchronously with children This provides a primitive "event loop" which only responds - to the :py:class:`PingPong`, :py:class:`Schedule`, :py:class:`Switch` - and :py:class:`Lock`. This loop is appropriate for tests that need - to check concurrency. + to :py:class:`Schedule`, :py:class:`Switch` and :py:class:`Lock`. It should be applied as a decorator on an ``async def`` function, which is then turned into a synchronous callable that will run the ``async def`` @@ -159,9 +137,7 @@ def run_sync(*args: Any, **kwargs: Any): result = e.args[0] if e.args else None assert result is None, f"got '{result!r}' expected 'None'" else: - if isinstance(event, PingPong): - run_queue.appendleft((coro, event)) - elif isinstance(event, Schedule): + if isinstance(event, Schedule): run_queue.extend((new_coro, None) for new_coro in event.coros) run_queue.append((coro, event)) elif isinstance(event, Switch): From d803f528b13551c78d4dec5de43c55afee2ab074 Mon Sep 17 00:00:00 2001 From: Max Fischer Date: Sun, 6 Oct 2024 21:17:19 +0200 Subject: [PATCH 10/12] strictly follow AsyncGenerator interface --- asyncstdlib/asynctools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/asyncstdlib/asynctools.py b/asyncstdlib/asynctools.py index b75731b..04c7620 100644 --- a/asyncstdlib/asynctools.py +++ b/asyncstdlib/asynctools.py @@ -35,7 +35,7 @@ class _BorrowedAsyncIterator(AsyncGenerator[T, S]): __slots__ = "__wrapped__", "__anext__", "asend", "athrow", "_wrapper" # Type checker does not understand `__slot__` definitions - __anext__: Callable[[Any], Awaitable[T]] + __anext__: Callable[[Any], Coroutine[Any, Any, T]] asend: Any athrow: Any From dc3ae1ccc40186fc555c80571d5fec0034325618 Mon Sep 17 00:00:00 2001 From: Max Fischer Date: Sun, 6 Oct 2024 21:26:30 +0200 Subject: [PATCH 11/12] make the type checker happy --- asyncstdlib/contextlib.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/asyncstdlib/contextlib.py b/asyncstdlib/contextlib.py index bb2c0b9..5050567 100644 --- a/asyncstdlib/contextlib.py +++ b/asyncstdlib/contextlib.py @@ -328,18 +328,15 @@ def push(self, exit: SE) -> SE: If a context manager must also be entered, use :py:meth:`~.enter_context` instead. """ - try: + aexit: Callable[..., Awaitable[Union[None, bool]]] + if hasattr(exit, "__aexit__"): aexit = exit.__aexit__ # type: ignore - except AttributeError: - try: - aexit = awaitify( - exit.__exit__, # type: ignore - ) - except AttributeError: - assert callable( - exit - ), f"Expected (async) context manager or callable, got {exit}" - aexit = awaitify(exit) + elif hasattr(exit, "__exit__"): + aexit = exit.__aexit__ # type: ignore + elif callable(exit): + aexit = awaitify(exit) # type: ignore + else: + raise TypeError(f"Expected (async) context manager or callable, got {exit}") self._exit_callbacks.append(aexit) # pyright: ignore[reportUnknownArgumentType] return exit From ad71cc9c947779fae76329cff967292c0547d442 Mon Sep 17 00:00:00 2001 From: Max Fischer Date: Sun, 6 Oct 2024 21:37:47 +0200 Subject: [PATCH 12/12] fix __enter__ case --- asyncstdlib/contextlib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/asyncstdlib/contextlib.py b/asyncstdlib/contextlib.py index 5050567..e467c4d 100644 --- a/asyncstdlib/contextlib.py +++ b/asyncstdlib/contextlib.py @@ -332,7 +332,7 @@ def push(self, exit: SE) -> SE: if hasattr(exit, "__aexit__"): aexit = exit.__aexit__ # type: ignore elif hasattr(exit, "__exit__"): - aexit = exit.__aexit__ # type: ignore + aexit = awaitify(exit.__exit__) # type: ignore elif callable(exit): aexit = awaitify(exit) # type: ignore else: