Skip to content

Commit 06e1302

Browse files
authored
Merge pull request #9 from nolar/pytest-asyncio-1.0.0-compat-v2
Use the dynamically chosen event loop of pytest-asyncio>=1.0.0 (2nd attempt)
2 parents 2d1c034 + dd1c1fa commit 06e1302

File tree

10 files changed

+625
-57
lines changed

10 files changed

+625
-57
lines changed

.github/workflows/ci.yaml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,12 @@ jobs:
2424
strategy:
2525
fail-fast: false
2626
matrix:
27-
python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13" ]
28-
name: Python ${{ matrix.python-version }}
27+
python-version: [ "3.9", "3.10", "3.11", "3.12" ]
28+
include:
29+
- python-version: "3.13"
30+
- python-version: "3.13"
31+
install-extras: "pytest-asyncio<1.0.0"
32+
name: Python ${{ matrix.python-version }}${{ matrix.install-extras && ', ' || '' }}${{ matrix.install-extras }}
2933
runs-on: ubuntu-24.04
3034
timeout-minutes: 5
3135
steps:
@@ -36,6 +40,8 @@ jobs:
3640

3741
- run: pip install -r requirements.txt
3842
- run: pip install -e .
43+
- run: pip install "${{ matrix.install-extras }}"
44+
if: ${{ matrix.install-extras }}
3945
- run: pytest --color=yes --cov=looptime --cov-branch
4046

4147
- name: Publish coverage to Coveralls.io

README.md

Lines changed: 127 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@
33
[![CI](https://github.com/nolar/looptime/workflows/Thorough%20tests/badge.svg)](https://github.com/nolar/looptime/actions/workflows/thorough.yaml)
44
[![codecov](https://codecov.io/gh/nolar/looptime/branch/main/graph/badge.svg)](https://codecov.io/gh/nolar/looptime)
55
[![Coverage Status](https://coveralls.io/repos/github/nolar/looptime/badge.svg?branch=main)](https://coveralls.io/github/nolar/looptime?branch=main)
6-
[![Total alerts](https://img.shields.io/lgtm/alerts/g/nolar/looptime.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/nolar/looptime/alerts/)
7-
[![Language grade: Python](https://img.shields.io/lgtm/grade/python/g/nolar/looptime.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/nolar/looptime/context:python)
86
[![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit)
97

108
## What?
@@ -19,10 +17,10 @@ The effects of time removal can be seen from both sides:
1917
This hides the code overhead and network latencies from the time measurements,
2018
making the loop time sharply and predictably advancing in configured steps.
2119

22-
* From the **observer's (i.e. your) point of view,**
20+
* From the **observer's (i.e. your personal) point of view,**
2321
all activities of the event loop, such as sleeps, events/conditions waits,
2422
timeouts, "later" callbacks, happen in near-zero amount of the real time
25-
(above the usual code overhead).
23+
(due to the usual code execution overhead).
2624
This speeds up the execution of tests without breaking the tests' time-based
2725
design, even if they are designed to run in seconds or minutes.
2826

@@ -59,28 +57,28 @@ at the same time:
5957
* One is for the coroutine-under-test which moves between states
6058
in the background.
6159
* Another one is for the test itself, which controls the flow
62-
of that coroutine-under-test: it sets events, injects data, etc.
60+
of that coroutine-under-test: it schedules events, injects data, etc.
6361

6462
In textbook cases with simple coroutines that are more like regular functions,
6563
it is possible to design a test so that it runs straight to the end in one hop
6664
— with all the preconditions set and data prepared in advance in the test setup.
6765

68-
However, in real-world cases, the tests often must verify that
66+
However, in the real-world cases, the tests often must verify that
6967
the coroutine stops at some point, waits for a condition for some limited time,
7068
and then passes or fails.
7169

7270
The problem is often "solved" by mocking the low-level coroutines of sleep/wait
7371
that we expect the coroutine-under-test to call. But this violates the main
74-
principle of good unit-tests: test the promise, not the implementation.
72+
principle of good unit-tests: **test the promise, not the implementation.**
7573
Mocking and checking the low-level coroutines is based on the assumptions
7674
of how the coroutine is implemented internally, which can change over time.
7775
Good tests do not change on refactoring if the protocol remains the same.
7876

7977
Another (straightforward) approach is to not mock the low-level routines, but
8078
to spend the real-world time, just in short bursts as hard-coded in the test.
8179
Not only it makes the whole test-suite slower, it also brings the execution
82-
time close to the values where the code overhead affects the timing
83-
and makes it difficult to assert on the coroutine's pure time.
80+
time close to the values where the code overhead or measurement errors affect
81+
the timing, which makes it difficult to assert on the coroutine's pure time.
8482

8583

8684
## Solution
@@ -232,13 +230,26 @@ async def test_me():
232230
`start` (`float` or `None`, or a no-argument callable that returns the same)
233231
is the initial time of the event loop.
234232

235-
If it is callable, it is invoked once per event loop to get the value:
233+
If it is a callable, it is invoked once per event loop to get the value:
236234
e.g. `start=time.monotonic` to align with the true time,
237235
or `start=lambda: random.random() * 100` to add some unpredictability.
238236

239237
`None` is treated the same as `0.0`.
240238

241-
The default is `0.0`.
239+
The default is `0.0`. For reusable event loops, the default is to keep
240+
the time untouched, which means `0.0` or the explicit value for the first test,
241+
but then an ever-increasing value for the 2nd, 3rd, and further tests.
242+
243+
Note: pytest-asyncio 1.0.0+ introduced event loops with higher scopes,
244+
e.g. class-, module-, packages-, session-scoped event loops used in tests.
245+
Such event loops are reused, so their time continues growing through many tests.
246+
However, if the test is explicitly configured with the start time,
247+
that time is enforced to the event loop when the test function starts —
248+
to satisfy the clearly declared intentions — even if the time moves backwards,
249+
which goes against the nature of the time itself (monotonically growing).
250+
This might lead to surprises in time measurements outside of the test,
251+
e.g. in fixtures: the code durations can become negative, or the events can
252+
happen (falsely) before they are scheduled (loop-clock-wise). Be careful.
242253

243254

244255
### The end of time
@@ -255,6 +266,8 @@ e.g. with `asyncio.sleep(0)`, simple `await` statements, etc.
255266

256267
If set to `None`, there is no end of time, and the event loop runs
257268
as long as needed. Note: `0` means ending the time immediately on start.
269+
Be careful with the explicit ending time in higher-scoped event loops
270+
of pytest-asyncio>=1.0.0, since they time increases through many tests.
258271

259272
If it is a callable, it is called once per event loop to get the value:
260273
e.g. `end=lambda: time.monotonic() + 10`.
@@ -512,6 +525,63 @@ For example, with the resolution `0.001`, the time
512525
everything smaller than `0.001` becomes `0` and probably misbehaves._
513526

514527

528+
### Time magic coverage
529+
530+
The time compaction magic is enabled only for the duration of the test,
531+
i.e. the test function — but not the fixtures.
532+
The fixtures run in the real (wall-clock) time.
533+
534+
The options (including the force starting time) are applied at the test function
535+
starting moment, not when it is setting up the fixtures (even function-scoped).
536+
537+
This is caused by a new concept of multiple co-existing event loops
538+
in pytest-asyncio>=1.0.0:
539+
540+
- It is unclear which options to apply to higher-scoped fixtures
541+
used by many tests, which themselves use higher-scoped event loops —
542+
especially in selective partial runs. Technically, it is the 1st test,
543+
with the options of 2nd and further tests simply ignored.
544+
- It is impossible to guess which event loop will be the running loop
545+
in the test until we reach the test itself, i.e. we do not know this
546+
when setting up the fixtures, even function-scoped fixtures.
547+
- There is no way to cover the fixture teardown (no hook in pytest),
548+
only for the fixture setup and post-teardown cleanup.
549+
550+
As such, this functionality (covering of function-scoped fixtures)
551+
was abandoned — since it was never promised, tested, or documented —
552+
plus an assumption that it was never used by anyone (it should not be).
553+
It was rather a side effect of the previous implemention,
554+
which is not available or possible anymore.
555+
556+
557+
### pytest-asyncio>=1.0.0
558+
559+
As it is said above, pytest-asyncio>=1.0.0 introduced several co-existing
560+
event loops of different scopes. The time compaction in these event loops
561+
is NOT activated. Only the running loop of the test function is activated.
562+
563+
Configuring and activating multiple co-existing event loops brings a few
564+
conceptual challenges, which require a good sample case to look into,
565+
and some time to think.
566+
567+
Would you need time compaction in your fixtures of higher scopes,
568+
do it explicitly:
569+
570+
```python
571+
import asyncio
572+
import pytest
573+
574+
@pytest.fixture
575+
async def fixt():
576+
loop = asyncio.get_running_loop()
577+
loop.setup_looptime(start=123, end=456)
578+
with loop.looptime_enabled():
579+
await do_things()
580+
```
581+
582+
There is #11 to add a feature to do this automatically, but it is not yet done.
583+
584+
515585
## Extras
516586

517587
### Chronometers
@@ -605,6 +675,8 @@ the loop time also includes the time of all fixtures setups.
605675
Do you use a custom event loop? No problem! Create a test-specific descendant
606676
with the provided mixin — and it will work the same as the default event loop.
607677

678+
For `pytest-asyncio<1.0.0`:
679+
608680
```python
609681
import looptime
610682
import pytest
@@ -620,13 +692,38 @@ def event_loop():
620692
return LooptimeCustomEventLoop()
621693
```
622694

695+
For `pytest-asyncio>=1.0.0`:
696+
697+
```python
698+
import asyncio
699+
import looptime
700+
import pytest
701+
from wherever import CustomEventLoop
702+
703+
704+
class LooptimeCustomEventLoop(looptime.LoopTimeEventLoop, CustomEventLoop):
705+
pass
706+
707+
708+
class LooptimeCustomEventLoopPolicy(asyncio.DefaultEventLoopPolicy):
709+
def new_event_loop(self):
710+
return LooptimeCustomEventLoop()
711+
712+
713+
@pytest.fixture(scope='session')
714+
def event_loop_policy():
715+
return LooptimeCustomEventLoopPolicy()
716+
```
717+
623718
Only selector-based event loops are supported: the event loop must rely on
624719
`self._selector.select(timeout)` to sleep for `timeout` true-time seconds.
625720
Everything that inherits from `asyncio.BaseEventLoop` should work.
626721

627722
You can also patch almost any event loop class or event loop object
628723
the same way as `looptime` does that (via some dirty hackery):
629724

725+
For `pytest-asyncio<1.0.0`:
726+
630727
```python
631728
import asyncio
632729
import looptime
@@ -639,6 +736,25 @@ def event_loop():
639736
return looptime.patch_event_loop(loop)
640737
```
641738

739+
For `pytest-asyncio>=1.0.0`:
740+
741+
```python
742+
import asyncio
743+
import looptime
744+
import pytest
745+
746+
747+
class LooptimeEventLoopPolicy(asyncio.DefaultEventLoopPolicy):
748+
def new_event_loop(self):
749+
loop = super().new_event_loop()
750+
return looptime.patch_event_loop(loop)
751+
752+
753+
@pytest.fixture(scope='session')
754+
def event_loop_policy():
755+
return LooptimeEventLoopPolicy()
756+
```
757+
642758
`looptime.make_event_loop_class(cls)` constructs a new class that inherits
643759
from the referenced class and the specialised event loop class mentioned above.
644760
The resulting classes are cached, so it can be safely called multiple times.

looptime/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
from .chronometers import Chronometer
2-
from .loops import IdleTimeoutError, LoopTimeEventLoop, LoopTimeoutError
2+
from .loops import IdleTimeoutError, LoopTimeEventLoop, LoopTimeoutError, TimeWarning
33
from .patchers import make_event_loop_class, new_event_loop, patch_event_loop, reset_caches
44
from .timeproxies import LoopTimeProxy
55

66
__all__ = [
77
'Chronometer',
8+
'TimeWarning',
89
'LoopTimeProxy',
910
'IdleTimeoutError',
1011
'LoopTimeoutError',

looptime/loops.py

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import contextlib
45
import selectors
56
import time
7+
import warnings
68
import weakref
7-
from typing import TYPE_CHECKING, Any, Callable, MutableSet, TypeVar, cast, overload
9+
from typing import TYPE_CHECKING, Any, Callable, Iterator, MutableSet, TypeVar, cast, overload
810

911
_T = TypeVar('_T')
1012

@@ -16,6 +18,11 @@
1618
AnyTask = asyncio.Task
1719

1820

21+
class TimeWarning(UserWarning):
22+
"""Issued when the loop time moves backwards, violating its monotonicity."""
23+
pass
24+
25+
1926
class LoopTimeoutError(asyncio.TimeoutError):
2027
"""A special kind of timeout when the loop's time reaches its end."""
2128
pass
@@ -61,6 +68,7 @@ def setup_looptime(
6168
idle_step: float | None = None,
6269
idle_timeout: float | None = None,
6370
noop_cycles: int = 42,
71+
_enabled: bool | None = None, # None means do nothing
6472
) -> None:
6573
"""
6674
Set all the fake-time fields and patch the i/o selector.
@@ -69,9 +77,33 @@ def setup_looptime(
6977
when the mixin/class is injected into the existing event loop object.
7078
In that case, the object is already initialised except for these fields.
7179
"""
80+
new_time: float | None = start() if callable(start) else start
81+
end_time: float | None = end() if callable(end) else end
82+
old_time: float | None
83+
try:
84+
# NB: using the existing (old) reciprocal!
85+
old_time = self.__int2time(self.__now)
86+
except AttributeError: # initial setup: either reciprocals or __now are absent
87+
old_time = None
88+
new_time = float(new_time) if new_time is not None else None
89+
90+
# If it is the 2nd or later setup, double-check on time monotonicity.
91+
# In some configurations, this waring might raise an error and fail the test.
92+
# In that case, the time must not be changed for the next test.
93+
if old_time is not None and new_time is not None and new_time < old_time:
94+
warnings.warn(
95+
f"The time of the event loop moves backwards from {old_time} to {new_time},"
96+
" thus breaking the monotonicity of time. This is dangerous!"
97+
" Perhaps, caused by reusing a higher-scope event loop in tests."
98+
" Revise the scopes of fixtures & event loops."
99+
" Remove the start=… kwarg and rely on arbitrary time values."
100+
" Migrate from `loop.time()` to the `looptime` numeric fixture.",
101+
TimeWarning,
102+
)
103+
72104
self.__resolution_reciprocal: int = round(1/resolution)
73-
self.__now: int = self.__time2int(start() if callable(start) else start) or 0
74-
self.__end: int | None = self.__time2int(end() if callable(end) else end)
105+
self.__now: int = self.__time2int(new_time or old_time) or 0
106+
self.__end: int | None = self.__time2int(end_time)
75107

76108
self.__idle_timeout: int | None = self.__time2int(idle_timeout)
77109
self.__idle_step: int | None = self.__time2int(idle_step)
@@ -85,6 +117,13 @@ def setup_looptime(
85117
self.__sync_clock: Callable[[], float] = time.perf_counter
86118
self.__sync_ts: float | None = None # system/true-time clock timestamp
87119

120+
try:
121+
self.__enabled # type: ignore
122+
except AttributeError:
123+
self.__enabled = _enabled if _enabled is not None else True # old behaviour
124+
else:
125+
self.__enabled = _enabled if _enabled is not None else self.__enabled
126+
88127
# TODO: why do we patch the selector as an object while the event loop as a class?
89128
# this should be the same patching method for both.
90129
try:
@@ -93,6 +132,24 @@ def setup_looptime(
93132
self.__original_select = self._selector.select
94133
self._selector.select = self.__replaced_select # type: ignore
95134

135+
@property
136+
def looptime_on(self) -> bool:
137+
return bool(self.__enabled)
138+
139+
@contextlib.contextmanager
140+
def looptime_enabled(self) -> Iterator[None]:
141+
"""
142+
Temporarily enable the time compaction, restore the normal mode on exit.
143+
"""
144+
if self.__enabled:
145+
raise RuntimeError('Looptime mode is already enabled. Entered twice? Avoid this!')
146+
old_enabled = self.__enabled
147+
self.__enabled = True
148+
try:
149+
yield
150+
finally:
151+
self.__enabled = old_enabled
152+
96153
def time(self) -> float:
97154
return self.__int2time(self.__now)
98155

@@ -111,6 +168,16 @@ def __replaced_select(self, timeout: float | None) -> list[tuple[Any, Any]]:
111168
if ready:
112169
pass
113170

171+
# If nothing to do right now, and the time is not compacted, truly sleep as requested.
172+
# Move the fake time by the exact real time spent in this wait (±discrepancies).
173+
elif not self.__enabled:
174+
t0 = time.monotonic()
175+
ready = self.__original_select(timeout=timeout)
176+
t1 = time.monotonic()
177+
178+
# If timeout=None, it never exists until ready. This timeout check is for typing only.
179+
self.__now += self.__time2int(t1 - t0 if ready or timeout is None else timeout)
180+
114181
# Regardless of the timeout, if there are executors sync futures, we move the time in steps.
115182
# The timeout (if present) can limit the size of the step, but not the logic of stepping.
116183
# Generally, external things (threads) take some time (e.g. for thread spawning).

0 commit comments

Comments
 (0)