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/.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/.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..2d5e16c --- /dev/null +++ b/docs/configuration.rst @@ -0,0 +1,150 @@ +============= +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 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 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. + +``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 second, third, and subsequent tests. + +.. note:: + pytest-asyncio 1.0.0+ introduced event loops with higher scopes, + 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 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: code durations can become negative, or 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 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``. + +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/getting-started.rst b/docs/getting-started.rst new file mode 100644 index 0000000..af427cb --- /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 are 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..cf1b0cd --- /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, 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 advance 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, 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, +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 small +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. +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 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 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. + +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 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 +======== + +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 — 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..8204241 --- /dev/null +++ b/docs/nuances.rst @@ -0,0 +1,336 @@ +======= +Nuances +======= + +Premature finalization +====================== + +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 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. +* 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 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, why not, indeed. + + +Sync-async synchronization +========================== + +Consider this test, which mixes sync & async activities & primitives: + +.. 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 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 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 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 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 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 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: 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: a smaller step will get results faster but will spend more CPU power on resultless cycles. + + +Idle I/O activities +=================== + +Consider this test, which does the external I/O communication: + +.. 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 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 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 ever happen, so it is a good idea to cease its existence: it injects +: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. +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 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 +(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 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. + +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 & floating point precision errors +================================================= + +Python (as well as many other languages) has issues with calculating 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 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 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. + +.. note:: + + A side-note: in fact, the reciprocal (1/x) of the resolution is used. + 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), + 0.001 (float) becomes 1 (int); + everything smaller than 0.001 becomes 0 and probably misbehaves. + + +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. +Fixtures run in real (wall-clock) time. + +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 +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 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 + 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 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 +===================== + +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 +and some time to think. + +Should 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..88d21be --- /dev/null +++ b/docs/tools.rst @@ -0,0 +1,193 @@ +=============== +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: + +* :class:`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 syntactic 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 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 with the ``@`` operation +(it works for loops with both 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 + +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 fixture setups. + + +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``, use the ``event_loop`` fixture: + +.. 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``, use the ``event_loop_policy``: + +.. 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, +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`` and the ``even_loop`` fixture: + +.. 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`` and the ``event_loop_policy`` fixture: + +.. 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 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 +constructed one. For those who care, it is an equivalent of the following hack +(some restrictions apply to the derived class). + +In general, patching the existing event loop instance is done by this hack: + +.. code-block:: python + + loop.__class__ = looptime.make_event_loop_class(loop.__class__) 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 74% rename from looptime/chronometers.py rename to looptime/_internal/chronometers.py index 126bdd0..b10356e 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") @@ -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/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 98% rename from looptime/loops.py rename to looptime/_internal/loops.py index e99162f..7eb3e01 100644 --- a/looptime/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/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/_internal/patchers.py b/looptime/_internal/patchers.py new file mode 100644 index 0000000..e56fea6 --- /dev/null +++ b/looptime/_internal/patchers.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import asyncio +from typing import Any, Type, cast + +from . import loops + +_class_cache: dict[Type[asyncio.BaseEventLoop], Type[loops.LoopTimeEventLoop]] = {} + + +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() + + +def make_event_loop_class( + cls: Type[asyncio.BaseEventLoop], + *, + 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: + new_class = type(f'{prefix}{cls.__name__}', (loops.LoopTimeEventLoop, cls), {}) + _class_cache[cls] = new_class + return _class_cache[cls] + + +def patch_event_loop( + loop: asyncio.BaseEventLoop, + **kwargs: Any, +) -> loops.LoopTimeEventLoop: + """ + 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(): + return loop + case _: + new_class = make_event_loop_class(loop.__class__) + loop.__class__ = new_class + loop = cast(loops.LoopTimeEventLoop, loop) + loop.setup_looptime(**kwargs) + return loop + + +def new_event_loop(**kwargs: Any) -> loops.LoopTimeEventLoop: + """ + 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/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 90% rename from looptime/timeproxies.py rename to looptime/_internal/timeproxies.py index 1203006..fef4f60 100644 --- a/looptime/timeproxies.py +++ b/looptime/_internal/timeproxies.py @@ -2,12 +2,14 @@ import asyncio -from looptime import math +from . import math 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__( diff --git a/looptime/patchers.py b/looptime/patchers.py deleted file mode 100644 index 753a4e6..0000000 --- a/looptime/patchers.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations - -import asyncio -from typing import Any, Type, cast - -from looptime import loops - -_class_cache: dict[Type[asyncio.BaseEventLoop], Type[loops.LoopTimeEventLoop]] = {} - - -def reset_caches() -> None: - _class_cache.clear() - - -def make_event_loop_class( - cls: Type[asyncio.BaseEventLoop], - *, - prefix: str = 'Looptime', -) -> Type[loops.LoopTimeEventLoop]: - if issubclass(cls, loops.LoopTimeEventLoop): - return cls - elif cls not in _class_cache: - new_class = type(f'{prefix}{cls.__name__}', (loops.LoopTimeEventLoop, cls), {}) - _class_cache[cls] = new_class - return _class_cache[cls] - - -def patch_event_loop( - loop: asyncio.BaseEventLoop, - **kwargs: Any, -) -> loops.LoopTimeEventLoop: - result: loops.LoopTimeEventLoop - match loop: - case loops.LoopTimeEventLoop(): - return loop - case _: - new_class = make_event_loop_class(loop.__class__) - loop.__class__ = new_class - loop = cast(loops.LoopTimeEventLoop, loop) - loop.setup_looptime(**kwargs) - return loop - - -def new_event_loop(**kwargs: Any) -> loops.LoopTimeEventLoop: - return patch_event_loop(cast(asyncio.BaseEventLoop, asyncio.new_event_loop()), **kwargs) diff --git a/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..982c341 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,11 +41,16 @@ 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] +docs = [ + "sphinx>=9.0.0", + "sphinx-llm>=0.1.4", + "furo", +] dev = [ "async-timeout", "codecov", @@ -65,3 +70,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