From 41532f8377c0452c41a065aa8a007e4c88735974 Mon Sep 17 00:00:00 2001 From: Sergey Vasilyev Date: Sun, 28 Dec 2025 18:20:12 +0100 Subject: [PATCH 1/5] Move project configs to pyproject.toml Signed-off-by: Sergey Vasilyev --- .isort.cfg | 6 ------ mypy.ini | 3 --- pyproject.toml | 11 +++++++++++ 3 files changed, 11 insertions(+), 9 deletions(-) delete mode 100644 .isort.cfg delete mode 100644 mypy.ini diff --git a/.isort.cfg b/.isort.cfg deleted file mode 100644 index 118c0dc..0000000 --- a/.isort.cfg +++ /dev/null @@ -1,6 +0,0 @@ -[settings] -line_length = 100 -multi_line_output = 11 -balanced_wrapping = true -combine_as_imports = true -case_sensitive = true diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index fc40ca5..0000000 --- a/mypy.ini +++ /dev/null @@ -1,3 +0,0 @@ -[mypy] -warn_unused_configs = True -ignore_missing_imports = True diff --git a/pyproject.toml b/pyproject.toml index ce5b9a0..bd3e5b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,3 +65,14 @@ lint = [ [tool.setuptools_scm] version_file = "looptime/_version.py" + +[tool.mypy] +warn_unused_configs = true +ignore_missing_imports = true + +[tool.isort] +line_length = 120 +multi_line_output = 11 +balanced_wrapping = true +combine_as_imports = true +case_sensitive = true From c478b5764494bc805e43a44c5c9bd8159f42dc01 Mon Sep 17 00:00:00 2001 From: Sergey Vasilyev Date: Sun, 28 Dec 2025 19:29:02 +0100 Subject: [PATCH 2/5] Hide all internal submodules from the public interface Signed-off-by: Sergey Vasilyev --- looptime/__init__.py | 10 +++++----- looptime/_internal/__init__.py | 0 looptime/{ => _internal}/chronometers.py | 2 +- looptime/{ => _internal}/enabler.py | 2 +- looptime/{ => _internal}/loops.py | 0 looptime/{ => _internal}/math.py | 0 looptime/{ => _internal}/patchers.py | 2 +- looptime/{ => _internal}/plugin.py | 3 +-- looptime/{ => _internal}/timeproxies.py | 2 +- pyproject.toml | 6 +++--- 10 files changed, 13 insertions(+), 14 deletions(-) create mode 100644 looptime/_internal/__init__.py rename looptime/{ => _internal}/chronometers.py (98%) rename looptime/{ => _internal}/enabler.py (99%) rename looptime/{ => _internal}/loops.py (100%) rename looptime/{ => _internal}/math.py (100%) rename looptime/{ => _internal}/patchers.py (97%) rename looptime/{ => _internal}/plugin.py (99%) rename looptime/{ => _internal}/timeproxies.py (97%) diff --git a/looptime/__init__.py b/looptime/__init__.py index d25a75c..17bc7dd 100644 --- a/looptime/__init__.py +++ b/looptime/__init__.py @@ -1,8 +1,8 @@ -from .chronometers import Chronometer -from .enabler import enabled -from .loops import IdleTimeoutError, LoopTimeEventLoop, LoopTimeoutError, TimeWarning -from .patchers import make_event_loop_class, new_event_loop, patch_event_loop, reset_caches -from .timeproxies import LoopTimeProxy +from ._internal.chronometers import Chronometer +from ._internal.enabler import enabled +from ._internal.loops import IdleTimeoutError, LoopTimeEventLoop, LoopTimeoutError, TimeWarning +from ._internal.patchers import make_event_loop_class, new_event_loop, patch_event_loop, reset_caches +from ._internal.timeproxies import LoopTimeProxy __all__ = [ 'Chronometer', diff --git a/looptime/_internal/__init__.py b/looptime/_internal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/looptime/chronometers.py b/looptime/_internal/chronometers.py similarity index 98% rename from looptime/chronometers.py rename to looptime/_internal/chronometers.py index 126bdd0..c980b19 100644 --- a/looptime/chronometers.py +++ b/looptime/_internal/chronometers.py @@ -3,7 +3,7 @@ import time from typing import Any, Callable, TypeVar -from looptime import math +from . import math _SelfT = TypeVar('_SelfT', bound="Chronometer") diff --git a/looptime/enabler.py b/looptime/_internal/enabler.py similarity index 99% rename from looptime/enabler.py rename to looptime/_internal/enabler.py index cf84d07..1e38418 100644 --- a/looptime/enabler.py +++ b/looptime/_internal/enabler.py @@ -4,7 +4,7 @@ import warnings from typing import Any, Callable, ContextManager, ParamSpec, TypeVar -from looptime import loops +from . import loops P = ParamSpec('P') R = TypeVar('R') diff --git a/looptime/loops.py b/looptime/_internal/loops.py similarity index 100% rename from looptime/loops.py rename to looptime/_internal/loops.py diff --git a/looptime/math.py b/looptime/_internal/math.py similarity index 100% rename from looptime/math.py rename to looptime/_internal/math.py diff --git a/looptime/patchers.py b/looptime/_internal/patchers.py similarity index 97% rename from looptime/patchers.py rename to looptime/_internal/patchers.py index 753a4e6..ccb81ea 100644 --- a/looptime/patchers.py +++ b/looptime/_internal/patchers.py @@ -3,7 +3,7 @@ import asyncio from typing import Any, Type, cast -from looptime import loops +from . import loops _class_cache: dict[Type[asyncio.BaseEventLoop], Type[loops.LoopTimeEventLoop]] = {} diff --git a/looptime/plugin.py b/looptime/_internal/plugin.py similarity index 99% rename from looptime/plugin.py rename to looptime/_internal/plugin.py index d50cee7..05d050c 100644 --- a/looptime/plugin.py +++ b/looptime/_internal/plugin.py @@ -109,13 +109,12 @@ import asyncio import sys -import warnings from typing import Any import _pytest.nodes import pytest -from looptime import loops, patchers, timeproxies +from . import loops, patchers, timeproxies # Critical implementation details: It MUST be sync! It CANNOT be async! diff --git a/looptime/timeproxies.py b/looptime/_internal/timeproxies.py similarity index 97% rename from looptime/timeproxies.py rename to looptime/_internal/timeproxies.py index 1203006..c5c000b 100644 --- a/looptime/timeproxies.py +++ b/looptime/_internal/timeproxies.py @@ -2,7 +2,7 @@ import asyncio -from looptime import math +from . import math class LoopTimeProxy(math.Numeric): diff --git a/pyproject.toml b/pyproject.toml index bd3e5b4..d246fff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,9 +41,9 @@ repository = "https://github.com/nolar/looptime" documentation = "https://github.com/nolar/looptime" [project.entry-points.pytest11] -looptime_plugin = "looptime.plugin" -looptime_timeproxies = "looptime.timeproxies" -looptime_chronometers = "looptime.chronometers" +looptime_plugin = "looptime._internal.plugin" +looptime_timeproxies = "looptime._internal.timeproxies" +looptime_chronometers = "looptime._internal.chronometers" [dependency-groups] dev = [ From 9df6c7ecd60c072234cd8fb139c2f7533238fbd1 Mon Sep 17 00:00:00 2001 From: Sergey Vasilyev Date: Sat, 27 Dec 2025 21:05:31 +0100 Subject: [PATCH 3/5] Migrate the docs from readme to sphinx docs Signed-off-by: Sergey Vasilyev --- .agent/workflows/documentation_style.md | 36 ++ .readthedocs.yaml | 16 + README.md | 686 +----------------------- docs/conf.py | 83 +++ docs/configuration.rst | 136 +++++ docs/getting-started.rst | 68 +++ docs/index.rst | 21 + docs/introduction.rst | 96 ++++ docs/nuances.rst | 316 +++++++++++ docs/tools.rst | 189 +++++++ pyproject.toml | 5 + 11 files changed, 979 insertions(+), 673 deletions(-) create mode 100644 .agent/workflows/documentation_style.md create mode 100644 .readthedocs.yaml create mode 100644 docs/conf.py create mode 100644 docs/configuration.rst create mode 100644 docs/getting-started.rst create mode 100644 docs/index.rst create mode 100644 docs/introduction.rst create mode 100644 docs/nuances.rst create mode 100644 docs/tools.rst diff --git a/.agent/workflows/documentation_style.md b/.agent/workflows/documentation_style.md new file mode 100644 index 0000000..feaf82b --- /dev/null +++ b/.agent/workflows/documentation_style.md @@ -0,0 +1,36 @@ +--- +description: Documentation style rules for looptime RST files +--- + +All `.rst` documentation files in this project must follow these styling rules: + +### Page Title +The page title must be enclosed in double-equal signs (`=`) both on top and at the bottom. The length of the underline/overline must match the title length. + +Example: +```rst +========== +Page Title +========== +``` + +### 1st Level Header +The first level header (section) must be underlined with double-equal signs (`=`). + +Example: +```rst +Section Name +============ +``` + +### 2nd Level Header +The second level header (sub-section) must be underlined with single-dash characters (`-`). + +Example: +```rst +Sub-section Name +---------------- +``` + +### 3rd Level and deeper +If needed, use `~` for 3rd level and `^` for 4th level headers. diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..78beee6 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,16 @@ +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details +version: 2 +formats: all +build: + os: ubuntu-24.04 + tools: + python: "3" + jobs: + install: + - pip install --upgrade pip + - pip install --group docs -e . +sphinx: + configuration: docs/conf.py + builder: "dirhtml" +# fail_on_warning: true diff --git a/README.md b/README.md index a097b46..8b25cbf 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Coverage Status](https://coveralls.io/repos/github/nolar/looptime/badge.svg?branch=main)](https://coveralls.io/github/nolar/looptime?branch=main) [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) -## What? +## What is this? Fake the flow of time in asyncio event loops. The effects of time removal can be seen from both sides: @@ -35,64 +35,19 @@ You can see how this library changes and simplifies the tests in [Kopf's PR #881](https://github.com/nolar/kopf/pull/881). -## Why? +## What is it not? -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). +It speeds up tests based on the flow of time, in particular various kinds +of timers, timeouts, sleeps, delays, rate limiters +— both in tests and in the system under test. -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. +It does NOT speed up tests that are simply slow with no explicit delays, +such as those involving the local/loopback network communication, +heavy algorithmical compute, slow data moving or processing, etc. +These activities take their fair time and cannot be time-compacted. -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 schedules 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 coroutine 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-coded in the test. -Not only it makes the whole test-suite slower, it also brings the execution -time close to the values where the code overhead or measurement errors affect -the timing, which 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). +It does NOT speed up time-based tests using the synchronous primitives +and the wall-clock time; ``looptime`` compacts only the asyncio time. ## Examples @@ -148,621 +103,6 @@ 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 inspect -import pytest - -@pytest.hookimpl(hookwrapper=True) -def pytest_pycollect_makeitem(collector, name, obj): - if collector.funcnamefilter(name) and inspect.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 the 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 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`. For reusable event loops, the default is to keep -the time untouched, which means `0.0` or the explicit value for the first test, -but then an ever-increasing value for the 2nd, 3rd, and further tests. - -Note: pytest-asyncio 1.0.0+ introduced event loops with higher scopes, -e.g. class-, module-, packages-, session-scoped event loops used in tests. -Such event loops are reused, so their time continues growing through many tests. -However, if the test is explicitly configured with the start time, -that time is enforced to the event loop when the test function starts — -to satisfy the clearly declared intentions — even if the time moves backwards, -which goes against the nature of the time itself (monotonically growing). -This might lead to surprises in time measurements outside of the test, -e.g. in fixtures: the code durations can become negative, or the events can -happen (falsely) before they are scheduled (loop-clock-wise). Be careful. - - -### 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. -Be careful with the explicit ending time in higher-scoped event loops -of pytest-asyncio>=1.0.0, since they time increases through many tests. - -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 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` 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 some synchronous tasks are 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 artificially 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. +## Documentation -Second, `looptime(end=N)` syncs the loop time with the real time for N seconds, -i.e. it does not instantly fast-forward 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._ - - -### Time magic coverage - -The time compaction magic is enabled only for the duration of the test, -i.e. the test function — but not the fixtures. -The fixtures run in the real (wall-clock) time. - -The options (including the force starting time) are applied at the test function -starting moment, not when it is setting up the fixtures (even function-scoped). - -This is caused by a new concept of multiple co-existing event loops -in pytest-asyncio>=1.0.0: - -- It is unclear which options to apply to higher-scoped fixtures - used by many tests, which themselves use higher-scoped event loops — - especially in selective partial runs. Technically, it is the 1st test, - with the options of 2nd and further tests simply ignored. -- It is impossible to guess which event loop will be the running loop - in the test until we reach the test itself, i.e. we do not know this - when setting up the fixtures, even function-scoped fixtures. -- There is no way to cover the fixture teardown (no hook in pytest), - only for the fixture setup and post-teardown cleanup. - -As such, this functionality (covering of function-scoped fixtures) -was abandoned — since it was never promised, tested, or documented — -plus an assumption that it was never used by anyone (it should not be). -It was rather a side effect of the previous implemention, -which is not available or possible anymore. - - -### pytest-asyncio>=1.0.0 - -As it is said above, pytest-asyncio>=1.0.0 introduced several co-existing -event loops of different scopes. The time compaction in these event loops -is NOT activated. Only the running loop of the test function is activated. - -Configuring and activating multiple co-existing event loops brings a few -conceptual challenges, which require a good sample case to look into, -and some time to think. - -Would you need time compaction in your fixtures of higher scopes, -do it explicitly: - -```python -import asyncio -import pytest - -@pytest.fixture -async def fixt(): - loop = asyncio.get_running_loop() - loop.setup_looptime(start=123, end=456) - with loop.looptime_enabled(): - await do_things() -``` - -There is #11 to add a feature to do this automatically, but it is not yet done. - - -## 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 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, subtracting, 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 that 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. - -For `pytest-asyncio<1.0.0`: - -```python -import looptime -import pytest -from wherever import CustomEventLoop - - -class LooptimeCustomEventLoop(looptime.LoopTimeEventLoop, CustomEventLoop): - pass - - -@pytest.fixture -def event_loop(): - return LooptimeCustomEventLoop() -``` - -For `pytest-asyncio>=1.0.0`: - -```python -import asyncio -import looptime -import pytest -from wherever import CustomEventLoop - - -class LooptimeCustomEventLoop(looptime.LoopTimeEventLoop, CustomEventLoop): - pass - - -class LooptimeCustomEventLoopPolicy(asyncio.DefaultEventLoopPolicy): - def new_event_loop(self): - return LooptimeCustomEventLoop() - - -@pytest.fixture(scope='session') -def event_loop_policy(): - return LooptimeCustomEventLoopPolicy() -``` - -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): - -For `pytest-asyncio<1.0.0`: - -```python -import asyncio -import looptime -import pytest - - -@pytest.fixture -def event_loop(): - loop = asyncio.new_event_loop() - return looptime.patch_event_loop(loop) -``` - -For `pytest-asyncio>=1.0.0`: - -```python -import asyncio -import looptime -import pytest - - -class LooptimeEventLoopPolicy(asyncio.DefaultEventLoopPolicy): - def new_event_loop(self): - loop = super().new_event_loop() - return looptime.patch_event_loop(loop) - - -@pytest.fixture(scope='session') -def event_loop_policy(): - return LooptimeEventLoopPolicy() -``` - -`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__) -``` +For more tricks and options, see the [full documentation](https://looptime.readthedocs.io/). diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..cc03020 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,83 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html +import os + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = 'looptime' +copyright = '2021-2025 Sergey Vasilyev' +author = 'Sergey Vasilyev' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.napoleon', + 'sphinx.ext.viewcode', + 'sphinx.ext.extlinks', + 'sphinx.ext.linkcode', + 'sphinx.ext.intersphinx', + 'sphinx.ext.todo', + 'sphinx_llm.txt', +] + +html_theme = 'furo' +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +html_static_path = [] +templates_path = [] + +# -- Options for intersphinx extension --------------------------------------- +intersphinx_mapping = { + 'python': ('https://docs.python.org/3', None), +} + +# -- Options for linkcode extension ------------------------------------------ +# https://www.sphinx-doc.org/en/master/usage/extensions/linkcode.html + +def linkcode_resolve(domain, info): + if domain != 'py': + return None + if not info['module']: + return None + filename = info['module'].replace('.', '/') + return "https://github.com/nolar/looptime/blob/main/%s.py" % filename + +# -- Options for extlinks extension ------------------------------------------ +# https://www.sphinx-doc.org/en/master/usage/extensions/extlinks.html + +extlinks = { + 'issue': ('https://github.com/nolar/looptime/issues/%s', 'issue %s'), +} + +############################################################################### +# Ensure the apidoc is always built as part of the build process, +# especially in ReadTheDocs build environment. +# See: https://github.com/rtfd/readthedocs.org/issues/1139 +############################################################################### + +def run_apidoc(_): + ignore_paths = [ + ] + + docs_path = os.path.relpath(os.path.dirname(__file__)) + root_path = os.path.relpath(os.path.dirname(os.path.dirname(__file__))) + + argv = [ + '--force', + '--no-toc', + '--separate', + '--module-first', + '--output-dir', os.path.join(docs_path, 'packages'), + os.path.join(root_path, 'looptime'), + ] + ignore_paths + + from sphinx.ext import apidoc + apidoc.main(argv) + + +def setup(app): + app.connect('builder-inited', run_apidoc) diff --git a/docs/configuration.rst b/docs/configuration.rst new file mode 100644 index 0000000..75b1422 --- /dev/null +++ b/docs/configuration.rst @@ -0,0 +1,136 @@ +============= +Configuration +============= + +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): + +.. code-block:: 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: + +.. code-block:: python + + import inspect + import pytest + + @pytest.hookimpl(hookwrapper=True) + def pytest_pycollect_makeitem(collector, name, obj): + if collector.funcnamefilter(name) and inspect.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 the 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 define the test-suite defaults +and override them on the directory, module, class, function, or test level: + +.. code-block:: 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``. For reusable event loops, the default is to keep +the time untouched, which means ``0.0`` or the explicit value for the first test, +but then an ever-increasing value for the 2nd, 3rd, and further tests. + +.. note:: + pytest-asyncio 1.0.0+ introduced event loops with higher scopes, + e.g. class-, module-, packages-, session-scoped event loops used in tests. + Such event loops are reused, so their time continues growing through many tests. + However, if the test is explicitly configured with the start time, + that time is enforced to the event loop when the test function starts — + to satisfy the clearly declared intentions — even if the time moves backwards, + which goes against the nature of the time itself (monotonically growing). + This might lead to surprises in time measurements outside of the test, + e.g. in fixtures: the code durations can become negative, or the events can + happen (falsely) before they are scheduled (loop-clock-wise). Be careful. + + +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 :class:`looptime.LoopTimeoutError`, +a subclass of :class:`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. +Be careful with the explicit ending time in higher-scoped event loops +of pytest-asyncio>=1.0.0, since they time increases through many tests. + +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 :doc:`nuances` +on differences with ``async-timeout``. diff --git a/docs/getting-started.rst b/docs/getting-started.rst new file mode 100644 index 0000000..e558fa3 --- /dev/null +++ b/docs/getting-started.rst @@ -0,0 +1,68 @@ +=============== +Getting Started +=============== + +Installation +============ + +First, install the necessary packages. We assume that the async tests are +supported. For example, use `pytest-asyncio `_: + +.. code-block:: bash + + pip install pytest-asyncio + pip install looptime + + +Activation from CLI +=================== + +Nothing is needed to make async tests run with the fake time, it just works: + +.. code-block:: python + + import asyncio + import pytest + + + @pytest.mark.asyncio + async def test_me(): + await asyncio.sleep(100) + assert asyncio.get_running_loop().time() == 100 + +Run it with the ``--looptime`` flag: + +.. code-block:: bash + + pytest --looptime + +The test will be executed in approximately **0.01 seconds**, +while the event loop believes it is 100 seconds old. + + +Activation by marks +=================== + +If the command line or ini-file options for all tests is not desirable, +individual tests can be marked for fast time forwarding explicitly: + +.. code-block:: 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 + +Then just run regular pytest: + +.. code-block:: bash + + pytest + +Under the hood, the library solves some nuanced situations with time in tests. +See :doc:`nuances` for more complicated (and nuanced) examples. diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..a816cd8 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,21 @@ +======== +looptime +======== + +Fast-forward asyncio event loop time (in tests). + +.. toctree:: + :maxdepth: 2 + :caption: Tutorial: + + introduction + getting-started + configuration + tools + nuances + +.. toctree:: + :maxdepth: 2 + :caption: Reference: + + packages/looptime diff --git a/docs/introduction.rst b/docs/introduction.rst new file mode 100644 index 0000000..3ce1a5e --- /dev/null +++ b/docs/introduction.rst @@ -0,0 +1,96 @@ +============ +Introduction +============ + +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 personal) 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 + (due to the usual code execution overhead). + This speeds up the execution of tests 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 `_, +a framework for `Kubernetes Operators in Python `_, +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 `_. + + +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 schedules 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 coroutine 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-coded in the test. +Not only it makes the whole test-suite slower, it also brings the execution +time close to the values where the code overhead or measurement errors affect +the timing, which 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). diff --git a/docs/nuances.rst b/docs/nuances.rst new file mode 100644 index 0000000..a6ae12f --- /dev/null +++ b/docs/nuances.rst @@ -0,0 +1,316 @@ +======= +Nuances +======= + +Preliminary execution +===================== + +Consider this test: + +.. code-block:: 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 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: + +.. code-block:: 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`` 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 some synchronous tasks are 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: + +.. code-block:: 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 artificially 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 `_ +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-forward 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. + +.. code-block:: 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: + +.. code-block:: python + + >>> 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. + +.. note:: + + 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. + + +Time magic coverage +=================== + +The time compaction magic is enabled only for the duration of the test, +i.e. the test function — but not the fixtures. +The fixtures run in the real (wall-clock) time. + +The options (including the force starting time) are applied at the test function +starting moment, not when it is setting up the fixtures (even function-scoped). + +This is caused by a new concept of multiple co-existing event loops +in pytest-asyncio>=1.0.0: + +- It is unclear which options to apply to higher-scoped fixtures + used by many tests, which themselves use higher-scoped event loops — + especially in selective partial runs. Technically, it is the 1st test, + with the options of 2nd and further tests simply ignored. +- It is impossible to guess which event loop will be the running loop + in the test until we reach the test itself, i.e. we do not know this + when setting up the fixtures, even function-scoped fixtures. +- There is no way to cover the fixture teardown (no hook in pytest), + only for the fixture setup and post-teardown cleanup. + +As such, this functionality (covering of function-scoped fixtures) +was abandoned — since it was never promised, tested, or documented — +plus an assumption that it was never used by anyone (it should not be). +It was rather a side effect of the previous implemention, +which is not available or possible anymore. + + +pytest-asyncio>=1.0.0 +===================== + +As it is said above, pytest-asyncio>=1.0.0 introduced several co-existing +event loops of different scopes. The time compaction in these event loops +is NOT activated. Only the running loop of the test function is activated. + +Configuring and activating multiple co-existing event loops brings a few +conceptual challenges, which require a good sample case to look into, +and some time to think. + +Would you need time compaction in your fixtures of higher scopes, +do it explicitly: + +.. code-block:: python + + import asyncio + import pytest + + @pytest.fixture + async def fixt(): + loop = asyncio.get_running_loop() + loop.setup_looptime(start=123, end=456) + with loop.looptime_enabled(): + await do_things() + +There is :issue:`11` to add a feature to do this automatically, +but it is not yet done. diff --git a/docs/tools.rst b/docs/tools.rst new file mode 100644 index 0000000..02af917 --- /dev/null +++ b/docs/tools.rst @@ -0,0 +1,189 @@ +=============== +Tools & Helpers +=============== + +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: + +.. code-block:: 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: + +.. code-block:: 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 + + +Assertions +========== + +The ``looptime`` **fixture** is syntax sugar for easy loop time assertions: + +.. code-block:: 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, subtracting, 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 that is bound to a specific +event loop (it works for loops both with fake- and real-world time): + +.. code-block:: 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. + +For ``pytest-asyncio<1.0.0``: + +.. code-block:: python + + import looptime + import pytest + from wherever import CustomEventLoop + + + class LooptimeCustomEventLoop(looptime.LoopTimeEventLoop, CustomEventLoop): + pass + + + @pytest.fixture + def event_loop(): + return LooptimeCustomEventLoop() + +For ``pytest-asyncio>=1.0.0``: + +.. code-block:: python + + import asyncio + import looptime + import pytest + from wherever import CustomEventLoop + + + class LooptimeCustomEventLoop(looptime.LoopTimeEventLoop, CustomEventLoop): + pass + + + class LooptimeCustomEventLoopPolicy(asyncio.DefaultEventLoopPolicy): + def new_event_loop(self): + return LooptimeCustomEventLoop() + + + @pytest.fixture(scope='session') + def event_loop_policy(): + return LooptimeCustomEventLoopPolicy() + +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): + +For ``pytest-asyncio<1.0.0``: + +.. code-block:: python + + import asyncio + import looptime + import pytest + + + @pytest.fixture + def event_loop(): + loop = asyncio.new_event_loop() + return looptime.patch_event_loop(loop) + +For ``pytest-asyncio>=1.0.0``: + +.. code-block:: python + + import asyncio + import looptime + import pytest + + + class LooptimeEventLoopPolicy(asyncio.DefaultEventLoopPolicy): + def new_event_loop(self): + loop = super().new_event_loop() + return looptime.patch_event_loop(loop) + + + @pytest.fixture(scope='session') + def event_loop_policy(): + return LooptimeEventLoopPolicy() + +``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): + +.. code-block:: python + + loop.__class__ = looptime.make_event_loop_class(loop.__class__) diff --git a/pyproject.toml b/pyproject.toml index d246fff..982c341 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,11 @@ looptime_timeproxies = "looptime._internal.timeproxies" looptime_chronometers = "looptime._internal.chronometers" [dependency-groups] +docs = [ + "sphinx>=9.0.0", + "sphinx-llm>=0.1.4", + "furo", +] dev = [ "async-timeout", "codecov", From 2535564cd08de593eeaffc65b6143f9fcc6c4de9 Mon Sep 17 00:00:00 2001 From: Sergey Vasilyev Date: Sun, 28 Dec 2025 15:54:12 +0100 Subject: [PATCH 4/5] Fix grammar, punctuation, and style errors in docs Signed-off-by: Sergey Vasilyev --- docs/configuration.rst | 30 +++++------ docs/getting-started.rst | 2 +- docs/introduction.rst | 40 +++++++------- docs/nuances.rst | 111 +++++++++++++++++++-------------------- docs/tools.rst | 22 ++++---- 5 files changed, 102 insertions(+), 103 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index 75b1422..1461bc2 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -47,17 +47,17 @@ The markers can also be artificially injected by plugins/hooks if needed: 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). +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— +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 the real time. +``--no-looptime`` runs all tests, both marked and unmarked, with real time. This flag effectively disables the plugin. @@ -89,25 +89,25 @@ The time zero 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, +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``. For reusable event loops, the default is to keep the time untouched, which means ``0.0`` or the explicit value for the first test, -but then an ever-increasing value for the 2nd, 3rd, and further tests. +but then an ever-increasing value for the second, third, and subsequent tests. .. note:: pytest-asyncio 1.0.0+ introduced event loops with higher scopes, - e.g. class-, module-, packages-, session-scoped event loops used in tests. + e.g., class-, module-, package-, session-scoped event loops used in tests. Such event loops are reused, so their time continues growing through many tests. - However, if the test is explicitly configured with the start time, - that time is enforced to the event loop when the test function starts — - to satisfy the clearly declared intentions — even if the time moves backwards, - which goes against the nature of the time itself (monotonically growing). + However, if the test is explicitly configured with a start time, + that time is enforced on the event loop when the test function starts— + to satisfy the clearly declared intentions—even if the time moves backwards, + which goes against the nature of time itself (monotonically growing). This might lead to surprises in time measurements outside of the test, - e.g. in fixtures: the code durations can become negative, or the events can + e.g., in fixtures: code durations can become negative, or events can happen (falsely) before they are scheduled (loop-clock-wise). Be careful. @@ -121,16 +121,16 @@ The injected exception is :class:`looptime.LoopTimeoutError`, a subclass of :class:`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. +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. Be careful with the explicit ending time in higher-scoped event loops -of pytest-asyncio>=1.0.0, since they time increases through many tests. +of pytest-asyncio>=1.0.0, since their time increases through many tests. If it is a callable, it is called once per event loop to get the value: -e.g. ``end=lambda: time.monotonic() + 10``. +e.g., ``end=lambda: time.monotonic() + 10``. The end of time is not the same as timeouts — see :doc:`nuances` on differences with ``async-timeout``. diff --git a/docs/getting-started.rst b/docs/getting-started.rst index e558fa3..af427cb 100644 --- a/docs/getting-started.rst +++ b/docs/getting-started.rst @@ -43,7 +43,7 @@ while the event loop believes it is 100 seconds old. Activation by marks =================== -If the command line or ini-file options for all tests is not desirable, +If the command line or ini-file options for all tests are not desirable, individual tests can be marked for fast time forwarding explicitly: .. code-block:: python diff --git a/docs/introduction.rst b/docs/introduction.rst index 3ce1a5e..b064a5e 100644 --- a/docs/introduction.rst +++ b/docs/introduction.rst @@ -8,22 +8,22 @@ 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,** +* 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 — + and I/O with sockets, servers, and 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. + making the loop time sharply and predictably advance in configured steps. -* From the **observer's (i.e. your personal) point of view,** +* From the **observer's (i.e., your personal) 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 + timeouts, and "later" callbacks, happen in near-zero amount of the real time (due to the usual code execution overhead). This speeds up the execution of tests 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, +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 `_, @@ -37,11 +37,11 @@ Why? ==== Without ``looptime``, the event loops use ``time.monotonic()`` for the time, -which also captures the code overhead and the network latencies, adding little +which also captures the code overhead and the network latencies, adding small 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. +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.) @@ -50,11 +50,11 @@ 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 +It is difficult to test complex asynchronous coroutines with 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 +* 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 schedules events, injects data, etc. @@ -63,7 +63,7 @@ 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 +However, in real-world cases, tests often must verify that the coroutine stops at some point, waits for a condition for some limited time, and then passes or fails. @@ -74,11 +74,11 @@ 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-coded in the test. -Not only it makes the whole test-suite slower, it also brings the execution -time close to the values where the code overhead or measurement errors affect -the timing, which makes it difficult to assert on the coroutine's pure time. +Another (straightforward) approach is not to mock the low-level routines, but +to spend real-world time, just in short bursts as hard-coded in the test. +Not only does it make the whole test-suite slower, it also brings the execution +time close to values where code overhead or measurement errors affect +timing, which makes it difficult to assert on the coroutine's pure time. Solution @@ -88,9 +88,9 @@ 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). +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. +``looptime`` goes deeper and mocks the very foundation of it all — 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). +requests the actual true-time sleep from the underlying selectors (I/O sockets). diff --git a/docs/nuances.rst b/docs/nuances.rst index a6ae12f..1889c08 100644 --- a/docs/nuances.rst +++ b/docs/nuances.rst @@ -20,19 +20,19 @@ Consider this test: async with async_timeout.timeout(9): await asyncio.sleep(1) -Normally, it should not fail. However, with fake time (without workarounds) +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 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`` +* ``async_timeout`` library sets its delayed timer at 9 seconds from now. +* The event loop notices that there is only one timer at T0+9s. +* The event loop fast-forwards time to ``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. +tasks, and handles a fair chance to be entered, spawned, and scheduled. This is why the example works as intended. The ``noop_cycles`` (``int``) setting is how many cycles the event loop makes. @@ -66,20 +66,20 @@ Consider this test: 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: +With true time, this test will finish in a fraction of a second. +However, with fake time (with no workarounds), the following happens: -* A new synchronous event is created, it is unset by default. +* 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, +* ``looptime`` performs its no-op cycles, letting all coroutines 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, +* 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 @@ -90,27 +90,26 @@ 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. +in small steps and also does a 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 +Luckily for this case, in one or two such steps, the executor's thread will +do its job, and the event will be set, as will the synchronous and asynchronous futures of the executor. The latter one (the async future) will also let the ``await`` 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 some synchronous tasks are running in the thread pools. +time step when fast-forwarding the time if executors are used — +i.e., if some synchronous tasks are 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 negative side effect: thread spawning can be potentially much faster, +e.g., finish in 0.001 second; but it will be rounded to 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. +A trade-off: a smaller step will get results faster but will spend more CPU power on resultless cycles. I/O idle @@ -131,35 +130,35 @@ Consider this test: 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, +With no workarounds, the test will hang forever waiting for I/O to happen. +This mostly happens when the only thing left in the event loop is I/O; all internal scheduled callbacks are gone. ``looptime`` can artificially 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. +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 I/O, i.e., it waits forever. +The default is ``1.0`` second. 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 +will ever happen, 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. +in the true-time timeline, while the end-of-time is in the fake-time timeline. +Besides, once I/O happens, the idle timeout is reset, while the end-of-time +can still 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. +The ``idle_step`` (``float`` or ``None``) setting synchronizes the flow +of the fake-time with the flow of the true-time while waiting for 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 +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``, @@ -173,7 +172,7 @@ 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 `_ +e.g., `async-timeout `_ or native ``asyncio.wait_for(…, timeout=…)``. First, the mentioned approaches can be applied to arbitrary code blocks, @@ -182,7 +181,7 @@ 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-forward the loop time when the loops +i.e., it does not instantly fast-forward the loop time when the loop 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. @@ -218,7 +217,7 @@ If the async timeout is reached, further code can proceed normally. Time resolution =============== -Python (so as many other languages) has issues with calculating the floats: +Python (as well as many other languages) has issues with calculating floats: .. code-block:: python @@ -231,16 +230,16 @@ Python (so as many other languages) has issues with calculating the floats: >>> 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 +This can break assertions on time and durations. To work around +the issue, ``looptime`` internally performs all time math in integers. +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 +The default is 1 microsecond, i.e., ``0.000001`` (``1e-6``), which is good enough +for typical unit-tests while keeping 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. @@ -248,7 +247,7 @@ Normally, you should not worry about it or configure it. .. note:: A side-note: in fact, the reciprocal (1/x) of the resolution is used. - For example, with the resolution 0.001, the time + For example, with a resolution of 0.001, the time 1.0 (float) becomes 1000 (int), 0.1 (float) becomes 100 (int), 0.01 (float) becomes 10 (int), @@ -260,10 +259,10 @@ Time magic coverage =================== The time compaction magic is enabled only for the duration of the test, -i.e. the test function — but not the fixtures. -The fixtures run in the real (wall-clock) time. +i.e., the test function — but not the fixtures. +Fixtures run in real (wall-clock) time. -The options (including the force starting time) are applied at the test function +Options (including the forced starting time) are applied at the test function's starting moment, not when it is setting up the fixtures (even function-scoped). This is caused by a new concept of multiple co-existing event loops @@ -271,10 +270,10 @@ in pytest-asyncio>=1.0.0: - It is unclear which options to apply to higher-scoped fixtures used by many tests, which themselves use higher-scoped event loops — - especially in selective partial runs. Technically, it is the 1st test, - with the options of 2nd and further tests simply ignored. + especially in selective partial runs. Technically, it is the first test, + with the options of the second and subsequent tests simply ignored. - It is impossible to guess which event loop will be the running loop - in the test until we reach the test itself, i.e. we do not know this + in the test until we reach the test itself, i.e., we do not know this when setting up the fixtures, even function-scoped fixtures. - There is no way to cover the fixture teardown (no hook in pytest), only for the fixture setup and post-teardown cleanup. @@ -282,22 +281,22 @@ in pytest-asyncio>=1.0.0: As such, this functionality (covering of function-scoped fixtures) was abandoned — since it was never promised, tested, or documented — plus an assumption that it was never used by anyone (it should not be). -It was rather a side effect of the previous implemention, +It was rather a side effect of the previous implementation, which is not available or possible anymore. pytest-asyncio>=1.0.0 ===================== -As it is said above, pytest-asyncio>=1.0.0 introduced several co-existing -event loops of different scopes. The time compaction in these event loops +As mentioned above, pytest-asyncio>=1.0.0 introduced several co-existing +event loops of different scopes. Time compaction in these event loops is NOT activated. Only the running loop of the test function is activated. Configuring and activating multiple co-existing event loops brings a few -conceptual challenges, which require a good sample case to look into, +conceptual challenges, which require a good sample case to look into and some time to think. -Would you need time compaction in your fixtures of higher scopes, +Should you need time compaction in your fixtures of higher scopes, do it explicitly: .. code-block:: python diff --git a/docs/tools.rst b/docs/tools.rst index 02af917..8a004cc 100644 --- a/docs/tools.rst +++ b/docs/tools.rst @@ -50,7 +50,7 @@ the provided context manager class with the event loop's clock: Assertions ========== -The ``looptime`` **fixture** is syntax sugar for easy loop time assertions: +The ``looptime`` **fixture** is syntactic sugar for easy loop time assertions: .. code-block:: python @@ -63,16 +63,16 @@ The ``looptime`` **fixture** is syntax sugar for easy loop time assertions: 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, subtracting, 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 +Technically, it is a proxy object for ``asyncio.get_running_loop().time()``. +The proxy object supports direct comparison with numbers (integers/floats), +as well as some basic arithmetic (addition, subtraction, multiplication, etc.). +However, it adjusts to a time precision of 1 nanosecond (1e-9): every digit +beyond that precision is ignored — so you do not need to be 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 that is bound to a specific -event loop (it works for loops both with fake- and real-world time): +event loop (it works for loops with both fake and real-world time): .. code-block:: python @@ -84,10 +84,10 @@ event loop (it works for loops both with fake- and real-world time): 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. +Keep in mind that it is not the same as :class:`Chronometer` 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. +the loop time also includes the time of all fixture setups. Custom event loops @@ -141,7 +141,7 @@ Only selector-based event loops are supported: the event loop must rely on 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): +the same way as ``looptime`` does (via some dirty hackery): For ``pytest-asyncio<1.0.0``: @@ -177,7 +177,7 @@ For ``pytest-asyncio>=1.0.0``: return LooptimeEventLoopPolicy() ``looptime.make_event_loop_class(cls)`` constructs a new class that inherits -from the referenced class and the specialised event loop class mentioned above. +from the referenced class and the specialized 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 From 538d90c182b2a3d82f56edc485cc6f8c04fdff7a Mon Sep 17 00:00:00 2001 From: Sergey Vasilyev Date: Sun, 28 Dec 2025 18:22:02 +0100 Subject: [PATCH 5/5] Restructure & rewrite the docs for clarity Signed-off-by: Sergey Vasilyev --- docs/configuration.rst | 16 ++++++++- docs/introduction.rst | 2 +- docs/nuances.rst | 57 ++++++++++++++++++++---------- docs/tools.rst | 26 ++++++++------ looptime/_internal/chronometers.py | 18 ++++++---- looptime/_internal/loops.py | 8 ++++- looptime/_internal/patchers.py | 34 ++++++++++++++++++ looptime/_internal/timeproxies.py | 2 ++ 8 files changed, 125 insertions(+), 38 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index 1461bc2..2d5e16c 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -88,7 +88,7 @@ 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: +If it is a callable, it is invoked once per test 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. @@ -134,3 +134,17 @@ e.g., ``end=lambda: time.monotonic() + 10``. The end of time is not the same as timeouts — see :doc:`nuances` on differences with ``async-timeout``. + + +Advanced settings +----------------- + +A few more settings are considered advanced and documented in :doc:`nuances`. +They cover very nuanced aspects of the time flow, mainly synchronising across +two timelines: fake time & real time, sync & async. +You normally should not use them and the defaults should be fine. + +- ``noop_cycles`` +- ``idle_step`` +- ``idle_timeout`` +- ``resolution`` diff --git a/docs/introduction.rst b/docs/introduction.rst index b064a5e..cf1b0cd 100644 --- a/docs/introduction.rst +++ b/docs/introduction.rst @@ -38,7 +38,7 @@ Why? Without ``looptime``, the event loops use ``time.monotonic()`` for the time, which also captures the code overhead and the network latencies, adding small -random fluctuations to the time measurements (approx. 0.01-0.001 seconds). +random fluctuations to the time measurements (approx. 0.1-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. diff --git a/docs/nuances.rst b/docs/nuances.rst index 1889c08..8204241 100644 --- a/docs/nuances.rst +++ b/docs/nuances.rst @@ -2,8 +2,8 @@ Nuances ======= -Preliminary execution -===================== +Premature finalization +====================== Consider this test: @@ -21,7 +21,7 @@ Consider this test: await asyncio.sleep(1) Normally, it should not fail. However, with fake time (without workarounds), -the following scenario is possible: +the following step-by-step scenario is possible: * ``async_timeout`` library sets its delayed timer at 9 seconds from now. * The event loop notices that there is only one timer at T0+9s. @@ -36,13 +36,13 @@ tasks, and handles a fair chance to be entered, spawned, and 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, … +The default is ``42``. Why 42? Well, why not, indeed. -Slow executors -============== +Sync-async synchronization +========================== -Consider this test: +Consider this test, which mixes sync & async activities & primitives: .. code-block:: python @@ -112,10 +112,10 @@ of steps with no fractions: e.g., 0.01 or 0.02 seconds in this example. A trade-off: a smaller step will get results faster but will spend more CPU power on resultless cycles. -I/O idle -======== +Idle I/O activities +=================== -Consider this test: +Consider this test, which does the external I/O communication: .. code-block:: python @@ -145,7 +145,8 @@ The default is ``1.0`` second. If nothing happens within this time, the event loop assumes that nothing will ever happen, so it is a good idea to cease its existence: it injects -``IdleTimeoutError`` (a subclass of ``asyncio.TimeoutError``) into all tasks. +:class:`looptime.IdleTimeoutError` (a subclass of :class:`asyncio.TimeoutError`) +into all currently running 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 in the fake-time timeline. @@ -214,8 +215,8 @@ If the async timeout is reached, further code can proceed normally. assert chronometer < 0.1 -Time resolution -=============== +Time resolution & floating point precision errors +================================================= Python (as well as many other languages) has issues with calculating floats: @@ -255,8 +256,8 @@ Normally, you should not worry about it or configure it. everything smaller than 0.001 becomes 0 and probably misbehaves. -Time magic coverage -=================== +Exclusion of fixture setup/teardown +=================================== The time compaction magic is enabled only for the duration of the test, i.e., the test function — but not the fixtures. @@ -284,13 +285,33 @@ plus an assumption that it was never used by anyone (it should not be). It was rather a side effect of the previous implementation, which is not available or possible anymore. +If the time magic is needed in fixtures, use the more explicit approach: + +.. code-block:: python + + import looptime + import pytest_async + + @pytest_async.fixture + def async_fixture_example(): + with looptime.enabled(): + # Execute some async time-based code, but compacted. + await asyncio.sleep(1) + + # Go to the test(s). + yield + + with looptime.enabled(): + # Execute some async time-based code, but compacted. + await asyncio.sleep(1) + pytest-asyncio>=1.0.0 ===================== -As mentioned above, pytest-asyncio>=1.0.0 introduced several co-existing -event loops of different scopes. Time compaction in these event loops -is NOT activated. Only the running loop of the test function is activated. +pytest-asyncio>=1.0.0 introduced several co-existing event loops +of different scopes. Time compaction in these event loops is NOT activated. +Only the running loop of the test function is activated. Configuring and activating multiple co-existing event loops brings a few conceptual challenges, which require a good sample case to look into diff --git a/docs/tools.rst b/docs/tools.rst index 8a004cc..88d21be 100644 --- a/docs/tools.rst +++ b/docs/tools.rst @@ -8,7 +8,7 @@ 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). +* :class:`looptime.Chronometer` (a context manager class). * ``chronometer`` (a pytest fixture). It can be used as a sync or async context manager: @@ -71,8 +71,9 @@ beyond that precision is ignored — so you do not need to be 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 that is bound to a specific -event loop (it works for loops with both fake and real-world time): +The proxy object can be used to create a new proxy that is bound +to a specific event loop with the ``@`` operation +(it works for loops with both fake and real-world time): .. code-block:: python @@ -90,13 +91,13 @@ 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 fixture setups. -Custom event loops -================== +Custom event loops & mixins +=========================== 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. -For ``pytest-asyncio<1.0.0``: +For ``pytest-asyncio<1.0.0``, use the ``event_loop`` fixture: .. code-block:: python @@ -113,7 +114,7 @@ For ``pytest-asyncio<1.0.0``: def event_loop(): return LooptimeCustomEventLoop() -For ``pytest-asyncio>=1.0.0``: +For ``pytest-asyncio>=1.0.0``, use the ``event_loop_policy``: .. code-block:: python @@ -138,12 +139,13 @@ For ``pytest-asyncio>=1.0.0``: 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. +Everything that inherits from ``asyncio.BaseEventLoop`` should work, +but a more generic ``asyncio.AbstractEventLoop`` might be a problem. You can also patch almost any event loop class or event loop object the same way as ``looptime`` does (via some dirty hackery): -For ``pytest-asyncio<1.0.0``: +For ``pytest-asyncio<1.0.0`` and the ``even_loop`` fixture: .. code-block:: python @@ -157,7 +159,7 @@ For ``pytest-asyncio<1.0.0``: loop = asyncio.new_event_loop() return looptime.patch_event_loop(loop) -For ``pytest-asyncio>=1.0.0``: +For ``pytest-asyncio>=1.0.0`` and the ``event_loop_policy`` fixture: .. code-block:: python @@ -182,7 +184,9 @@ 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): +(some restrictions apply to the derived class). + +In general, patching the existing event loop instance is done by this hack: .. code-block:: python diff --git a/looptime/_internal/chronometers.py b/looptime/_internal/chronometers.py index c980b19..b10356e 100644 --- a/looptime/_internal/chronometers.py +++ b/looptime/_internal/chronometers.py @@ -14,13 +14,18 @@ class Chronometer(math.Numeric): Usage: - with Chronometer() as chronometer: - do_something() - print(f"Executing for {chronometer.seconds}s already.") - do_something_else() + .. code-block:: python - print(f"Executed in {chronometer.seconds}s.") - assert chronometer.seconds < 5.0 + import time + + def test_chronometer(): + with Chronometer() as chronometer: + time.sleep(1.23) # do something slow + print(f"Executing for {chronometer.seconds}s already.") + time.sleep(2.34) # do something slow again + + print(f"Executed in {chronometer.seconds}s.") + assert chronometer.seconds < 5.0 # 3.57s or slightly more """ def __init__(self, clock: Callable[[], float] = time.perf_counter) -> None: @@ -35,6 +40,7 @@ def _value(self) -> float: @property def seconds(self) -> float | None: + """The elapsed time in seconds (fractional).""" if self._ts is None: return None elif self._te is None: diff --git a/looptime/_internal/loops.py b/looptime/_internal/loops.py index e99162f..7eb3e01 100644 --- a/looptime/_internal/loops.py +++ b/looptime/_internal/loops.py @@ -34,6 +34,9 @@ class IdleTimeoutError(asyncio.TimeoutError): class LoopTimeEventLoop(asyncio.BaseEventLoop): + """ + An event loop with time compaction. Either a class or a mixin. + """ # BaseEventLoop does not have "_selector" declared but uses it in _run_once(). _selector: selectors.BaseSelector @@ -134,12 +137,15 @@ def setup_looptime( @property def looptime_on(self) -> bool: + """ + Whether the time compaction is enabled at the moment. + """ return bool(self.__enabled) @contextlib.contextmanager def looptime_enabled(self) -> Iterator[None]: """ - Temporarily enable the time compaction, restore the normal mode on exit. + A context manager to temporarily enable the time compaction. """ if self.__enabled: raise RuntimeError('Looptime mode is already enabled. Entered twice? Avoid this!') diff --git a/looptime/_internal/patchers.py b/looptime/_internal/patchers.py index ccb81ea..e56fea6 100644 --- a/looptime/_internal/patchers.py +++ b/looptime/_internal/patchers.py @@ -9,6 +9,12 @@ def reset_caches() -> None: + """ + Purge all caches populated by the patching function of ``looptime``. + + The classes themselves are not destroyed, so if there are event loops + that were created before the caches are cleared, they will continue to work. + """ _class_cache.clear() @@ -17,6 +23,22 @@ def make_event_loop_class( *, prefix: str = 'Looptime', ) -> Type[loops.LoopTimeEventLoop]: + """ + Create a new looptime-enabled event loop class from the original class. + + Technically, it is equivalent to creating a new class that inherits + from the original class and :class:`looptime.LoopTimeEventLoop` as a mixin, + with no content (methods or fields) of its own: + + .. code-block:: python + + # Not the actual code, just the idea of what happens under the hood. + class NewEventLoop(loops.LoopTimeEventLoop, cls): + pass + + New classes are cached, so the same original class always produces the same + derived class, not a new one on every call. + """ if issubclass(cls, loops.LoopTimeEventLoop): return cls elif cls not in _class_cache: @@ -29,6 +51,15 @@ def patch_event_loop( loop: asyncio.BaseEventLoop, **kwargs: Any, ) -> loops.LoopTimeEventLoop: + """ + Patch an existing event loop to be looptime-ready. + + This operation is idempotent and can be safely called multiple times. + + Internally, it takes the existing class of the event loop and replaces it + with the new class, which is a mix of the original class and + :class:`looptime.LoopTimeEventLoop` as a mixin. The new classes are cached. + """ result: loops.LoopTimeEventLoop match loop: case loops.LoopTimeEventLoop(): @@ -42,4 +73,7 @@ def patch_event_loop( def new_event_loop(**kwargs: Any) -> loops.LoopTimeEventLoop: + """ + Create a new event loop as :func:`asyncio.new_event_loop`, but patched. + """ return patch_event_loop(cast(asyncio.BaseEventLoop, asyncio.new_event_loop()), **kwargs) diff --git a/looptime/_internal/timeproxies.py b/looptime/_internal/timeproxies.py index c5c000b..fef4f60 100644 --- a/looptime/_internal/timeproxies.py +++ b/looptime/_internal/timeproxies.py @@ -8,6 +8,8 @@ class LoopTimeProxy(math.Numeric): """ A numeric-compatible proxy to the time of the current/specific event loop. + + It is mainly represented by the ``looptime`` fixture in pytest. """ def __init__(