33import asyncio
44import datetime
55import itertools
6+ import time
67
78import pytest
89
910import python_utils
11+ from _python_utils_tests import clock
1012from 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)
6573def 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
132184async 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 ():
0 commit comments