44# BSD License
55import logging
66import threading
7- import time
87import weakref
98from concurrent .futures import Future , TimeoutError
10- from itertools import islice
119from unittest .mock import Mock , patch
1210
1311import pytest
@@ -48,6 +46,18 @@ def shutdown_executor():
4846 return exe
4947
5048
49+ @pytest .fixture
50+ def executor0 ():
51+ """
52+ Provides a QThreadExecutor with max_workers=0 for deterministic testing.
53+ """
54+ executor = qasync .QThreadExecutor (max_workers = 0 )
55+ try :
56+ yield executor
57+ finally :
58+ executor .shutdown (wait = True , cancel_futures = False )
59+
60+
5161@pytest .mark .parametrize ("wait" , [True , False ])
5262def test_shutdown_after_shutdown (shutdown_executor , wait ):
5363 # it is safe to shutdown twice
@@ -125,16 +135,13 @@ def test_context(executor):
125135
126136
127137@pytest .mark .parametrize ("cancel" , [True , False ])
128- def test_shutdown_cancel_futures (cancel ):
138+ def test_shutdown_cancel_futures (executor0 , cancel ):
129139 """Test that shutdown with cancel_futures=True cancels all remaining futures in the queue."""
130140
131- # Create an executor with no workers so futures stay queued and never execute
132- executor = qasync .QThreadExecutor (max_workers = 0 )
133-
134- futures = [executor .submit (lambda : None ) for _ in range (10 )]
141+ futures = [executor0 .submit (lambda : None ) for _ in range (10 )]
135142
136143 # Shutdown with cancel_futures parameter
137- executor .shutdown (wait = False , cancel_futures = cancel )
144+ executor0 .shutdown (wait = False , cancel_futures = cancel )
138145
139146 if cancel :
140147 # All futures should be cancelled since no workers consumed them
@@ -149,8 +156,6 @@ def test_shutdown_cancel_futures(cancel):
149156 f"Expected no futures to be cancelled, got { cancelled_count } "
150157 )
151158
152- executor .shutdown (wait = True , cancel_futures = False )
153-
154159
155160def test_map (executor ):
156161 """Basic test of executor map functionality"""
@@ -161,86 +166,78 @@ def test_map(executor):
161166 assert results == [0 , 2 , 4 , 6 , 8 , 10 , 12 , 14 , 16 ]
162167
163168
164- def test_map_timeout (executor ):
165- """Test that map with timeout raises TimeoutError and cancels futures"""
166- results = []
169+ def test_map_timeout (executor0 ):
170+ """Test that map with timeout propagates the timeout parameter to future.result()"""
167171
168- def func (x ):
169- nonlocal results
170- time .sleep (0.05 )
171- results .append (x )
172- return x
172+ f = Mock (spec = Future )
173+ f .result = Mock (side_effect = TimeoutError ("Timeout" ))
174+ f .cancel = Mock (return_value = True )
173175
174- start = time .monotonic ()
175- with pytest .raises (TimeoutError ):
176- list (executor .map (func , range (10 ), timeout = 0.01 ))
177- duration = time .monotonic () - start
178- # this test is flaky on some platforms, so we give it a wide bearth.
179- assert duration < 0.1
176+ with patch .object (executor0 , "submit" , return_value = f ):
177+ with pytest .raises (TimeoutError , match = "Timeout" ):
178+ list (executor0 .map (lambda x : x , [1 ], timeout = 0.5 ))
180179
181- executor .shutdown (wait = True )
182- # only about half of the tasks should have completed
183- # because the max number of workers is 5 and the rest of
184- # the tasks were not started at the time of the cancel.
185- assert set (results ) != {0 , 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9 }
180+ # Verify the timeout parameter was passed to result() (not None)
181+ # Note: The timeout is calculated as (deadline - time.monotonic()), so it will be
182+ # slightly less than 0.5 due to the time taken to submit futures and start iteration
183+ assert f .result .called
184+ f_timeout = f .result .call_args [0 ][0 ] if f .result .call_args [0 ] else None
185+ assert f_timeout is not None
186+ assert f_timeout <= 0.5
186187
187188
188- def test_map_error (executor ):
189+ def test_map_error (executor0 ):
189190 """Test that map with an exception will raise, and remaining tasks are cancelled"""
190- results = []
191191
192- def func (x ):
193- nonlocal results
194- time .sleep (0.05 )
195- if len (results ) == 5 :
196- raise ValueError ("Test error" )
197- results .append (x )
198- return x
192+ # Create 3 futures: one success, one exception, one to be cancelled
193+ mock_futures = []
199194
200- with pytest .raises (ValueError ):
201- list (executor .map (func , range (15 )))
195+ # First future succeeds
196+ f0 = Mock (spec = Future )
197+ f0 .result = Mock (return_value = 0 )
198+ f0 .cancel = Mock (return_value = True )
199+ mock_futures .append (f0 )
202200
203- executor .shutdown (wait = True , cancel_futures = False )
204- assert len (results ) <= 10 , "Final 5 at least should have been cancelled"
201+ # Second future raises an exception
202+ f1 = Future ()
203+ f1 .set_exception (ValueError ("Test error" ))
204+ mock_futures .append (f1 )
205205
206+ # Third future should be cancelled
207+ f2 = Mock (spec = Future )
208+ f2 .result = Mock (return_value = 2 )
209+ f2 .cancel = Mock (return_value = True )
210+ mock_futures .append (f2 )
206211
207- @pytest .mark .parametrize ("cancel" , [True , False ])
208- def test_map_shutdown (executor , cancel ):
209- results = []
210-
211- def func (x ):
212- nonlocal results
213- time .sleep (0.05 )
214- results .append (x )
215- return x
216-
217- # Get the first few results.
218- # Keep the iterator alive so that it isn't closed when its reference is dropped.
219- m = executor .map (func , range (15 ))
220- values = list (islice (m , 5 ))
221- assert values == [0 , 1 , 2 , 3 , 4 ]
222-
223- executor .shutdown (wait = True , cancel_futures = cancel )
224- if cancel :
225- assert len (results ) < 15 , "Some tasks should have been cancelled"
226- else :
227- assert len (results ) == 15 , "All tasks should have been completed"
228- m .close ()
212+ with patch .object (executor0 , "submit" , side_effect = mock_futures ):
213+ with pytest .raises (ValueError , match = "Test error" ):
214+ list (executor0 .map (lambda x : x , range (3 )))
215+
216+ # Verify the third future was cancelled when the exception occurred
217+ assert f2 .cancel .called , "Future after exception should have been cancelled"
229218
230219
231- def test_map_start (executor ):
220+ def test_map_start (executor0 ):
232221 """Test that map starts tasks immediately, before iterating"""
233- e = threading .Event ()
234- m = executor .map (lambda x : (e .set (), x ), range (1 ))
235- e .wait (timeout = 0.1 )
236- assert list (m ) == [(None , 0 )]
237222
223+ # Mock future that returns immediately
224+ mock_future = Mock (spec = Future )
225+ mock_future .result = Mock (return_value = 0 )
226+ mock_future .cancel = Mock (return_value = True )
238227
239- def test_map_close ():
240- """Test that closing a running map cancels all remaining tasks."""
228+ with patch .object (executor0 , "submit" , return_value = mock_future ) as mock_submit :
229+ # Create the map - submit should be called immediately
230+ m = executor0 .map (lambda x : x , range (1 ))
241231
242- # Create an executor with no workers so we have full control
243- executor = qasync .QThreadExecutor (max_workers = 0 )
232+ # Verify submit was called before we start iterating
233+ mock_submit .assert_called_once ()
234+
235+ # Now iterate to verify the result
236+ assert list (m ) == [0 ]
237+
238+
239+ def test_map_close (executor0 ):
240+ """Test that closing a running map cancels all remaining tasks."""
244241
245242 # Create mock futures with proper result() method
246243 mock_futures = []
@@ -251,8 +248,8 @@ def test_map_close():
251248 mock_futures .append (mock_future )
252249
253250 # Mock submit to return our pre-created futures
254- with patch .object (executor , "submit" , side_effect = mock_futures ):
255- m = executor .map (lambda x : x , range (10 ))
251+ with patch .object (executor0 , "submit" , side_effect = mock_futures ):
252+ m = executor0 .map (lambda x : x , range (10 ))
256253 # must start the generator so that close() has any effect
257254 assert next (m ) == 0
258255 m .close ()
@@ -262,5 +259,3 @@ def test_map_close():
262259 # - The rest via the finally block when the generator is closed
263260 for i , f in enumerate (mock_futures ):
264261 assert f .cancel .called , f"Future { i } should have been cancelled"
265-
266- executor .shutdown (wait = True , cancel_futures = False )
0 commit comments