Skip to content

Commit c1a512b

Browse files
committed
Restructure and rewrite the docs for clarity
Signed-off-by: Sergey Vasilyev <nolar@nolar.info>
1 parent 2535564 commit c1a512b

8 files changed

Lines changed: 125 additions & 38 deletions

File tree

docs/configuration.rst

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ The time zero
8888
``start`` (``float`` or ``None``, or a no-argument callable that returns the same)
8989
is the initial time of the event loop.
9090

91-
If it is a callable, it is invoked once per event loop to get the value:
91+
If it is a callable, it is invoked once per test to get the value:
9292
e.g., ``start=time.monotonic`` to align with the true time,
9393
or ``start=lambda: random.random() * 100`` to add some unpredictability.
9494

@@ -134,3 +134,17 @@ e.g., ``end=lambda: time.monotonic() + 10``.
134134

135135
The end of time is not the same as timeouts — see :doc:`nuances`
136136
on differences with ``async-timeout``.
137+
138+
139+
Advanced settings
140+
-----------------
141+
142+
A few more settings are considered advanced and documented in :doc:`nuances`.
143+
They cover very nuanced aspects of the time flow, mainly synchronising across
144+
two timelines: fake time & real time, sync & async.
145+
You normally should not use them and the defaults should be fine.
146+
147+
- ``noop_cycles``
148+
- ``idle_step``
149+
- ``idle_timeout``
150+
- ``resolution``

docs/introduction.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ Why?
3838

3939
Without ``looptime``, the event loops use ``time.monotonic()`` for the time,
4040
which also captures the code overhead and the network latencies, adding small
41-
random fluctuations to the time measurements (approx. 0.01-0.001 seconds).
41+
random fluctuations to the time measurements (approx. 0.1-0.01-0.001 seconds).
4242

4343
Without ``looptime``, the event loops spend the real wall-clock time
4444
when there is no I/O happening but some callbacks are scheduled for later.

docs/nuances.rst

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
Nuances
33
=======
44

5-
Preliminary execution
6-
=====================
5+
Premature finalization
6+
======================
77

88
Consider this test:
99

@@ -21,7 +21,7 @@ Consider this test:
2121
await asyncio.sleep(1)
2222
2323
Normally, it should not fail. However, with fake time (without workarounds),
24-
the following scenario is possible:
24+
the following step-by-step scenario is possible:
2525

2626
* ``async_timeout`` library sets its delayed timer at 9 seconds from now.
2727
* 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.
3636
This is why the example works as intended.
3737

3838
The ``noop_cycles`` (``int``) setting is how many cycles the event loop makes.
39-
The default is ``42``. Why 42? Well,
39+
The default is ``42``. Why 42? Well, why not, indeed.
4040

4141

42-
Slow executors
43-
==============
42+
Sync-async synchronization
43+
==========================
4444

45-
Consider this test:
45+
Consider this test, which mixes sync & async activities & primitives:
4646

4747
.. code-block:: python
4848
@@ -112,10 +112,10 @@ of steps with no fractions: e.g., 0.01 or 0.02 seconds in this example.
112112
A trade-off: a smaller step will get results faster but will spend more CPU power on resultless cycles.
113113

114114

115-
I/O idle
116-
========
115+
Idle I/O activities
116+
===================
117117

118-
Consider this test:
118+
Consider this test, which does the external I/O communication:
119119

120120
.. code-block:: python
121121
@@ -145,7 +145,8 @@ The default is ``1.0`` second.
145145

146146
If nothing happens within this time, the event loop assumes that nothing
147147
will ever happen, so it is a good idea to cease its existence: it injects
148-
``IdleTimeoutError`` (a subclass of ``asyncio.TimeoutError``) into all tasks.
148+
:class:`looptime.IdleTimeoutError` (a subclass of :class:`asyncio.TimeoutError`)
149+
into all currently running tasks.
149150

150151
This is similar to how the end-of-time behaves, except that it is measured
151152
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.
214215
assert chronometer < 0.1
215216
216217
217-
Time resolution
218-
===============
218+
Time resolution & floating point precision errors
219+
=================================================
219220

220221
Python (as well as many other languages) has issues with calculating floats:
221222

@@ -255,8 +256,8 @@ Normally, you should not worry about it or configure it.
255256
everything smaller than 0.001 becomes 0 and probably misbehaves.
256257

257258

258-
Time magic coverage
259-
===================
259+
Exclusion of fixture setup/teardown
260+
===================================
260261

261262
The time compaction magic is enabled only for the duration of the test,
262263
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).
284285
It was rather a side effect of the previous implementation,
285286
which is not available or possible anymore.
286287

288+
If the time magic is needed in fixtures, use the more explicit approach:
289+
290+
.. code-block:: python
291+
292+
import looptime
293+
import pytest_async
294+
295+
@pytest_async.fixture
296+
def async_fixture_example():
297+
with looptime.enabled():
298+
# Execute some async time-based code, but compacted.
299+
await asyncio.sleep(1)
300+
301+
# Go to the test(s).
302+
yield
303+
304+
with looptime.enabled():
305+
# Execute some async time-based code, but compacted.
306+
await asyncio.sleep(1)
307+
287308
288309
pytest-asyncio>=1.0.0
289310
=====================
290311

291-
As mentioned above, pytest-asyncio>=1.0.0 introduced several co-existing
292-
event loops of different scopes. Time compaction in these event loops
293-
is NOT activated. Only the running loop of the test function is activated.
312+
pytest-asyncio>=1.0.0 introduced several co-existing event loops
313+
of different scopes. Time compaction in these event loops is NOT activated.
314+
Only the running loop of the test function is activated.
294315

295316
Configuring and activating multiple co-existing event loops brings a few
296317
conceptual challenges, which require a good sample case to look into

docs/tools.rst

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Chronometers
88
For convenience, the library also provides a class and a fixture
99
to measure the duration of arbitrary code blocks in real-world time:
1010

11-
* ``looptime.Chronometer`` (a context manager class).
11+
* :class:`looptime.Chronometer` (a context manager class).
1212
* ``chronometer`` (a pytest fixture).
1313

1414
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
7171
``123.456/1.2`` suddenly becoming ``102.88000000000001`` and not equal to ``102.88``
7272
(as long as the time proxy object is used and not converted to a native float).
7373

74-
The proxy object can be used to create a new proxy that is bound to a specific
75-
event loop (it works for loops with both fake and real-world time):
74+
The proxy object can be used to create a new proxy that is bound
75+
to a specific event loop with the ``@`` operation
76+
(it works for loops with both fake and real-world time):
7677

7778
.. code-block:: python
7879
@@ -90,13 +91,13 @@ the loop time can start at a non-zero point; even if it starts at zero,
9091
the loop time also includes the time of all fixture setups.
9192

9293

93-
Custom event loops
94-
==================
94+
Custom event loops & mixins
95+
===========================
9596

9697
Do you use a custom event loop? No problem! Create a test-specific descendant
9798
with the provided mixin — and it will work the same as the default event loop.
9899

99-
For ``pytest-asyncio<1.0.0``:
100+
For ``pytest-asyncio<1.0.0``, use the ``event_loop`` fixture:
100101

101102
.. code-block:: python
102103
@@ -113,7 +114,7 @@ For ``pytest-asyncio<1.0.0``:
113114
def event_loop():
114115
return LooptimeCustomEventLoop()
115116
116-
For ``pytest-asyncio>=1.0.0``:
117+
For ``pytest-asyncio>=1.0.0``, use the ``event_loop_policy``:
117118

118119
.. code-block:: python
119120
@@ -138,12 +139,13 @@ For ``pytest-asyncio>=1.0.0``:
138139
139140
Only selector-based event loops are supported: the event loop must rely on
140141
``self._selector.select(timeout)`` to sleep for ``timeout`` true-time seconds.
141-
Everything that inherits from ``asyncio.BaseEventLoop`` should work.
142+
Everything that inherits from ``asyncio.BaseEventLoop`` should work,
143+
but a more generic ``asyncio.AbstractEventLoop`` might be a problem.
142144

143145
You can also patch almost any event loop class or event loop object
144146
the same way as ``looptime`` does (via some dirty hackery):
145147

146-
For ``pytest-asyncio<1.0.0``:
148+
For ``pytest-asyncio<1.0.0`` and the ``even_loop`` fixture:
147149

148150
.. code-block:: python
149151
@@ -157,7 +159,7 @@ For ``pytest-asyncio<1.0.0``:
157159
loop = asyncio.new_event_loop()
158160
return looptime.patch_event_loop(loop)
159161
160-
For ``pytest-asyncio>=1.0.0``:
162+
For ``pytest-asyncio>=1.0.0`` and the ``event_loop_policy`` fixture:
161163

162164
.. code-block:: python
163165
@@ -182,7 +184,9 @@ The resulting classes are cached, so it can be safely called multiple times.
182184

183185
``looptime.patch_event_loop()`` replaces the event loop's class with the newly
184186
constructed one. For those who care, it is an equivalent of the following hack
185-
(some restrictions apply to the derived class):
187+
(some restrictions apply to the derived class).
188+
189+
In general, patching the existing event loop instance is done by this hack:
186190

187191
.. code-block:: python
188192

looptime/_internal/chronometers.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,18 @@ class Chronometer(math.Numeric):
1414
1515
Usage:
1616
17-
with Chronometer() as chronometer:
18-
do_something()
19-
print(f"Executing for {chronometer.seconds}s already.")
20-
do_something_else()
17+
.. code-block:: python
2118
22-
print(f"Executed in {chronometer.seconds}s.")
23-
assert chronometer.seconds < 5.0
19+
import time
20+
21+
def test_chronometer():
22+
with Chronometer() as chronometer:
23+
time.sleep(1.23) # do something slow
24+
print(f"Executing for {chronometer.seconds}s already.")
25+
time.sleep(2.34) # do something slow again
26+
27+
print(f"Executed in {chronometer.seconds}s.")
28+
assert chronometer.seconds < 5.0 # 3.57s or slightly more
2429
"""
2530

2631
def __init__(self, clock: Callable[[], float] = time.perf_counter) -> None:
@@ -35,6 +40,7 @@ def _value(self) -> float:
3540

3641
@property
3742
def seconds(self) -> float | None:
43+
"""The elapsed time in seconds (fractional)."""
3844
if self._ts is None:
3945
return None
4046
elif self._te is None:

looptime/_internal/loops.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ class IdleTimeoutError(asyncio.TimeoutError):
3434

3535

3636
class LoopTimeEventLoop(asyncio.BaseEventLoop):
37+
"""
38+
An event loop with time compaction. Either a class or a mixin.
39+
"""
3740

3841
# BaseEventLoop does not have "_selector" declared but uses it in _run_once().
3942
_selector: selectors.BaseSelector
@@ -134,12 +137,15 @@ def setup_looptime(
134137

135138
@property
136139
def looptime_on(self) -> bool:
140+
"""
141+
Whether the time compaction is enabled at the moment.
142+
"""
137143
return bool(self.__enabled)
138144

139145
@contextlib.contextmanager
140146
def looptime_enabled(self) -> Iterator[None]:
141147
"""
142-
Temporarily enable the time compaction, restore the normal mode on exit.
148+
A context manager to temporarily enable the time compaction.
143149
"""
144150
if self.__enabled:
145151
raise RuntimeError('Looptime mode is already enabled. Entered twice? Avoid this!')

looptime/_internal/patchers.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99

1010

1111
def reset_caches() -> None:
12+
"""
13+
Purge all caches populated by the patching function of ``looptime``.
14+
15+
The classes themselves are not destroyed, so if there are event loops
16+
that were created before the caches are cleared, they will continue to work.
17+
"""
1218
_class_cache.clear()
1319

1420

@@ -17,6 +23,22 @@ def make_event_loop_class(
1723
*,
1824
prefix: str = 'Looptime',
1925
) -> Type[loops.LoopTimeEventLoop]:
26+
"""
27+
Create a new looptime-enabled event loop class from the original class.
28+
29+
Technically, it is equivalent to creating a new class that inherits
30+
from the original class and :class:`looptime.LoopTimeEventLoop` as a mixin,
31+
with no content (methods or fields) of its own:
32+
33+
.. code-block:: python
34+
35+
# Not the actual code, just the idea of what happens under the hood.
36+
class NewEventLoop(loops.LoopTimeEventLoop, cls):
37+
pass
38+
39+
New classes are cached, so the same original class always produces the same
40+
derived class, not a new one on every call.
41+
"""
2042
if issubclass(cls, loops.LoopTimeEventLoop):
2143
return cls
2244
elif cls not in _class_cache:
@@ -29,6 +51,15 @@ def patch_event_loop(
2951
loop: asyncio.BaseEventLoop,
3052
**kwargs: Any,
3153
) -> loops.LoopTimeEventLoop:
54+
"""
55+
Patch an existing event loop to be looptime-ready.
56+
57+
This operation is idempotent and can be safely called multiple times.
58+
59+
Internally, it takes the existing class of the event loop and replaces it
60+
with the new class, which is a mix of the original class and
61+
:class:`looptime.LoopTimeEventLoop` as a mixin. The new classes are cached.
62+
"""
3263
result: loops.LoopTimeEventLoop
3364
match loop:
3465
case loops.LoopTimeEventLoop():
@@ -42,4 +73,7 @@ def patch_event_loop(
4273

4374

4475
def new_event_loop(**kwargs: Any) -> loops.LoopTimeEventLoop:
76+
"""
77+
Create a new event loop as :func:`asyncio.new_event_loop`, but patched.
78+
"""
4579
return patch_event_loop(cast(asyncio.BaseEventLoop, asyncio.new_event_loop()), **kwargs)

looptime/_internal/timeproxies.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
class LoopTimeProxy(math.Numeric):
99
"""
1010
A numeric-compatible proxy to the time of the current/specific event loop.
11+
12+
It is mainly represented by the ``looptime`` fixture in pytest.
1113
"""
1214

1315
def __init__(

0 commit comments

Comments
 (0)