Skip to content

Commit 9371123

Browse files
committed
Make the timing tests independent of sleep accuracy
The timeout tests counted items against real sleeps and left 10 to 40 ms of slack. A sleep only promises to take at least as long as requested, so the counts changed on a busy machine and on a coarse clock: - Blocking sleeps that overshoot by 40 ms or more made timeout_generator yield one item fewer, in five test cases and in its doctest. - A 15.6 ms event loop clock resolution, the Windows default, let the 0.05 s timeout fire together with a 0.04 s sleep, so the detector tests stopped at 3 instead of 4. The sync tests and the doctest now run on a fake clock that only moves when it is slept on, and they check the requested sleeps as well. The total timeout tests advance the same clock. The per-item timeout tests yield without waiting and then stall for 10 s against a 0.05 s timeout. One test stays on the real clock and only checks what holds for any sleep accuracy. The fixtures are loaded from a conftest.py in the repository root so the doctests can use them, and the sdist ships that file.
1 parent 334a829 commit 9371123

4 files changed

Lines changed: 152 additions & 45 deletions

File tree

‎_python_utils_tests/clock.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""A fake clock for the tests and doctests of ``python_utils.time``."""
2+
3+
import typing
4+
5+
import pytest
6+
7+
import python_utils.time
8+
9+
#: The doctest that sleeps on the clock, by the name pytest gives it.
10+
TIMEOUT_GENERATOR_DOCTEST: str = 'python_utils.time.timeout_generator'
11+
12+
13+
class FakeClock:
14+
"""
15+
A clock that only moves when something sleeps on it.
16+
17+
``time.sleep`` promises to sleep at least as long as requested. A busy
18+
machine sleeps tens of milliseconds longer, and that changes how many
19+
items ``timeout_generator`` yields before its timeout. On this clock the
20+
number of items depends on the arguments alone.
21+
22+
Attributes:
23+
now (float): The current time in seconds.
24+
sleeps (list[float]): Every requested sleep, in order.
25+
"""
26+
27+
def __init__(self) -> None:
28+
"""Start at zero without any recorded sleeps."""
29+
self.now: float = 0.0
30+
self.sleeps: list[float] = []
31+
32+
def perf_counter(self) -> float:
33+
"""Return the current time, like ``time.perf_counter``."""
34+
return self.now
35+
36+
def sleep(self, seconds: float) -> None:
37+
"""Record the sleep and move the clock forward, without waiting."""
38+
self.sleeps.append(seconds)
39+
self.now += seconds
40+
41+
42+
@pytest.fixture
43+
def fake_clock(monkeypatch: pytest.MonkeyPatch) -> FakeClock:
44+
"""Replace the ``time`` module inside ``python_utils.time``."""
45+
clock: FakeClock = FakeClock()
46+
monkeypatch.setattr(python_utils.time, 'time', clock)
47+
return clock
48+
49+
50+
@pytest.fixture(autouse=True)
51+
def fake_clock_in_doctest(request: pytest.FixtureRequest) -> None:
52+
"""Run the ``timeout_generator`` doctest on the fake clock."""
53+
# pytest leaves `FixtureRequest.node` without a type.
54+
node: pytest.Item = typing.cast(pytest.Item, request.node)
55+
if node.name == TIMEOUT_GENERATOR_DOCTEST:
56+
request.getfixturevalue('fake_clock')

‎_python_utils_tests/test_time.py‎

Lines changed: 87 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,18 @@
33
import asyncio
44
import datetime
55
import itertools
6+
import time
67

78
import pytest
89

910
import python_utils
11+
from _python_utils_tests import clock
1012
from python_utils import types
1113

14+
#: Far longer than every timeout in this module, so a generator that sleeps
15+
#: this long is always interrupted first.
16+
STALL: float = 10.0
17+
1218

1319
@pytest.mark.parametrize(
1420
'timeout,interval,interval_multiplier,maximum_interval,iterable,result',
@@ -46,23 +52,26 @@ async def test_aio_timeout_generator(
4652

4753

4854
@pytest.mark.parametrize(
49-
'timeout,interval,interval_multiplier,maximum_interval,iterable,result',
55+
'timeout,interval,interval_multiplier,maximum_interval,iterable,result,'
56+
'sleeps',
5057
[
51-
(0.1, 0.06, 0.5, 0.1, 'abc', 'c'),
52-
(0.1, 0.07, 0.5, 0.1, itertools.count, 2),
53-
(0.1, 0.07, 0.5, 0.1, itertools.count(), 2),
54-
(0.1, 0.06, 1.0, None, 'abc', 'c'),
58+
(0.1, 0.06, 0.5, 0.1, 'abc', 'c', [0.06, 0.03, 0.015]),
59+
(0.1, 0.07, 0.5, 0.1, itertools.count, 2, [0.07, 0.035]),
60+
(0.1, 0.07, 0.5, 0.1, itertools.count(), 2, [0.07, 0.035]),
61+
(0.1, 0.06, 1.0, None, 'abc', 'c', [0.06, 0.06]),
5562
(
5663
datetime.timedelta(seconds=0.1),
5764
datetime.timedelta(seconds=0.06),
5865
2.0,
5966
datetime.timedelta(seconds=0.1),
6067
itertools.count,
6168
2,
69+
[0.06, 0.1],
6270
),
6371
],
6472
)
6573
def test_timeout_generator(
74+
fake_clock: clock.FakeClock,
6675
timeout: float,
6776
interval: float,
6877
interval_multiplier: float,
@@ -73,8 +82,9 @@ def test_timeout_generator(
7382
types.Callable[..., types.Iterable[types.Any]],
7483
],
7584
result: int,
85+
sleeps: types.List[float],
7686
) -> None:
77-
"""Stop the sync generator near the configured timeout."""
87+
"""Stop the sync generator at the timeout and scale the interval."""
7888
i = None
7989
for i in python_utils.timeout_generator(
8090
timeout=timeout,
@@ -86,59 +96,97 @@ def test_timeout_generator(
8696
assert i is not None
8797

8898
assert i == result
99+
assert fake_clock.sleeps == pytest.approx(sleeps)
89100

90101

91-
@pytest.mark.asyncio
92-
async def test_aio_generator_timeout_detector() -> None:
93-
"""Raise or exit on per-item and total timeouts."""
94-
# Make pyright happy
95-
i = None
102+
def test_timeout_generator_real_clock() -> None:
103+
"""Keep yielding on the real clock until the timeout has passed."""
104+
timeout: float = 0.05
105+
interval: float = 0.01
106+
start: float = time.perf_counter()
107+
items: types.List[int] = list(
108+
python_utils.timeout_generator(timeout, interval, itertools.count())
109+
)
110+
elapsed: float = time.perf_counter() - start
111+
112+
# A sleep can take longer than requested but never shorter, so these
113+
# hold on any machine. The exact number of items does not.
114+
assert items == list(range(len(items)))
115+
assert len(items) <= timeout / interval + 2
116+
assert elapsed >= timeout
117+
118+
119+
async def stalling_generator() -> types.AsyncGenerator[int, None]:
120+
"""Yield 0-4 without waiting, then stall before the next item."""
121+
for i in range(10):
122+
if i == 5:
123+
await asyncio.sleep(STALL)
124+
yield i
125+
126+
127+
def ticking_generator(
128+
fake_clock: clock.FakeClock,
129+
) -> types.AsyncGenerator[int, None]:
130+
"""Yield 0-9 and let 0.1 seconds pass on the fake clock for each item."""
96131

97132
async def generator() -> types.AsyncGenerator[int, None]:
98-
"""Yield 0-9 with increasing sleeps between items."""
133+
"""Advance the fake clock before every item."""
99134
for i in range(10):
100-
await asyncio.sleep(i / 20.0)
135+
fake_clock.sleep(0.1)
101136
yield i
102137

138+
return generator()
139+
140+
141+
@pytest.mark.asyncio
142+
async def test_aio_generator_timeout_detector(
143+
fake_clock: clock.FakeClock,
144+
) -> None:
145+
"""Raise or exit on per-item and total timeouts."""
146+
# Make pyright happy
147+
i = None
148+
103149
detector = python_utils.aio_generator_timeout_detector
104150
# Test regular timeout with reraise
105151
with pytest.raises(asyncio.TimeoutError):
106-
async for i in detector(generator(), 0.25):
152+
async for i in detector(stalling_generator(), 0.05):
107153
pass
108154

109155
# Test regular timeout with clean exit
110-
async for i in detector(generator(), 0.25, on_timeout=None):
156+
async for i in detector(stalling_generator(), 0.05, on_timeout=None):
111157
pass
112158

113159
assert i == 4
114160

115161
# Test total timeout with reraise
116162
with pytest.raises(asyncio.TimeoutError):
117-
async for i in detector(generator(), total_timeout=0.5):
163+
async for i in detector(
164+
ticking_generator(fake_clock), total_timeout=0.45
165+
):
118166
pass
119167

120168
# Test total timeout with clean exit
121-
async for i in detector(generator(), total_timeout=0.5, on_timeout=None):
169+
async for i in detector(
170+
ticking_generator(fake_clock), total_timeout=0.45, on_timeout=None
171+
):
122172
pass
123173

124174
assert i == 4
125175

126176
# Test stop iteration
127-
async for i in detector(generator(), on_timeout=None):
177+
async for i in detector(ticking_generator(fake_clock), on_timeout=None):
128178
pass
129179

180+
assert i == 9
181+
130182

131183
@pytest.mark.asyncio
132184
async def test_aio_generator_timeout_detector_decorator_reraise() -> None:
133185
"""Reraise ``TimeoutError`` on a per-item timeout."""
134-
135186
# Test regular timeout with reraise
136-
@python_utils.aio_generator_timeout_detector_decorator(timeout=0.05)
137-
async def generator_timeout() -> types.AsyncGenerator[int, None]:
138-
"""Yield with increasing delays to trip the timeout."""
139-
for i in range(10):
140-
await asyncio.sleep(i / 100.0)
141-
yield i
187+
generator_timeout = python_utils.aio_generator_timeout_detector_decorator(
188+
timeout=0.05
189+
)(stalling_generator)
142190

143191
with pytest.raises(asyncio.TimeoutError):
144192
async for _ in generator_timeout():
@@ -152,14 +200,9 @@ async def test_aio_generator_timeout_detector_decorator_clean_exit() -> None:
152200
i = None
153201

154202
# Test regular timeout with clean exit
155-
@python_utils.aio_generator_timeout_detector_decorator(
203+
generator_clean = python_utils.aio_generator_timeout_detector_decorator(
156204
timeout=0.05, on_timeout=None
157-
)
158-
async def generator_clean() -> types.AsyncGenerator[int, None]:
159-
"""Yield with increasing delays to trip the timeout."""
160-
for i in range(10):
161-
await asyncio.sleep(i / 100.0)
162-
yield i
205+
)(stalling_generator)
163206

164207
async for i in generator_clean():
165208
pass
@@ -168,17 +211,16 @@ async def generator_clean() -> types.AsyncGenerator[int, None]:
168211

169212

170213
@pytest.mark.asyncio
171-
async def test_aio_generator_timeout_detector_decorator_reraise_total() -> (
172-
None
173-
):
214+
async def test_aio_generator_timeout_detector_decorator_reraise_total(
215+
fake_clock: clock.FakeClock,
216+
) -> None:
174217
"""Reraise ``TimeoutError`` on a total timeout."""
175218

176219
# Test total timeout with reraise
177-
@python_utils.aio_generator_timeout_detector_decorator(total_timeout=0.1)
220+
@python_utils.aio_generator_timeout_detector_decorator(total_timeout=0.45)
178221
async def generator_reraise() -> types.AsyncGenerator[int, None]:
179-
"""Yield with increasing delays to trip the timeout."""
180-
for i in range(10):
181-
await asyncio.sleep(i / 100.0)
222+
"""Let the fake clock pass the total timeout while yielding."""
223+
async for i in ticking_generator(fake_clock):
182224
yield i
183225

184226
with pytest.raises(asyncio.TimeoutError):
@@ -187,19 +229,20 @@ async def generator_reraise() -> types.AsyncGenerator[int, None]:
187229

188230

189231
@pytest.mark.asyncio
190-
async def test_aio_generator_timeout_detector_decorator_clean_total() -> None:
232+
async def test_aio_generator_timeout_detector_decorator_clean_total(
233+
fake_clock: clock.FakeClock,
234+
) -> None:
191235
"""Exit cleanly on total timeout when ``on_timeout`` is ``None``."""
192236
# Make pyright happy
193237
i = None
194238

195239
# Test total timeout with clean exit
196240
@python_utils.aio_generator_timeout_detector_decorator(
197-
total_timeout=0.1, on_timeout=None
241+
total_timeout=0.45, on_timeout=None
198242
)
199243
async def generator_clean_total() -> types.AsyncGenerator[int, None]:
200-
"""Yield with increasing delays to trip the timeout."""
201-
for i in range(10):
202-
await asyncio.sleep(i / 100.0)
244+
"""Let the fake clock pass the total timeout while yielding."""
245+
async for i in ticking_generator(fake_clock):
203246
yield i
204247

205248
async for i in generator_clean_total():

‎conftest.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""
2+
Load the shared fixtures for the tests and the doctests.
3+
4+
The fixtures live in the tests package. They are loaded from the repository
5+
root because the doctests in ``python_utils`` need them as well.
6+
"""
7+
8+
pytest_plugins: tuple[str, ...] = ('_python_utils_tests.clock',)

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ module-root = ''
88
module-name = 'python_utils'
99
# Keep the tests and tox config in the sdist (parity with the old MANIFEST.in)
1010
# so downstream packagers can build and test from the source distribution.
11-
source-include = ['_python_utils_tests/**/*.py', 'tox.ini']
11+
source-include = ['_python_utils_tests/**/*.py', 'conftest.py', 'tox.ini']
1212

1313
[project]
1414
name = 'python-utils'

0 commit comments

Comments
 (0)