diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7688b1e..9e44b35 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -18,7 +18,7 @@ jobs: python-version: "3.10" - run: pip install -r requirements.txt - run: pre-commit run --all-files - - run: mypy pytest_instant --strict + - run: mypy looptime --strict unit-tests: strategy: @@ -36,7 +36,7 @@ jobs: - run: pip install -r requirements.txt - run: pip install -e . - - run: pytest --color=yes --cov=pytest_instant --cov-branch + - run: pytest --color=yes --cov=looptime --cov-branch - name: Publish coverage to Coveralls.io if: success() diff --git a/.github/workflows/thorough.yaml b/.github/workflows/thorough.yaml index ee7e7fd..4970cf5 100644 --- a/.github/workflows/thorough.yaml +++ b/.github/workflows/thorough.yaml @@ -20,7 +20,7 @@ jobs: python-version: "3.10" - run: pip install -r requirements.txt - run: pre-commit run --all-files - - run: mypy pytest_instant --strict + - run: mypy looptime --strict unit-tests: strategy: @@ -38,7 +38,7 @@ jobs: - run: pip install -r requirements.txt - run: pip install -e . - - run: pytest --color=yes --cov=pytest_instant --cov-branch + - run: pytest --color=yes --cov=looptime --cov-branch - name: Publish coverage to Coveralls.io if: success() diff --git a/README.md b/README.md index 153f37e..8227840 100644 --- a/README.md +++ b/README.md @@ -6,3 +6,647 @@ [![Total alerts](https://img.shields.io/lgtm/alerts/g/nolar/looptime.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/nolar/looptime/alerts/) [![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) [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) + +## What? + +Fake the flow of time in asyncio event loops. +The effects of time removal can be seen from both sides: + +* From the **event loop's (i.e. your tests') point of view,** + all external activities, such as synchronous executor calls (thread pools) + and i/o with sockets, servers, files, happen in zero amount of the loop time — + even if it takes some real time. + This hides the code overhead and network latencies from the time measurements, + making the loop time sharply and predictably advancing in configured steps. + +* From the **observer's (i.e. your) point of view,** + all activities of the event loop, such as sleeps, events/conditions waits, + timeouts, "later" callbacks, happen in near-zero amount of the real time + (above the usual code overhead). + This speeds up the tests execution without breaking the tests' time-based + design, even if they are designed to run in seconds or minutes. + +For the latter case, there are a few exceptions when the event loop's activities +are synced with the true-time external activities, such as thread pools or i/o, +so that they spend the real time above the usual code overhead (if configured). + +The library was originally developed for [Kopf](https://github.com/nolar/kopf), +a framework for [Kubernetes Operators in Python](https://github.com/nolar/kopf), +which actively uses asyncio tests in pytest (≈7000 unit-tests in ≈2 minutes). +You can see how this library changes and simplifies the tests in +[Kopf's PR #881](https://github.com/nolar/kopf/pull/881). + + +## Why? + +Without `looptime`, the event loops use `time.monotonic()` for the time, +which also captures the code overhead and the network latencies, adding little +random fluctuations to the time measurements (approx. 0.01-0.001 seconds). + +Without `looptime`, the event loops spend the real wall-clock time +when there is no i/o happening but some callbacks are scheduled for later. +In controlled environments like unit tests and fixtures, this time is wasted. + +Also, because I can! (It was a little over-engineering exercise for fun.) + + +## Problem + +It is difficult to test complex asynchronous coroutines with the established +unit-testing practices since there are typically two execution flows happening +at the same time: + +* One is for the coroutine-under-test which moves between states + in the background. +* Another one is for the test itself, which controls the flow + of that coroutine-under-test: it sets events, injects data, etc. + +In textbook cases with simple coroutines that are more like regular functions, +it is possible to design a test so that it runs straight to the end in one hop +— with all the preconditions set and data prepared in advance in the test setup. + +However, in the real-world cases, the tests often must verify that +the corotuine stops at some point, waits for a condition for some limited time, +and then passes or fails. + +The problem is often "solved" by mocking the low-level coroutines of sleep/wait +that we expect the coroutine-under-test to call. But this violates the main +principle of good unit-tests: test the promise, not the implementation. +Mocking and checking the low-level coroutines is based on the assumptions +of how the coroutine is implemented internally, which can change over time. +Good tests do not change on refactoring if the protocol remains the same. + +Another (straightforward) approach is to not mock the low-level routines, but +to spend the real-world time, just in short bursts as hard-cded in the test. +Not only it makes the whole test-suite slower, it also brings the exection +time close to the values where the code overhead affects the timing +and makes it difficult to assert on the coroutine's pure time. + + +## Solution + +Similar to the mentioned approaches, to address this issue, `looptime` +takes care of mocking the event loop and removes this hassle from the tests. + +However, unlike the tests, `looptime` does not mock the typically used +low-level coroutines (e.g. sleep), primitives (e.g. events/conditions), +or library calls (e.g. requests getting/posting, sockets reading/writing, etc). + +`looptime` goes deeper and mocks the very foundation of it all — the time itself. +Then, it controllably moves the time forward in sharp steps when the event loop +requests the actual true-time sleep from the underlying selectors (i/o sockets). + + +## Examples + +Here, we assume that the async tests are supported. For example, +use [`pytest-asyncio`](https://github.com/pytest-dev/pytest-asyncio): + +```bash +pip install pytest-asyncio +pip install looptime +``` + +Nothing is needed to make async tests to run with the fake time, it just works: + +```python +import asyncio +import pytest + + +@pytest.mark.asyncio +async def test_me(): + await asyncio.sleep(100) + assert asyncio.get_running_loop().time() == 100 +``` + +```bash +pytest --looptime +``` + +The test will be executed in approximately **0.01 seconds**, +while the event loop believes it is 100 seconds old. + +If the command line or ini-file options for all tests is not desirable, +individual tests can be marked for fast time forwarding explicitly: + +```python +import asyncio +import pytest + + +@pytest.mark.asyncio +@pytest.mark.looptime +async def test_me(): + await asyncio.sleep(100) + assert asyncio.get_running_loop().time() == 100 +``` + +```bash +pytest +``` + +Under the hood, the library solves some nuanced situations with time in tests. +See "Nuances" below for more complicated (and nuanced) examples. + + +## Markers + +`@pytest.mark.looptime` configures the test's options if and when it is +executed with the timeline replaced to fast-forwarding time. +In normal mode with no configs/CLI options specified, +it marks the test to be executed with the time replaced. + +`@pytest.mark.looptime(False)` (with the positional argument) +excludes the test from the time fast-forwarding under any circumstances. +The test will be executed with the loop time aligned with the real-world time. +Use it only for the tests that are designed to be true-time-based. + +Note that markers can be applied not only to individual tests, +but also to whole test suites (classes, modules, packages): + +```python +import asyncio +import pytest + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.looptime(end=60), +] + + +async def test_me(): + await asyncio.sleep(100) +``` + +The markers can also be artificially injected by plugins/hooks if needed: + +```python +import asyncio +import pytest + +@pytest.hookimpl(hookwrapper=True) +def pytest_pycollect_makeitem(collector, name, obj): + if collector.funcnamefilter(name) and asyncio.iscoroutinefunction(obj): + pytest.mark.asyncio(obj) + pytest.mark.looptime(end=60)(obj) + yield +``` + +All in all, the `looptime` plugin uses the most specific (the "closest") value +for each setting separately (i.e. not the closest marker as a whole). + + +## Options + +`--looptime` enables time fast-forwarding for all tests that are not explicitly +marked as using the fake loop time —including those not marked at all— +as if all tests were implicitly marked. + +`--no-looptime` runs all tests —both marked and unmarked— with real time. +This flag effectively disables the plugin. + + +## Settings + +The marker accepts several settings for the test. The closest to the test +function applies. This lets you to define the test-suite defaults +and override them on the directory, module, class, function, or test level: + +```python +import asyncio +import pytest + +pytestmark = pytest.mark.looptime(end=10, idle_timeout=1) + +@pytest.mark.asyncio +@pytest.mark.looptime(end=101) +async def test_me(): + await asyncio.sleep(100) + assert asyncio.get_running_loop().time() == 100 +``` + + +### The time zero + +`start` (`float` or `None`, or a no-argument callable that returns the same) +is the initial time of the event loop. + +If it is a callable, it is invoked once per event loop to get the value: +e.g. `start=time.monotonic` to align with the true time, +or `start=lambda: random.random() * 100` to add some unpredictability. + +`None` is treated the same as `0.0`. + +The default is `0.0`. + + +### The end of time + +`end` (`float` or `None`, or a no-argument callable that returns the same) +is the final time in the event loop (the internal fake time). +If it is reached, all tasks get terminated and the test is supposed to fail. +The injected exception is `LoopTimeoutError`, +a subclass of `asyncio.TimeoutError`. + +All test-/fixture-finalizing routines will have their fair chance to execute +as long as they do not move the loop time forward, i.e. they take zero time: +e.g. with `asyncio.sleep(0)`, simple `await` statements, etc. + +If set to `None`, there is no end of time, and the event loop runs +as long as needed. Note: `0` means ending the time immediately on start. + +If it is a callable, it is called once per event loop to get the value: +e.g. `end=lambda: time.monotonic() + 10`. + +The end of time is not the same as timeouts — see the nuances below +on differences with `async-timeout`. + + +## Nuances + +### Preliminary execution + +Consider this test: + +```python +import asyncio +import async_timeout +import pytest + + +@pytest.mark.asyncio +@pytest.mark.looptime +async def test_me(): + async with async_timeout.timeout(9): + await asyncio.sleep(1) +``` + +Normally, it should not fail. However, with fake time (without workarounds) +the following scenario is possible: + +* `async_timeout` library sets its delayed timer at 9 seconds since now. +* the event loop notices that there is and and only one timer at T0+9s. +* the event loop fast-forwards time to be `9`. +* since there are no other handles/timers, that timer is executed. +* `async_timeout` fails the test with `asyncio.TimeoutError` +* The `sleep()` never gets any chance to be scheduled or executed. + +To solve this, `looptime` performs several dummy zero-time no-op cycles +before actually moving the time forward. This gives other coroutines, +tasks, and handles their fair chance to be entered, spawned, scheduled. +This is why the example works as intended. + +The `noop_cycles` (`int`) setting is how many cycles the event loop makes. +The default is `42`. Why 42? Well, … + + +### Slow executors + +Consider this test: + +```python +import asyncio +import async_timeout +import contextlib +import pytest +import threading + + +def sync_fn(event: threading.Event): + event.set() + + +@pytest.mark.asyncio +@pytest.mark.looptime +async def test_me(event_loop): + sync_event = threading.Event() + with contextlib.suppress(asyncio.TimeoutError): + async with async_timeout.timeout(9): + await event_loop.run_in_executor(None, sync_fn, sync_event) + assert sync_event.is_set() +``` + +With the true time, this test will finish in a fraction of a second. +However, with the fake time (with no workarounds), the following happens: + +* A new synchronous event is created, it is unset by default. +* A synchronous task is submitted to a thread pool executor. +* The thread pool starts spawning a new thread and passing the task there. +* An asynchronous awaitable (future) is returned, which is chained + with its synchronous counterpart. +* `looptime` performs its no-op cycles, letting all coroutines to start, + but it does this in near-zero true-time. +* The event loop forwards its time to 9 seconds and raises a timeout error. +* The test suppresses the timeout, checks the assertion, and fails: + the sync event is still unset. +* A fraction of a second (e.g. `0.001` second) later, the thread starts, + calls the function, and sets the sync event, but it is too late. + +Compared to the fake fast-forwarding time, even such fast things as threads +are too slow to start. Unfortunately, `looptime` and the event loop can +neither control what is happening outside of the event loop, nor predict +how long it will take. + +To work around this, `looptime` remembers all calls to executors and then +keeps track of the futures they returned. Instead of fast-forwarding the time +by 9 seconds all at once, `looptime` fast-forwards the loop's fake time +in small steps and also does the true-time sleep for that step. +So, the fake time and real time move along while waiting for executors. + +Luckily for this case, in 1 or 2 such steps, the executor's thread will +do its job, the event will be set, so as the synchronous & asynchronous +futures of the executor. The latter one (the async future) will also +let the `await` to move on. + +The `idle_step` (`float` or `None`) setting is the duration of a single +time step when fast-forwarding the time if there are executors used — +i.e. if there are some synchronous tasks running in the thread pools. + +Note that the steps are both true-time and fake-time: they spend the same +amount of the observer's true time as they increment the loop's fake time. + +A negative side effect: the thread spawning can be potentially much faster, +e.g. finish in in 0.001 second; but it will be rounded to be the round number +of steps with no fractions: e.g. 0.01 or 0.02 seconds in this example. + +A trade-off: the smaller step will get the results faster, +but will spend more CPU power on resultless cycles. + + +### I/O idle + +Consider this test: + +```python +import aiohttp +import pytest + + +@pytest.mark.asyncio +@pytest.mark.looptime +async def test_me(): + async with aiohttp.ClientSession(timeout=None) as session: + await session.get('http://some-unresponsive-web-site.com') +``` + +How long should it take if there are no implicit timeouts deep in the code? +With no workarounds, the test will hang forever waiting for the i/o to happen. +This mostly happens when the only thing left in the event loop is the i/o, +all internal scheduled callbacks are gone. + +`looptime` can artifically limit the lifetime of the event loop. +This can be done as a default setting for the whole test suite, for example. + +The `idle_timeout` (`float` or `None`) setting is the true-time limit +of the i/o wait in the absence of scheduled handles/timers/timeouts. +(This i/o includes the dummy i/o used by `loop.call_soon_threadsafe()`.) +`None` means there is no timeout waiting for the i/o, i.e. it waits forever. +The default is `1.0` seconds. + +If nothing happens within this time, the event loop assumes that nothing +will happen ever, so it is a good idea to cease its existence: it injects +`IdleTimeoutError` (a subclass of `asyncio.TimeoutError`) into all tasks. + +This is similar to how the end-of-time behaves, except that it is measured +in the true-time timeline, while the end-of-time is the fake-time timeline. +Besides, once an i/o happens, the idle timeout is reset, while the end-of-time +still can be reached. + +The `idle_step` (`float` or `None`) setting synchronises the flow +of the fake-time with the flow of the true-time while waiting for the i/o +or synchronous futures, i.e. when nothing happens in the event loop itself. +It sets the single step increment of both timelines. + +If the step is not set or set to `None`, the loop time does not move regardless +of how long the i/o or synchronous futures take in the true time +(with or without the timeout). + +If the `idle_step` is set, but the `idle_timeout` is `None`, +then the fake time flows naturally in sync with the true time infinitely. + +The default is `None`. + + +### Timeouts vs. the end-of-time + +The end of time might look like a global timeout, but it is not the same, +and it is better to use other methods for restricting the execution time: +e.g. [`async-timeout`](https://github.com/aio-libs/async-timeout) +or native `asyncio.wait_for(…, timeout=…)`. + +First, the mentioned approaches can be applied to arbitrary code blocks, +even multiple times independently, +while `looptime(end=N)` applies to the lifecycle of the whole event loop, +which is usually the duration of the whole test and monotonically increases. + +Second, `looptime(end=N)` syncs the loop time with the real time for N seconds, +i.e. it does not instantly fast-forwards the loop time when the loops +attempts to make an "infinite sleep" (technically, `selector.select(None)`). +`async_timeout.timeout()` and `asyncio.wait_for()` set a delayed callback, +so the time fast-forwards to it on the first possible occasion. + +Third, once the end-of-time is reached in the event loop, all further attempts +to run async coroutines will fail (except those taking zero loop time). +If the async timeout is reached, further code can proceed normally. + +```python +import asyncio +import pytest + +@pytest.mark.asyncio +@pytest.mark.looptime(end=10) +async def test_the_end_of_time(chronometer, looptime): + with chronometer: + with pytest.raises(asyncio.TimeoutError): + await asyncio.Event().wait() + assert looptime == 10 + assert chronometer >= 10 + +@pytest.mark.asyncio +@pytest.mark.looptime +async def test_async_timeout(chronometer, looptime): + with chronometer: + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(asyncio.Event().wait(), timeout=10) + assert looptime == 10 + assert chronometer < 0.1 +``` + + +### Time resolution + +Python (so as many other languages) has issues with calculating the floats: + +``` +>>> 0.2-0.05 +0.15000000000000002 +>>> 0.2-0.19 +0.010000000000000009 +>>> 0.2+0.21 +0.41000000000000003 +>>> 100_000 * 0.000_001 +0.09999999999999999 +``` + +This can break the assertions on the time and durations. To work around +the issue, `looptime` internally performs all the time math in integers. +The time arguments are converted to the internal integer form +and back to the floating-point form when needed. + +The `resolution` (`float`) setting is the minimum supported time step. +All time steps smaller than that are rounded to the nearest value. + +The default is 1 microsecond, i.e. `0.000001` (`1e-6`), which is good enough +for typical unit-tests while keeps the integers smaller than 32 bits +(1 second => 20 bits; 32 bits => 4294 seconds ≈1h11m). + +Normally, you should not worry about it or configure it. + +_A side-note: in fact, the reciprocal (1/x) of the resolution is used. +For example, with the resolution `0.001`, the time +`1.0` (float) becomes `1000` (int), +`0.1` (float) becomes `100` (int), +`0.01` (float) becomes `10` (int), +`0.001` (float) becomes `1` (int); +everything smaller than `0.001` becomes `0` and probably misbehaves._ + + +## Extras + +### Chronometers + +For convenience, the library also provides a class and a fixture +to measure the duration of arbitrary code blocks in real-world time: + +* `looptime.Chronometer` (a context manager class). +* `chronometer` (a pytest fixture). + +It can be used as a sync or async context manager: + +```python +import asyncio +import pytest + +@pytest.mark.asyncio +@pytest.mark.looptime +async def test_me(chronometer): + with chronometer: + await asyncio.sleep(1) + await asyncio.sleep(1) + assert chronometer.seconds < 0.01 # random code overhead +``` + +Usually, the loop-time duration is not needed or can be retrieved via +`asyncio.get_running_loop().time()`. If needed, it can be measured using +the provided context manager class with the event loop's clock: + +```python +import asyncio +import looptime +import pytest + +@pytest.mark.asyncio +@pytest.mark.looptime(start=100) +async def test_me(chronometer, event_loop): + with chronometer, looptime.Chronometer(event_loop.time) as loopometer: + await asyncio.sleep(1) + await asyncio.sleep(1) + assert chronometer.seconds < 0.01 # random code overhead + assert loopometer.seconds == 2 # precise timing, no code overhead + assert event_loop.time() == 102 +``` + + +### Loop time assertions + +The `looptime` **fixture** is a syntax sugar for easy loop time assertions:: + +```python +import asyncio +import pytest + +@pytest.mark.asyncio +@pytest.mark.looptime(start=100) +async def test_me(looptime): + await asyncio.sleep(1.23) + assert looptime == 101.23 +``` + +Technically, it is a proxy object to `asyncio.get_running_loop().time()`. +The proxy object supports the direct comparison with numbers (integers/floats), +so as some basic arithmetics (adding, substracting, multiplication, etc). +However, it adjusts to the time precision of 1 nanosecond (1e-9): every digit +beyond that precision is ignored — so you can be not afraid of +`123.456/1.2` suddenly becoming `102.88000000000001` and not equal to `102.88` +(as long as the time proxy object is used and not converted to a native float). + +The proxy object can be used to create a new proxy which is bound to a specific +event loop (it works for loops both with fake- and real-world time):: + +```python +import asyncio +from looptime import patch_event_loop + +def test_me(looptime): + new_loop = patch_event_loop(asyncio.new_event_loop(), start=100) + new_loop.run_until_complete(asyncio.sleep(1.23)) + assert looptime @ new_loop == 101.23 +``` + +Mind that it is not the same as `Chronographer` for the whole test. +The time proxy reflects the time of the loop, not the duration of the test: +the loop time can start at a non-zero point; even if it starts at zero, +the loop time also includes the time of all fixtures setups. + + +### Custom event loops + +Do you use a custom event loop? No problem! Create a test-specific descendant +with the provided mixin — and it will work the same as the default event loop. + +```python +import looptime +import pytest +from wherever import CustomEventLoop + + +class LooptimeCustomEventLoop(looptime.LoopTimeEventLoop, CustomEventLoop): + pass + + +@pytest.fixture +def event_loop(): + return LooptimeCustomEventLoop() +``` + +Only selector-based event loops are supported: the event loop must rely on +`self._selector.select(timeout)` to sleep for `timeout` true-time seconds. +Everything that inherits from `asyncio.BaseEventLoop` should work. + +You can also patch almost any event loop class or event loop object +the same way as `looptime` does that (via some dirty hackery): + +```python +import asyncio +import looptime +import pytest + + +@pytest.fixture +def event_loop(): + loop = asyncio.new_event_loop() + return looptime.patch_event_loop(loop) +``` + +`looptime.make_event_loop_class(cls)` constructs a new class that inherits +from the referenced class and the specialised event loop class mentioned above. +The resulting classes are cached, so it can be safely called multiple times. + +`looptime.patch_event_loop()` replaces the event loop's class with the newly +constructed one. For those who care, it is an equivalent of the following hack +(some restrictions apply to the derived class): + +```python +loop.__class__ = looptime.make_event_loop_class(loop.__class__) +``` diff --git a/looptime/__init__.py b/looptime/__init__.py new file mode 100644 index 0000000..0686424 --- /dev/null +++ b/looptime/__init__.py @@ -0,0 +1,16 @@ +from .chronometers import Chronometer +from .loops import IdleTimeoutError, LoopTimeEventLoop, LoopTimeoutError +from .patchers import make_event_loop_class, new_event_loop, patch_event_loop, reset_caches +from .timeproxies import LoopTimeProxy + +__all__ = [ + 'Chronometer', + 'LoopTimeProxy', + 'IdleTimeoutError', + 'LoopTimeoutError', + 'LoopTimeEventLoop', + 'reset_caches', + 'new_event_loop', + 'patch_event_loop', + 'make_event_loop_class', +] diff --git a/looptime/chronometers.py b/looptime/chronometers.py new file mode 100644 index 0000000..126bdd0 --- /dev/null +++ b/looptime/chronometers.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import time +from typing import Any, Callable, TypeVar + +from looptime import math + +_SelfT = TypeVar('_SelfT', bound="Chronometer") + + +class Chronometer(math.Numeric): + """ + A helper context manager to measure the time of the code-blocks. + + Usage: + + with Chronometer() as chronometer: + do_something() + print(f"Executing for {chronometer.seconds}s already.") + do_something_else() + + print(f"Executed in {chronometer.seconds}s.") + assert chronometer.seconds < 5.0 + """ + + def __init__(self, clock: Callable[[], float] = time.perf_counter) -> None: + super().__init__() + self._clock = clock + self._ts: float | None = None + self._te: float | None = None + + @property + def _value(self) -> float: + return float(self.seconds or 0) + + @property + def seconds(self) -> float | None: + if self._ts is None: + return None + elif self._te is None: + return self._clock() - self._ts + else: + return self._te - self._ts + + def __repr__(self) -> str: + status = 'new' if self._ts is None else 'running' if self._te is None else 'finished' + return f'' + + def __enter__(self: _SelfT) -> _SelfT: + self._ts = self._clock() + self._te = None + return self + + def __exit__(self, *args: Any) -> None: + self._te = self._clock() + + async def __aenter__(self: _SelfT) -> _SelfT: + return self.__enter__() + + async def __aexit__(self, *args: Any) -> None: + return self.__exit__(*args) + + +try: + import pytest +except ImportError: + pass +else: + @pytest.fixture() + def chronometer() -> Chronometer: + return Chronometer() diff --git a/looptime/loops.py b/looptime/loops.py new file mode 100644 index 0000000..21d763d --- /dev/null +++ b/looptime/loops.py @@ -0,0 +1,320 @@ +from __future__ import annotations + +import asyncio +import selectors +import time +import weakref +from typing import TYPE_CHECKING, Any, Callable, MutableSet, TypeVar, cast, overload + +_T = TypeVar('_T') + +if TYPE_CHECKING: + AnyFuture = asyncio.Future[Any] + AnyTask = asyncio.Task[Any] +else: + AnyFuture = asyncio.Future + AnyTask = asyncio.Task + + +class LoopTimeoutError(asyncio.TimeoutError): + """A special kind of timeout when the loop's time reaches its end.""" + pass + + +class IdleTimeoutError(asyncio.TimeoutError): + """A special kind of timeout when the loop idles with no external I/O.""" + pass + + +class LoopTimeEventLoop(asyncio.BaseEventLoop): + + # BaseEventLoop does not have "_selector" declared but uses it in _run_once(). + _selector: selectors.BaseSelector + + def __init__( + self, + *args: Any, + start: float | None | Callable[[], float | None] = None, + end: float | None | Callable[[], float | None] = None, + resolution: float = 1e-6, # to cut off the floating-point errors + idle_step: float | None = None, + idle_timeout: float | None = None, + noop_cycles: int = 42, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self.setup_looptime( + start=start, + end=end, + resolution=resolution, + idle_step=idle_step, + idle_timeout=idle_timeout, + noop_cycles=noop_cycles, + ) + + def setup_looptime( + self, + *, + start: float | None | Callable[[], float | None] = None, + end: float | None | Callable[[], float | None] = None, + resolution: float = 1e-6, # to cut off the floating-point errors + idle_step: float | None = None, + idle_timeout: float | None = None, + noop_cycles: int = 42, + ) -> None: + """ + Set all the fake-time fields and patch the i/o selector. + + This is the same as ``__init__(...)``, except that it can be used + when the mixin/class is injected into the existing event loop object. + In that case, the object is already initialised except for these fields. + """ + self.__resolution_reciprocal: int = round(1/resolution) + self.__now: int = self.__time2int(start() if callable(start) else start) or 0 + self.__end: int | None = self.__time2int(end() if callable(end) else end) + + self.__idle_timeout: int | None = self.__time2int(idle_timeout) + self.__idle_step: int | None = self.__time2int(idle_step) + self.__idle_end: int | None = None + + self.__noop_limit: int | None = None + self.__noop_cycle: int | None = None + self.__noop_cycles: int = noop_cycles + + self.__sync_futures: MutableSet[AnyFuture] = weakref.WeakSet() + self.__sync_clock: Callable[[], float] = time.perf_counter + self.__sync_ts: float | None = None # system/true-time clock timestamp + + # TODO: why do we patch the selector as an object while the event loop as a class? + # this should be the same patching method for both. + try: + self.__original_select # type: ignore + except AttributeError: + self.__original_select = self._selector.select + self._selector.select = self.__replaced_select # type: ignore + + def time(self) -> float: + return self.__int2time(self.__now) + + def run_in_executor(self, executor: Any, func: Any, *args: Any) -> AnyFuture: + future = super().run_in_executor(executor, func, *args) + if isinstance(future, asyncio.Future): + self.__sync_futures.add(future) + return future + + def __replaced_select(self, timeout: float | None) -> list[tuple[Any, Any]]: + overtime = False + + # First of all, do the i/o polling. Some i/o has happened? Fine! Process it asap! + # The time-play starts only when there is nothing to process from the outside (i.e. no i/o). + ready: list[tuple[Any, Any]] = self.__original_select(timeout=0) + if ready: + pass + + # Regardless of the timeout, if there are executors sync futures, we move the time in steps. + # The timeout (if present) can limit the size of the step, but not the logic of stepping. + # Generally, external things (threads) take some time (e.g. for thread spawning). + # We cannot reliably hook into the abstract executors or their sync futures, + # so we have to spend the true-time waiting for them. + # We hope that they finish fast enough —in a few steps— and schedule their new handles. + elif any(not fut.done() for fut in self.__sync_futures): + self.__noop_limit = self.__noop_cycle = None + + # Unbalanced split: if the events arrive closer to the end, still move the loop time. + # But skip the loop time increment if the events arrive early (first 80% of the range). + timeout, step, overtime = self.__sync(timeout, self.__idle_step) + readyA = self.__original_select(timeout=timeout * 0.8 if timeout is not None else None) + readyB = self.__original_select(timeout=timeout * 0.2 if timeout is not None else None) + ready = readyA + readyB + self.__now += step if not readyA else 0 + + # There is nothing to run or to wait inside the loop, only the external actors (I/O) + # can put life into the loop: either via the regular I/O (e.g. sockets, servers, files), + # or via a dummy self-writing socket of the event loop (used by the "thread-safe" calls). + # Instead of the eternal sleep, limit it to little true-time steps until the end-of-time. + # Set to `None` to actually sleep to infinity. Set to `0` to only poll and fail instantly. + elif timeout is None: + if self.__idle_end is None and self.__idle_timeout is not None: + self.__idle_end = self.__now + self.__idle_timeout + + # Unbalanced split: if the events arrive closer to the end, still move the loop time. + # But skip the loop time increment if the events arrive early (first 80% of the range). + timeout, step, overtime = self.__sync(timeout, self.__idle_step, self.__idle_end) + readyA = self.__original_select(timeout=timeout * 0.8 if timeout is not None else None) + readyB = self.__original_select(timeout=timeout * 0.2 if timeout is not None else None) + ready = readyA + readyB + self.__now += step if not readyA else 0 + + # There are handles ready to be executed right now. The time never advances here. + # We are explicitly asked to quick-check (poll) the status of I/O sockets just in case. + # Note: the countdown should execute for N cycles strictly in the loop's no-op mode, + # so any i/o polling resets the cycle counter. + elif timeout == 0: + if self.__noop_limit is not None: + self.__noop_cycle = 0 + + # Now, we have a request to actually move the time to the next scheduled timer/handle. + # Instead, we initiate a no-op cycle to let the coros/tasks/context-managers/generators run. + # Without this no-op cycle, consecutive async/await operations sometimes have no chance + # to execute because the earlier operations set a timeout or schedule the timer-handles + # so that the fake-time moves before it gets to the next operations. For example: + # async with async_timeout.timeout(2): # schedules to T0+2s, shifts the time, raises. + # await asyncio.sleep(1) # gets no chance to set its handle to T0+1s. + elif self.__noop_limit is None or self.__noop_cycle is None: + self.__noop_limit = self.__noop_cycles + self.__noop_cycle = 0 + + # Regardless of the requested timeout, let the no-op cycle go without side effects. + # We never sleep or advance the loop time while in this cycle-throttling mode. + elif self.__noop_cycle < self.__noop_limit: + self.__noop_cycle += 1 + + # Finally, the no-op cycles are over. We move the fake clock to the next scheduled handle! + # Since there is nothing that can happen from the outside, move the time all at once. + # Moving it in smaller steps can be a waste of compute power (though, not harmful). + else: + _, step, overtime = self.__sync(timeout) + self.__noop_limit = self.__noop_cycle = None + self.__now += step + + # If any i/o has happened, we've got work to do: all idling countdowns should restart anew. + if ready: + self.__idle_end = None + self.__noop_limit = None + self.__noop_cycle = None + + task: AnyTask + future: AnyFuture + + # If the loop has reached its end-of-time, destroy the loop's universe: stop all its tasks. + # If possible, inject the descriptive exceptions; if not, just cancel them (on every cycle). + if overtime and self.__end is not None and self.__now >= self.__end: + for task in asyncio.all_tasks(): + future = cast(AnyFuture, getattr(task, '_fut_waiter', None)) + if future is not None: + future.set_exception(LoopTimeoutError( + "The event loop has reached its end-of-time. " + "All running tasks are considered timed out.")) + else: + task.cancel() + + # If the end-of-time is not reached yet, but the loop is idling for too long? Fail too. + if overtime and self.__idle_end is not None and self.__now >= self.__idle_end: + for task in asyncio.all_tasks(): + future = cast(AnyFuture, getattr(task, '_fut_waiter', None)) + if future is not None: + future.set_exception(IdleTimeoutError( + "The event loop was idle for too long — giving up on waiting for new i/o. " + "All running tasks are considered timed out.")) + else: + task.cancel() + + return ready + + def __sync( + self, + timeout: float | None, + step: int | None = None, + end: int | None = None, + ) -> tuple[float | None, int, bool]: + """ + Synchronise the loop-time and real-time steps as much as possible. + + In some cases, the loop time moves in sync with the real-world time: + specifically, when there is nothing inside the loop that can + fast-forward the loop time and only external occasions can move it. + (The "external occasions" are either i/o or synchronous executors.) + + The loop time moves in sharp steps. However, there is also code overhead + that consumes time between the steps, making the loop time misaligned + with the real time. + + To work around that, the we keep track of the timestamps when the last + sync happened and adjusts the real-clock timeout and loop-time step. + For example, in a sequence of 4x loop-time steps of 0.01 seconds, + the code overhead between steps can be 0.0013, 0.0011, 0.0015 seconds; + in that case, the sleeps will be 0.01, 0.0087, 0.0089, 0.0085 seconds. + + This implementation is sufficiently precise but not very precise — + it measures the time from one sync to another, but not for the whole + sequence of steps. + """ + overtime = False + + # Move the loop time precisely to the nearest planned event, not beyond it. + real_step: int | None = step + if timeout is not None: + if real_step is not None: + real_step = min(real_step, self.__time2int(timeout)) + else: + real_step = self.__time2int(timeout) + if end is not None: + if real_step is not None: + overtime = real_step >= end - self.__now + real_step = min(real_step, end - self.__now) + else: + overtime = True + real_step = end - self.__now + if self.__end is not None: + if real_step is not None: + overtime = real_step >= self.__end - self.__now + real_step = min(real_step, self.__end - self.__now) + else: + overtime = True + real_step = self.__end - self.__now + + # Normally, the real-clock step is the same as the loop-time step. + real_timeout = self.__int2time(real_step) + + # Optionally, adjust the real-clock sleep by the code overhead since the last time sync. + # The code overhead timedelta can be negative if the previous cycle was interrupted by i/o. + prev_ts = self.__sync_ts + curr_ts = self.__sync_ts = self.__sync_clock() + if real_timeout is not None and prev_ts is not None and curr_ts > prev_ts: + code_overhead = curr_ts - prev_ts + real_timeout = max(0.0, real_timeout - code_overhead) + + # Pre-allocate the time for the calculated timeout (assuming the timeout is fully used). + if real_timeout is not None: + self.__sync_ts += real_timeout + + # Make the steps easier for int math: use 0 instead of None for "no step". + return real_timeout, (real_step or 0), overtime + + @overload + def __time2int(self, time: float) -> int: ... + + @overload + def __time2int(self, time: None) -> None: ... + + def __time2int(self, time: float | None) -> int | None: + """ + Convert the supposed time in seconds to its INTernal INTeger form. + + All time math is done in integers to avoid floating point errors + (there can also be some performance gain, but this is not the goal). + + Otherwise, the time assertions fail easily because of this:: + + 0.2-0.05 == 0.15000000000000002 + 0.2-0.19 == 0.010000000000000009 + 0.2+0.21 == 0.41000000000000003 + """ + return None if time is None else round(time * self.__resolution_reciprocal) + + @overload + def __int2time(self, time: int) -> float: ... + + @overload + def __int2time(self, time: None) -> None: ... + + def __int2time(self, time: int | None) -> float | None: + """ + Convert the INTernal INTeger form of time back to the time in seconds. + + The int/int division is better than the int*float multiplication:: + + 100_000 * 0.000_001 == 0.09999999999999999 + 100_000 / round(1/0.000_001) == 0.1 + """ + return None if time is None else time / self.__resolution_reciprocal diff --git a/looptime/math.py b/looptime/math.py new file mode 100644 index 0000000..40831ef --- /dev/null +++ b/looptime/math.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import abc + + +class Numeric(metaclass=abc.ABCMeta): + """A base class for objects that support direct comparison & arithmetics.""" + + def __init__(self, *, resolution: float = 1e-9) -> None: + super().__init__() + self.__rr: int = round(1 / resolution) # rr == resolution reciprocal + + @property + @abc.abstractmethod + def _value(self) -> float: + raise NotImplementedError + + # + # Type conversion: + # + + def __str__(self) -> str: + return str(float(self)) + + def __int__(self) -> int: + return int(float(self)) + + def __float__(self) -> float: + return round(self._value * self.__rr) / self.__rr + + # + # Comparison: + # + + def __eq__(self, other: object) -> bool: + if isinstance(other, (int, float)): + return round(self._value * self.__rr) == round(other * self.__rr) + else: + return NotImplemented + + def __ne__(self, other: object) -> bool: + if isinstance(other, (int, float)): + return round(self._value * self.__rr) != round(other * self.__rr) + else: + return NotImplemented + + def __ge__(self, other: object) -> bool: + if isinstance(other, (int, float)): + return round(self._value * self.__rr) >= round(other * self.__rr) + else: + return NotImplemented + + def __gt__(self, other: object) -> bool: + if isinstance(other, (int, float)): + return round(self._value * self.__rr) > round(other * self.__rr) + else: + return NotImplemented + + def __le__(self, other: object) -> bool: + if isinstance(other, (int, float)): + return round(self._value * self.__rr) <= round(other * self.__rr) + else: + return NotImplemented + + def __lt__(self, other: object) -> bool: + if isinstance(other, (int, float)): + return round(self._value * self.__rr) < round(other * self.__rr) + else: + return NotImplemented + + # + # Arithmetics: + # + + def __add__(self, other: object) -> float: + if isinstance(other, (int, float)): + return (round(self._value * self.__rr) + round(other * self.__rr)) / self.__rr + else: + return NotImplemented + + def __sub__(self, other: object) -> float: + if isinstance(other, (int, float)): + return (round(self._value * self.__rr) - round(other * self.__rr)) / self.__rr + else: + return NotImplemented + + def __mul__(self, other: object) -> float: + if isinstance(other, (int, float)): + return (round(self._value * self.__rr) * round(other * self.__rr)) / (self.__rr ** 2) + else: + return NotImplemented + + def __floordiv__(self, other: object) -> float: + if isinstance(other, (int, float)): + return (round(self._value * self.__rr) // round(other * self.__rr)) + else: + return NotImplemented + + def __truediv__(self, other: object) -> float: + if isinstance(other, (int, float)): + return (round(self._value * self.__rr) / round(other * self.__rr)) + else: + return NotImplemented + + def __mod__(self, other: object) -> float: + if isinstance(other, (int, float)): + return (round(self._value * self.__rr) % round(other * self.__rr)) / (self.__rr) + else: + return NotImplemented + + def __pow__(self, power: float, modulo: None = None) -> float: + return pow(round(self._value * self.__rr), power, modulo) / pow(self.__rr, power, modulo) diff --git a/looptime/patchers.py b/looptime/patchers.py new file mode 100644 index 0000000..3932655 --- /dev/null +++ b/looptime/patchers.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import asyncio +from typing import Any, Type, cast + +from looptime import loops + +_class_cache: dict[Type[asyncio.BaseEventLoop], Type[loops.LoopTimeEventLoop]] = {} + + +def reset_caches() -> None: + _class_cache.clear() + + +def make_event_loop_class( + cls: Type[asyncio.BaseEventLoop], + *, + prefix: str = 'Looptime', +) -> Type[loops.LoopTimeEventLoop]: + if issubclass(cls, loops.LoopTimeEventLoop): + return cls + elif cls not in _class_cache: + new_class = type(f'{prefix}{cls.__name__}', (loops.LoopTimeEventLoop, cls), {}) + _class_cache[cls] = new_class + return _class_cache[cls] + + +def patch_event_loop( + loop: asyncio.BaseEventLoop, + **kwargs: Any, +) -> loops.LoopTimeEventLoop: + result: loops.LoopTimeEventLoop + if isinstance(loop, loops.LoopTimeEventLoop): + return loop + else: + new_class = make_event_loop_class(loop.__class__) + loop.__class__ = new_class + loop = cast(loops.LoopTimeEventLoop, loop) + loop.setup_looptime(**kwargs) + return loop + + +def new_event_loop(**kwargs: Any) -> loops.LoopTimeEventLoop: + return patch_event_loop(cast(asyncio.BaseEventLoop, asyncio.new_event_loop()), **kwargs) diff --git a/looptime/plugin.py b/looptime/plugin.py new file mode 100644 index 0000000..e825340 --- /dev/null +++ b/looptime/plugin.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import asyncio +from typing import Any, cast + +import pytest + +from looptime import loops, patchers + + +@pytest.hookimpl(hookwrapper=True) # type: ignore +def pytest_fixture_setup(fixturedef: Any, request: Any) -> Any: + if fixturedef.argname == "event_loop": + result = yield + loop = cast(asyncio.BaseEventLoop, result.get_result()) if result.excinfo is None else None + if loop is not None and not isinstance(loop, loops.LoopTimeEventLoop): + + # True means implicitly on; False means explicitly off; None means "only if marked". + option: bool | None = request.config.getoption('looptime') + + markers = list(request.node.iter_markers('looptime')) + enabled = bool((markers or option is True) and option is not False) # but not None! + options = {} + for marker in reversed(markers): + options.update(marker.kwargs) + enabled = bool(marker.args[0]) if marker.args else enabled + + if enabled: + patched_loop = patchers.patch_event_loop(loop) + patched_loop.setup_looptime(**options) + result.force_result(patched_loop) + else: + yield + + +def pytest_configure(config: Any) -> None: + config.addinivalue_line('markers', "looptime: configure the fake fast-forwarding loop time.") + + +def pytest_addoption(parser: Any) -> None: + group = parser.getgroup("asyncio time contraction") + group.addoption("--no-looptime", dest='looptime', action="store_const", const=False, + help="Force all (even marked) tests to the true loop time.") + group.addoption("--looptime", dest='looptime', action="store_const", const=True, + help="Run unmarked tests with the fake loop time by default.") diff --git a/notime/py.typed b/looptime/py.typed similarity index 100% rename from notime/py.typed rename to looptime/py.typed diff --git a/looptime/timeproxies.py b/looptime/timeproxies.py new file mode 100644 index 0000000..7886df2 --- /dev/null +++ b/looptime/timeproxies.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import asyncio + +from looptime import math + + +class LoopTimeProxy(math.Numeric): + """ + A numeric-compatible proxy to the time of the current/specific event loop. + """ + + def __init__( + self, + loop: asyncio.AbstractEventLoop | None = None, + *, + resolution: float = 1e-9, + ) -> None: + super().__init__(resolution=resolution) + self._loop = loop + + def __repr__(self) -> str: + return f"" + + def __matmul__(self, other: object) -> LoopTimeProxy: + if isinstance(other, asyncio.AbstractEventLoop): + return self.__class__(other) + else: + return NotImplemented + + @property + def _value(self) -> float: + return self._loop.time() if self._loop is not None else asyncio.get_running_loop().time() + + +try: + import pytest +except ImportError: + pass +else: + @pytest.fixture() + def looptime() -> LoopTimeProxy: + return LoopTimeProxy() diff --git a/notime/__init__.py b/notime/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/requirements.txt b/requirements.txt index e451dfd..8b2f6e4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,10 +6,10 @@ codecov coverage coveralls isort +# Mypy requires typed-ast, which is broken on PyPy 3.7 (could work in PyPy 3.8). +mypy==0.920; implementation_name == "cpython" pre-commit pytest pytest-asyncio pytest-cov pytest-mock -# Mypy requires typed-ast, which is broken on PyPy 3.7 (could work in PyPy 3.8). -mypy==0.910; implementation_name == "cpython" diff --git a/setup.py b/setup.py index 05dca8d..c60e8a9 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,11 @@ packages=find_packages(), include_package_data=True, entry_points={ + 'pytest11': [ + 'looptime_plugin = looptime.plugin', + 'looptime_timeproxies = looptime.timeproxies', + 'looptime_chronometers = looptime.chronometers', + ] }, python_requires='>=3.7', @@ -38,6 +43,7 @@ 'setuptools_scm', ], install_requires=[ + # 'pytest', ], - package_data={"pytest_instant": ["py.typed"]}, + package_data={"looptime": ["py.typed"]}, ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d6fd6da --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,17 @@ +import asyncio + +import pytest + +import looptime + + +@pytest.fixture(autouse=True) +def _clear_caches(): + looptime.reset_caches() + yield + looptime.reset_caches() + + +@pytest.fixture() +def looptime_loop(): + return looptime.patch_event_loop(asyncio.new_event_loop()) diff --git a/tests/test_chronometers.py b/tests/test_chronometers.py new file mode 100644 index 0000000..ae6a2ed --- /dev/null +++ b/tests/test_chronometers.py @@ -0,0 +1,61 @@ +import asyncio +import time + +import pytest + +import looptime + + +def test_duration_is_none_initially(): + chronometer = looptime.Chronometer() + assert chronometer.seconds is None + + +def test_duration_resets_on_reuse(): + chronometer = looptime.Chronometer() + with chronometer: + time.sleep(0.1) + with chronometer: + time.sleep(0.1) + assert 0.1 <= chronometer.seconds <= 0.11 + + +def test_conversion_to_int(): + chronometer = looptime.Chronometer() + with chronometer: + time.sleep(0.1) + seconds = int(chronometer) + assert seconds == 0 + + +def test_conversion_to_float(): + chronometer = looptime.Chronometer() + with chronometer: + time.sleep(0.1) + seconds = float(chronometer) + assert 0.1 <= seconds < 0.11 + + +@pytest.mark.asyncio +async def test_sync_context_manager(): + with looptime.Chronometer() as chronometer: + time.sleep(0.1) + assert 0.1 <= chronometer.seconds < 0.11 + + +@pytest.mark.asyncio +async def test_async_context_manager(): + async with looptime.Chronometer() as chronometer: + time.sleep(0.1) + assert 0.1 <= chronometer.seconds < 0.11 + + +@pytest.mark.asyncio +@pytest.mark.looptime(start=100) +async def test_readme_example(chronometer, event_loop): + with chronometer, looptime.Chronometer(event_loop.time) as loopometer: + await asyncio.sleep(1) + await asyncio.sleep(1) + assert chronometer.seconds < 0.01 # random code overhead + assert loopometer.seconds == 2 # precise timing, no code overhead + assert event_loop.time() == 102 diff --git a/tests/test_initialisation.py b/tests/test_initialisation.py new file mode 100644 index 0000000..8868389 --- /dev/null +++ b/tests/test_initialisation.py @@ -0,0 +1,31 @@ +import asyncio + +import looptime + + +class MyEventLoop(looptime.LoopTimeEventLoop, asyncio.SelectorEventLoop): + pass + + +def test_initial_time_is_zero_by_default(): + loop = MyEventLoop() + assert loop.time() == 0 + + +def test_initial_time_is_customized(): + loop = MyEventLoop(start=123) + assert loop.time() == 123 + + +def test_double_init_patches_once(): + original_loop = asyncio.SelectorEventLoop() + original_select = original_loop._selector.select + + looptime_loop = looptime.patch_event_loop(original_loop) + patched_select_1 = looptime_loop._selector.select + assert patched_select_1 is not original_select + + looptime_loop.setup_looptime() + patched_select_2 = looptime_loop._selector.select + assert patched_select_2 is not original_select + assert patched_select_2 is patched_select_1 diff --git a/tests/test_patching.py b/tests/test_patching.py new file mode 100644 index 0000000..bebe423 --- /dev/null +++ b/tests/test_patching.py @@ -0,0 +1,44 @@ +import asyncio + +import looptime + + +class MyEventLoop(asyncio.SelectorEventLoop): + pass + + +class InheritedEventLoop(looptime.LoopTimeEventLoop, asyncio.SelectorEventLoop): + pass + + +def test_patching_skipped_if_already_inherited(): + old_loop = InheritedEventLoop() + new_loop = looptime.patch_event_loop(old_loop) + assert old_loop is new_loop + + +def test_patching_injects_the_base_class(): + old_loop = MyEventLoop() + new_loop = looptime.patch_event_loop(old_loop) + assert old_loop is new_loop + assert isinstance(new_loop, MyEventLoop) + assert isinstance(new_loop, looptime.LoopTimeEventLoop) + + +def test_patching_initialises(): + old_loop = MyEventLoop() + new_loop = looptime.patch_event_loop(old_loop) + assert hasattr(new_loop, '_LoopTimeEventLoop__now') + + +def test_initial_time_is_customized(): + loop = looptime.patch_event_loop(asyncio.new_event_loop(), start=123) + assert loop.time() == 123 + + +def test_new_event_loop_is_patched_out_of_the_box(): + default_loop_cls = asyncio.new_event_loop().__class__ + loop = looptime.new_event_loop(start=123) + assert isinstance(loop, default_loop_cls) + assert isinstance(loop, looptime.LoopTimeEventLoop) + assert loop.time() == 123 diff --git a/tests/test_plugin.py b/tests/test_plugin.py new file mode 100644 index 0000000..2d30b8e --- /dev/null +++ b/tests/test_plugin.py @@ -0,0 +1,119 @@ +pytest_plugins = ["pytester"] + + +def test_cli_default_mode(testdir): + testdir.makepyfile(""" + import looptime + import pytest + + @pytest.mark.asyncio + async def test_normal(event_loop): + assert not isinstance(event_loop, looptime.LoopTimeEventLoop) + + @pytest.mark.asyncio + @pytest.mark.looptime + async def test_marked(event_loop): + assert isinstance(event_loop, looptime.LoopTimeEventLoop) + assert event_loop.time() == 0 + + @pytest.mark.asyncio + @pytest.mark.looptime(start=123) + async def test_configured(event_loop): + assert isinstance(event_loop, looptime.LoopTimeEventLoop) + assert event_loop.time() == 123 + + @pytest.mark.asyncio + @pytest.mark.looptime(False) + async def test_disabled(event_loop): + assert not isinstance(event_loop, looptime.LoopTimeEventLoop) + """) + result = testdir.runpytest() + result.assert_outcomes(passed=4) + + +def test_cli_option_enabled(testdir): + testdir.makepyfile(""" + import looptime + import pytest + + @pytest.mark.asyncio + async def test_normal(event_loop): + assert isinstance(event_loop, looptime.LoopTimeEventLoop) + assert event_loop.time() == 0 + + @pytest.mark.asyncio + @pytest.mark.looptime + async def test_marked(event_loop): + assert isinstance(event_loop, looptime.LoopTimeEventLoop) + assert event_loop.time() == 0 + + @pytest.mark.asyncio + @pytest.mark.looptime(start=123) + async def test_configured(event_loop): + assert isinstance(event_loop, looptime.LoopTimeEventLoop) + assert event_loop.time() == 123 + + @pytest.mark.asyncio + @pytest.mark.looptime(False) + async def test_disabled(event_loop): + assert not isinstance(event_loop, looptime.LoopTimeEventLoop) + """) + result = testdir.runpytest("--looptime") + result.assert_outcomes(passed=4) + + +def test_cli_option_disabled(testdir): + testdir.makepyfile(""" + import looptime + import pytest + + @pytest.mark.asyncio + async def test_normal(event_loop): + assert not isinstance(event_loop, looptime.LoopTimeEventLoop) + + @pytest.mark.asyncio + @pytest.mark.looptime + async def test_marked(event_loop): + assert not isinstance(event_loop, looptime.LoopTimeEventLoop) + + @pytest.mark.asyncio + @pytest.mark.looptime(start=123) + async def test_configured(event_loop): + assert not isinstance(event_loop, looptime.LoopTimeEventLoop) + + @pytest.mark.asyncio + @pytest.mark.looptime(False) + async def test_disabled(event_loop): + assert not isinstance(event_loop, looptime.LoopTimeEventLoop) + """) + result = testdir.runpytest("--no-looptime") + result.assert_outcomes(passed=4) + + +def test_fixture_chronometer(testdir): + testdir.makepyfile(""" + import time + import pytest + + @pytest.mark.asyncio + async def test_me(chronometer): + with chronometer: + time.sleep(0.1) + assert 0.1 <= chronometer.seconds < 0.2 + """) + result = testdir.runpytest() + result.assert_outcomes(passed=1) + + +def test_fixture_timeloop(testdir): + testdir.makepyfile(""" + import time + import pytest + + @pytest.mark.asyncio + @pytest.mark.looptime(start=123) + async def test_me(looptime): + assert looptime == 123 + """) + result = testdir.runpytest() + result.assert_outcomes(passed=1) diff --git a/tests/test_readme.py b/tests/test_readme.py new file mode 100644 index 0000000..18d139b --- /dev/null +++ b/tests/test_readme.py @@ -0,0 +1,25 @@ +import asyncio + +import async_timeout +import pytest + + +@pytest.mark.asyncio +@pytest.mark.looptime(end=1) +async def test_the_end_of_time(chronometer, looptime): + with chronometer: + with pytest.raises(asyncio.TimeoutError): + await asyncio.Event().wait() + assert looptime == 1 + assert chronometer >= 1 + + +@pytest.mark.asyncio +@pytest.mark.looptime +async def test_async_timeout(chronometer, looptime): + with chronometer: + with pytest.raises(asyncio.TimeoutError): + async with async_timeout.timeout(1): + await asyncio.Event().wait() + assert looptime == 1 + assert chronometer < 0.1 diff --git a/tests/test_subclassing.py b/tests/test_subclassing.py new file mode 100644 index 0000000..4b37f1f --- /dev/null +++ b/tests/test_subclassing.py @@ -0,0 +1,52 @@ +import asyncio + +import looptime + + +class MyEventLoop(asyncio.BaseEventLoop): + pass + + +class ExtEventLoop(MyEventLoop): + pass + + +class InheritedEventLoop(looptime.LoopTimeEventLoop, asyncio.BaseEventLoop): + pass + + +def test_skipping_if_already_inherits_as_needed(): + cls = looptime.make_event_loop_class(InheritedEventLoop) + assert cls is InheritedEventLoop + + +def test_class_creation_anew(): + cls = looptime.make_event_loop_class(MyEventLoop) + assert issubclass(cls, MyEventLoop) + assert issubclass(cls, looptime.LoopTimeEventLoop) + assert cls.__name__ != MyEventLoop.__name__ + assert cls.__name__.startswith("Looptime") + + +def test_class_name_default(): + cls = looptime.make_event_loop_class(MyEventLoop) + assert cls.__name__ == "LooptimeMyEventLoop" + + +def test_class_name_customized(): + cls = looptime.make_event_loop_class(MyEventLoop, prefix='Some') + assert cls.__name__ == "SomeMyEventLoop" + + +def test_cache_hit_for_the_same_base_class(): + cls1 = looptime.make_event_loop_class(MyEventLoop) + cls2 = looptime.make_event_loop_class(MyEventLoop) + assert cls1 is cls2 + + +def test_cache_miss_for_the_different_base_classes(): + cls1 = looptime.make_event_loop_class(MyEventLoop) + cls2 = looptime.make_event_loop_class(ExtEventLoop) + assert cls1 is not cls2 + assert not issubclass(cls1, cls2) + assert not issubclass(cls2, cls1) diff --git a/tests/test_time_moves.py b/tests/test_time_moves.py new file mode 100644 index 0000000..7cad1bd --- /dev/null +++ b/tests/test_time_moves.py @@ -0,0 +1,128 @@ +import asyncio +import time + +import async_timeout +import pytest + +import looptime + + +async def _make_event() -> asyncio.Event: + """Create an event attached to a proper event loop.""" + return asyncio.Event() + + +def test_initial_time_is_zero_by_default(looptime_loop): + assert looptime_loop.time() == 0 + + +def test_initial_time_is_customized(looptime_loop): + looptime_loop.setup_looptime(start=123) + assert looptime_loop.time() == 123 + + +def test_execution_takes_near_zero_time(chronometer, looptime_loop): + with chronometer: + looptime_loop.run_until_complete(asyncio.sleep(10)) + assert looptime_loop.time() == 10 + assert 0.0 <= chronometer.seconds < 0.01 + + +def test_real_time_is_ignored(chronometer, looptime_loop): + async def f(): + await asyncio.sleep(5) + time.sleep(0.1) + await asyncio.sleep(5) + + with chronometer: + looptime_loop.run_until_complete(f()) + assert looptime_loop.time() == 10 + assert 0.1 <= chronometer.seconds < 0.12 + + +def test_timeout_doesnt_happen_before_entered_the_code(chronometer, looptime_loop): + async def f(): + async with async_timeout.timeout(10): + await asyncio.sleep(1) + + with chronometer: + looptime_loop.run_until_complete(f()) + assert looptime_loop.time() == 1 + assert 0.0 <= chronometer.seconds < 0.01 + + +def test_timeout_does_happen_according_to_schedule(chronometer, looptime_loop): + async def f(): + async with async_timeout.timeout(1): + await asyncio.sleep(10) + + with chronometer: + with pytest.raises(asyncio.TimeoutError): + looptime_loop.run_until_complete(f()) + assert looptime_loop.time() == 1 + assert 0.0 <= chronometer.seconds < 0.01 + + +def test_end_of_time_reached(chronometer, looptime_loop): + looptime_loop.setup_looptime(end=1) + + async def f(): + await asyncio.sleep(10) + + with chronometer: + with pytest.raises(looptime.LoopTimeoutError): + looptime_loop.run_until_complete(f()) + assert looptime_loop.time() == 1 + assert 0.0 <= chronometer.seconds < 0.01 + + +def test_end_of_time_injects_into_coros(looptime_loop): + looptime_loop.setup_looptime(end=1) + exc = None + + async def f(): + nonlocal exc + try: + await asyncio.sleep(10) + except Exception as e: + exc = e + + looptime_loop.run_until_complete(f()) + assert isinstance(exc, looptime.LoopTimeoutError) + + +def test_end_of_time_allows_zerotime_finalizers(looptime_loop): + looptime_loop.setup_looptime(end=1) + with pytest.raises(looptime.LoopTimeoutError): + looptime_loop.run_until_complete(asyncio.sleep(1)) + + some_event = looptime_loop.run_until_complete(_make_event()) + some_event.set() + looptime_loop.run_until_complete(asyncio.sleep(0)) + looptime_loop.run_until_complete(some_event.wait()) + + +def test_end_of_time_cancels_realtime_finalizers(looptime_loop): + looptime_loop.setup_looptime(end=1) + with pytest.raises(looptime.LoopTimeoutError): + looptime_loop.run_until_complete(asyncio.sleep(1)) + + some_event = looptime_loop.run_until_complete(_make_event()) + with pytest.raises(looptime.LoopTimeoutError): + looptime_loop.run_until_complete(asyncio.sleep(0.1)) + with pytest.raises(looptime.LoopTimeoutError): + looptime_loop.run_until_complete(some_event.wait()) + + +@pytest.mark.parametrize('start, sleep, expected_time', [ + (0.2, 0.09, 0.29), # in Python: 0.29000000000000004 + (0.2, 0.21, 0.41), # in Python: 0.41000000000000003 +]) +def test_floating_point_precision_fixed(looptime_loop, start, sleep, expected_time): + looptime_loop.setup_looptime(start=start) + + async def f(): + await asyncio.sleep(sleep) + + looptime_loop.run_until_complete(f()) + assert looptime_loop.time() == expected_time diff --git a/tests/test_time_on_executors.py b/tests/test_time_on_executors.py new file mode 100644 index 0000000..57909d0 --- /dev/null +++ b/tests/test_time_on_executors.py @@ -0,0 +1,32 @@ +import asyncio +import time + + +def test_with_empty_code(chronometer, looptime_loop): + looptime_loop.setup_looptime(idle_step=0.01) + + def sync(): + pass + + async def f(): + await asyncio.get_running_loop().run_in_executor(None, sync) + + with chronometer: + looptime_loop.run_until_complete(f()) + assert looptime_loop.time() == 0.0 # possibly 0.01 if the thread spawns too slowly + assert 0.0 <= chronometer.seconds < 0.1 + + +def test_with_sleep(chronometer, looptime_loop): + looptime_loop.setup_looptime(idle_step=0.01) + + def sync(): + time.sleep(0.1) + + async def f(): + await asyncio.get_running_loop().run_in_executor(None, sync) + + with chronometer: + looptime_loop.run_until_complete(f()) + assert looptime_loop.time() == 0.1 # possibly 0.11 if the threads spawns too slowly + assert 0.1 <= chronometer.seconds < 0.11 diff --git a/tests/test_time_on_io_idle.py b/tests/test_time_on_io_idle.py new file mode 100644 index 0000000..e4155ca --- /dev/null +++ b/tests/test_time_on_io_idle.py @@ -0,0 +1,104 @@ +import asyncio +import socket +import threading +import time + +import pytest + +import looptime + + +def test_no_idle_configured(chronometer, looptime_loop): + looptime_loop.setup_looptime(end=None, idle_timeout=None, idle_step=None) + + rsock, wsock = socket.socketpair() + + def sender(): + time.sleep(0.1) + wsock.send(b'z') + + async def f(): + reader, writer = await asyncio.open_connection(sock=rsock) + try: + await reader.read(1) + finally: + writer.close() + wsock.close() + + thread = threading.Thread(target=sender) + thread.start() + + with chronometer: + looptime_loop.run_until_complete(f()) + assert looptime_loop.time() == 0 + assert 0.1 <= chronometer.seconds < 0.12 + + thread.join() + + +def test_stepping_with_no_limit(chronometer, looptime_loop): + looptime_loop.setup_looptime(end=None, idle_timeout=None, idle_step=0.01) + + rsock, wsock = socket.socketpair() + + def sender(): + time.sleep(0.1) + wsock.send(b'z') + + async def f(): + reader, writer = await asyncio.open_connection(sock=rsock) + try: + await reader.read(1) + finally: + writer.close() + wsock.close() + + thread = threading.Thread(target=sender) + thread.start() + + with chronometer: + looptime_loop.run_until_complete(f()) + + # TODO: FIXME: Sometimes, the code overhead eats 1-2 steps (if they are small). + assert looptime_loop.time() in [0.1, 0.09] + assert 0.1 <= chronometer.seconds < 0.12 + + thread.join() + + +def test_end_of_idle(chronometer, looptime_loop): + looptime_loop.setup_looptime(end=None, idle_timeout=1, idle_step=0.1) + + async def f(): + rsock, wsock = socket.socketpair() + reader, writer = await asyncio.open_connection(sock=rsock) + try: + await reader.read(1) + finally: + writer.close() + wsock.close() + + with chronometer: + with pytest.raises(looptime.IdleTimeoutError): + looptime_loop.run_until_complete(f()) + assert looptime_loop.time() == 1 + assert 1.0 <= chronometer.seconds < 1.02 + + +def test_end_of_time(chronometer, looptime_loop): + looptime_loop.setup_looptime(end=1, idle_timeout=None, idle_step=0.1) + + async def f(): + rsock, wsock = socket.socketpair() + reader, writer = await asyncio.open_connection(sock=rsock) + try: + await reader.read(1) + finally: + writer.close() + wsock.close() + + with chronometer: + with pytest.raises(looptime.LoopTimeoutError): + looptime_loop.run_until_complete(f()) + assert looptime_loop.time() == 1 + assert 1.0 <= chronometer.seconds < 1.01 diff --git a/tests/test_timeproxies.py b/tests/test_timeproxies.py new file mode 100644 index 0000000..114ab3e --- /dev/null +++ b/tests/test_timeproxies.py @@ -0,0 +1,64 @@ +import looptime + + +def test_time_proxy_math(): + proxy = looptime.LoopTimeProxy(looptime.new_event_loop(start=123.456)) + + assert str(proxy) == '123.456' + assert int(proxy) == 123 + assert float(proxy) == 123.456 + + assert proxy == 123.456 + assert not proxy == 456.123 + assert proxy != 456.123 + assert not proxy != 123.456 + + assert proxy > 122 + assert proxy < 124 + assert proxy >= 122 + assert proxy <= 124 + + assert not proxy < 122 + assert not proxy > 124 + assert not proxy <= 122 + assert not proxy >= 124 + + assert not proxy > 123.456 + assert not proxy < 123.456 + assert proxy >= 123.456 + assert proxy <= 123.456 + + assert proxy + 1.2 == 124.656 + assert proxy - 1.2 == 122.256 + assert proxy * 1.2 == 148.1472 + + # The following values cause floating point precision errors if not adjusted: + # 123.456 / 1.2 => 102.88000000000001 + # 123.456 % 1.2 => 1.0560000000000076 + # We also test for floating point resolution here: + assert proxy / 1.2 == 102.88 + assert proxy // 1.2 == 102.0 + assert proxy % 1.2 == 1.056 + + assert round(proxy ** 1.2, 6) == 323.455576 # approximately + + +def test_resolution_ignores_extra_precision(): + proxy = looptime.LoopTimeProxy(looptime.new_event_loop(start=123.456789), resolution=.001) + assert str(proxy) == '123.457' + assert int(proxy) == 123 + assert float(proxy) == 123.457 + assert proxy == 123.457 + assert proxy == 123.457111 + assert proxy == 123.456999 + # assume that other operations use the same rounding logic. + + +def test_loop_attachement(): + loop1 = looptime.new_event_loop(start=123.456) + loop2 = looptime.new_event_loop(start=456.123) + proxy = looptime.LoopTimeProxy(loop1) + + assert proxy == 123.456 + assert proxy @ loop1 == 123.456 + assert proxy @ loop2 == 456.123