Skip to content

Commit b94a9c5

Browse files
committed
Add the loop time enabler for explicit usage (both as a context manager and decorator)
Signed-off-by: Sergey Vasilyev <nolar@nolar.info>
1 parent 513d4fa commit b94a9c5

3 files changed

Lines changed: 181 additions & 0 deletions

File tree

looptime/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from .chronometers import Chronometer
2+
from .enabler import enabled
23
from .loops import IdleTimeoutError, LoopTimeEventLoop, LoopTimeoutError, TimeWarning
34
from .patchers import make_event_loop_class, new_event_loop, patch_event_loop, reset_caches
45
from .timeproxies import LoopTimeProxy
@@ -14,4 +15,5 @@
1415
'new_event_loop',
1516
'patch_event_loop',
1617
'make_event_loop_class',
18+
'enabled',
1719
]

looptime/enabler.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import asyncio
2+
import functools
3+
import inspect
4+
import warnings
5+
from typing import Callable, ContextManager, ParamSpec, TypeVar
6+
7+
from looptime import loops
8+
9+
P = ParamSpec('P')
10+
R = TypeVar('R')
11+
12+
13+
class enabled(ContextManager[None]):
14+
"""
15+
Enable the looptime time compaction temporarily.
16+
17+
If used as a context manager, enables the time compaction for the wrapped
18+
code block only::
19+
20+
import asyncio
21+
import looptime
22+
23+
async def main() -> None:
24+
with looptime.enabled(strict=True):
25+
await asyncio.sleep(10)
26+
27+
if __name__ == '__main__':
28+
asuncio.run(main())
29+
30+
If used as a function/fixture decorator, enables the time compaction
31+
for the duration of the function/fixture::
32+
33+
import asyncio
34+
import looptime
35+
36+
@looptime.enabled(strict=True)
37+
async def main() -> None:
38+
await asyncio.sleep(10)
39+
40+
if __name__ == '__main__':
41+
asuncio.run(main())
42+
43+
In both cases, the event loop must be pre-patched (usually at creation).
44+
In strict mode, if the event loop is not patched, the call will fail.
45+
In non-strict mode (the default), it will issue a warning and continue
46+
with the real time flow (i.e. with no time compaction).
47+
48+
Use it, for example, for fixtures or finalizers of fixtures where the fast
49+
time flow is required despite fixtures are normally excluded from the time
50+
compaction magic (because it is impossible or difficult to infer which
51+
event loop is being used in the multi-scoped setup of pytest-asyncio),
52+
and because of the structure of pytest hooks for fixture finalizing
53+
(no finalizer hook, only the post-finalizer hook, when it is too late).
54+
55+
Beware of a caveat: if used as a decorator on a yield-based fixture,
56+
it will enable the looptime magic for the whole duration of the test,
57+
including all its fixtures (even undecorated ones), until the decorated
58+
fixture reaches its finalizer. This might have unexpected side effects.
59+
"""
60+
strict: bool
61+
_loop: asyncio.AbstractEventLoop | None
62+
_mgr: ContextManager[None] | None
63+
64+
def __init__(self, *, strict: bool = False, loop: asyncio.AbstractEventLoop | None = None) -> None:
65+
super().__init__()
66+
self.strict = strict
67+
self._loop = loop
68+
self._mgr = None
69+
70+
def __enter__(self) -> None:
71+
msg = "The running loop is not a looptime-patched loop, cannot enable it."
72+
loop = self._loop if self._loop is not None else asyncio.get_running_loop()
73+
if isinstance(loop, loops.LoopTimeEventLoop):
74+
self._mgr = loop.looptime_enabled()
75+
self._mgr.__enter__()
76+
elif self.strict:
77+
raise RuntimeError(msg)
78+
else:
79+
warnings.warn(msg, UserWarning)
80+
81+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
82+
if self._mgr is not None:
83+
self._mgr.__exit__(exc_type, exc_val, exc_tb)
84+
self._mgr = None
85+
86+
def __call__(self, fn: Callable[P, R]) -> Callable[P, R]:
87+
if inspect.iscoroutinefunction(fn):
88+
@functools.wraps(fn)
89+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
90+
nonlocal self
91+
with self:
92+
return await fn(*args, **kwargs)
93+
else:
94+
@functools.wraps(fn)
95+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
96+
nonlocal self
97+
with self:
98+
return fn(*args, **kwargs)
99+
100+
return wrapper

tests/test_enabler.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import asyncio
2+
import sys
3+
4+
import pytest
5+
6+
import looptime
7+
8+
9+
@pytest.mark.asyncio
10+
async def test_enabler_as_context_manager():
11+
loop = asyncio.get_running_loop()
12+
enabled = isinstance(loop, looptime.LoopTimeEventLoop) and loop.looptime_on
13+
assert not enabled
14+
15+
with looptime.enabled():
16+
enabled = isinstance(loop, looptime.LoopTimeEventLoop) and loop.looptime_on
17+
assert enabled
18+
19+
20+
@pytest.mark.asyncio
21+
async def test_enabler_as_decorator_for_sync_functions():
22+
@looptime.enabled()
23+
def fn(a: int) -> tuple[int, bool]:
24+
loop = asyncio.get_running_loop()
25+
enabled = isinstance(loop, looptime.LoopTimeEventLoop) and loop.looptime_on
26+
return a + 10, enabled
27+
28+
loop = asyncio.get_running_loop()
29+
enabled = isinstance(loop, looptime.LoopTimeEventLoop) and loop.looptime_on
30+
assert not enabled
31+
32+
result, enabled = fn(123)
33+
assert result == 133
34+
assert enabled
35+
36+
37+
@pytest.mark.asyncio
38+
async def test_enabler_as_decorator_for_async_functions():
39+
@looptime.enabled()
40+
async def fn(a: int) -> tuple[int, bool]:
41+
loop = asyncio.get_running_loop()
42+
enabled = isinstance(loop, looptime.LoopTimeEventLoop) and loop.looptime_on
43+
return a + 10, enabled
44+
45+
loop = asyncio.get_running_loop()
46+
enabled = isinstance(loop, looptime.LoopTimeEventLoop) and loop.looptime_on
47+
assert not enabled
48+
49+
result, enabled = await fn(123)
50+
assert result == 133
51+
assert enabled
52+
53+
54+
@pytest.mark.skipif(sys.version_info < (3, 11)) # for Runners
55+
def test_enabler_with_explicit_loop():
56+
with asyncio.Runner() as runner:
57+
runner_loop = runner.get_loop()
58+
looptime.patch_event_loop(runner_loop, _enabled=False)
59+
with looptime.enabled(loop=runner_loop):
60+
enabled = isinstance(runner_loop, looptime.LoopTimeEventLoop) and runner_loop.looptime_on
61+
assert enabled
62+
63+
64+
@pytest.mark.skipif(sys.version_info < (3, 11)) # for Runners
65+
def test_strict_mode_error():
66+
with asyncio.Runner() as runner:
67+
runner_loop = runner.get_loop() # unpatched!
68+
with pytest.raises(RuntimeError, match="loop is not a looptime-patched loop"):
69+
with looptime.enabled(loop=runner_loop, strict=True):
70+
pass
71+
72+
73+
@pytest.mark.skipif(sys.version_info < (3, 11)) # for Runners
74+
def test_nonstrict_mode_warning():
75+
with asyncio.Runner() as runner:
76+
runner_loop = runner.get_loop() # unpatched!
77+
with pytest.warns(UserWarning, match="loop is not a looptime-patched loop"):
78+
with looptime.enabled(loop=runner_loop, strict=False):
79+
pass

0 commit comments

Comments
 (0)