Skip to content

Commit 74b7379

Browse files
authored
Merge pull request #6227 from harvey0100/wait
Wait Module Preperation
2 parents 50bcca0 + bbc65fa commit 74b7379

File tree

5 files changed

+365
-12
lines changed

5 files changed

+365
-12
lines changed

avocado/utils/wait.py

Lines changed: 74 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
"""Utilities for waiting for conditions to be met.
2+
3+
This module provides utilities for polling functions until they return
4+
a truthy value or a timeout expires, useful for testing and development
5+
scenarios where you need to wait for system state changes.
6+
"""
7+
18
import logging
29
import time
310

@@ -6,18 +13,75 @@
613

714
# pylint: disable=R0913
815
def wait_for(func, timeout, first=0.0, step=1.0, text=None, args=None, kwargs=None):
9-
"""
10-
Wait until func() evaluates to True.
16+
"""Wait until a function returns a truthy value or timeout expires.
17+
18+
This function repeatedly calls a given function with optional arguments
19+
until it returns a truthy value (anything that evaluates to True in a
20+
boolean context) or until the specified timeout expires. It provides
21+
configurable delays before the first attempt and between subsequent
22+
attempts, making it useful for polling operations in testing and
23+
development scenarios.
24+
25+
The function uses time.monotonic() for reliable timeout calculation that
26+
is not affected by system clock adjustments. Note that the step sleep
27+
duration is not interrupted when timeout expires, so actual elapsed time
28+
may exceed the specified timeout by up to one step duration.
29+
30+
:param func: Callable to be executed repeatedly until it returns a truthy
31+
value. Can be any callable object (function, lambda, method,
32+
callable class instance).
33+
:type func: callable
34+
:param timeout: Maximum time in seconds to wait for func to return a
35+
truthy value. Must be a non-negative number. If timeout
36+
expires before func returns truthy, None is returned.
37+
:type timeout: float or int
38+
:param first: Time in seconds to sleep before the first attempt to call
39+
func. Useful when you know the condition won't be met
40+
immediately. Defaults to 0.0 (no initial delay).
41+
:type first: float or int
42+
:param step: Time in seconds to sleep between successive calls to func.
43+
The actual sleep happens after each failed attempt. Defaults
44+
to 1.0 second. Note that this sleep is not interrupted when
45+
timeout expires.
46+
:type step: float or int
47+
:param text: Optional debug message to log before each attempt. When
48+
provided, logs at DEBUG level with elapsed time since start.
49+
If None, no logging occurs. Useful for debugging wait
50+
operations.
51+
:type text: str or None
52+
:param args: Optional list or tuple of positional arguments to pass to
53+
func on each call. If None, defaults to empty list.
54+
:type args: list, tuple, or None
55+
:param kwargs: Optional dictionary of keyword arguments to pass to func on
56+
each call. If None, defaults to empty dict.
57+
:type kwargs: dict or None
58+
:return: The truthy return value from func if it succeeds within timeout,
59+
or None if timeout expires without func returning a truthy value.
60+
The actual return value from func is preserved (e.g., strings,
61+
numbers, lists, objects).
62+
:rtype: Any (return type of func) or None
63+
:raises: Any exception raised by func will be propagated to the caller.
64+
No exception handling is performed on func calls.
1165
12-
If func() evaluates to True before timeout expires, return the
13-
value of func(). Otherwise return None.
66+
Example::
1467
15-
:param timeout: Timeout in seconds
16-
:param first: Time to sleep before first attempt
17-
:param step: Time to sleep between attempts in seconds
18-
:param text: Text to print while waiting, for debug purposes
19-
:param args: Positional arguments to func
20-
:param kwargs: Keyword arguments to func
68+
>>> import os
69+
>>> # Wait for a file to exist
70+
>>> wait_for(lambda: os.path.exists("/tmp/myfile"), timeout=30, step=1)
71+
True
72+
>>> # Wait for a counter to reach threshold
73+
>>> counter = [0]
74+
>>> def check(): counter[0] += 1; return counter[0] >= 5
75+
>>> wait_for(check, timeout=10, step=0.5)
76+
True
77+
>>> # Wait with custom function and arguments
78+
>>> def check_value(expected, current):
79+
... return current >= expected
80+
>>> wait_for(check_value, timeout=5, step=0.1, args=[10, 15])
81+
True
82+
>>> # Wait with debug logging
83+
>>> wait_for(lambda: False, timeout=2, step=0.5, text="Waiting for condition")
84+
None
2185
"""
2286
args = args or []
2387
kwargs = kwargs or {}

selftests/check.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@
2727
"job-api-check-tmp-directory-exists": 1,
2828
"nrunner-interface": 90,
2929
"nrunner-requirement": 28,
30-
"unit": 874,
30+
"unit": 900,
3131
"jobs": 11,
32-
"functional-parallel": 342,
32+
"functional-parallel": 344,
3333
"functional-serial": 7,
3434
"optional-plugins": 0,
3535
"optional-plugins-golang": 2,

selftests/functional/utils/wait.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import os
2+
import threading
3+
import time
4+
import unittest
5+
6+
from avocado.utils import wait
7+
from selftests.utils import TestCaseTmpDir
8+
9+
10+
class WaitForFunctionalTest(TestCaseTmpDir):
11+
"""Functional tests for wait.wait_for with real-world scenarios."""
12+
13+
def test_condition_becomes_true(self):
14+
"""Test wait_for with condition that eventually becomes true (real I/O)."""
15+
filepath = os.path.join(self.tmpdir.name, "test_file.txt")
16+
17+
# Create file after a delay
18+
def create_file_delayed():
19+
time.sleep(0.3)
20+
with open(filepath, "w", encoding="utf-8") as f:
21+
f.write("test content")
22+
23+
# Start file creation in background
24+
thread = threading.Thread(target=create_file_delayed)
25+
thread.start()
26+
27+
# Wait for file to exist
28+
result = wait.wait_for(
29+
lambda: os.path.exists(filepath),
30+
timeout=2.0,
31+
step=0.1,
32+
text="Waiting for file to appear",
33+
)
34+
35+
thread.join()
36+
self.assertTrue(result)
37+
self.assertTrue(os.path.exists(filepath))
38+
39+
def test_timeout_when_condition_never_true(self):
40+
"""Test that wait_for respects timeout when condition never becomes true."""
41+
filepath = os.path.join(self.tmpdir.name, "nonexistent.txt")
42+
43+
# Wait for a file that will never be created
44+
start = time.time()
45+
result = wait.wait_for(lambda: os.path.exists(filepath), timeout=0.5, step=0.1)
46+
elapsed = time.time() - start
47+
48+
self.assertIsNone(result)
49+
self.assertGreaterEqual(elapsed, 0.5)
50+
self.assertLess(elapsed, 0.7)
51+
52+
53+
if __name__ == "__main__":
54+
unittest.main()

selftests/unit/utils/wait.py

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import time
2+
import unittest
3+
from unittest import mock
4+
5+
from avocado.utils import wait
6+
7+
8+
class WaitForTest(unittest.TestCase):
9+
"""Unit tests for wait.wait_for function."""
10+
11+
def test_basic_success_immediate(self):
12+
"""Test wait_for with function that succeeds immediately."""
13+
func = mock.Mock(return_value=True)
14+
result = wait.wait_for(func, timeout=5)
15+
self.assertTrue(result)
16+
func.assert_called_once()
17+
18+
def test_basic_success_with_value(self):
19+
"""Test wait_for returns the truthy value from function."""
20+
func = mock.Mock(return_value="success")
21+
result = wait.wait_for(func, timeout=5)
22+
self.assertEqual(result, "success")
23+
24+
def test_timeout_returns_none(self):
25+
"""Test wait_for returns None when timeout expires."""
26+
func = mock.Mock(return_value=False)
27+
start = time.time()
28+
result = wait.wait_for(func, timeout=0.5, step=0.1)
29+
elapsed = time.time() - start
30+
self.assertIsNone(result)
31+
self.assertGreaterEqual(elapsed, 0.5)
32+
self.assertLess(elapsed, 0.7) # Should not wait much longer than timeout
33+
34+
def test_function_eventually_succeeds(self):
35+
"""Test wait_for succeeds when function returns True after retries."""
36+
call_count = {"count": 0}
37+
38+
def func_on_third_call():
39+
call_count["count"] += 1
40+
return call_count["count"] >= 3
41+
42+
result = wait.wait_for(func_on_third_call, timeout=5, step=0.1)
43+
self.assertTrue(result)
44+
self.assertEqual(call_count["count"], 3)
45+
46+
def test_first_delay(self):
47+
"""Test wait_for respects first delay parameter."""
48+
func = mock.Mock(return_value=True)
49+
start = time.time()
50+
wait.wait_for(func, timeout=5, first=0.2)
51+
elapsed = time.time() - start
52+
self.assertGreaterEqual(elapsed, 0.2)
53+
54+
def test_step_interval(self):
55+
"""Test wait_for respects step interval between attempts."""
56+
func = mock.Mock(return_value=False)
57+
wait.wait_for(func, timeout=0.5, step=0.15, first=0.0)
58+
# Should make roughly 0.5/0.15 = 3-4 attempts
59+
call_count = func.call_count
60+
self.assertGreaterEqual(call_count, 3)
61+
self.assertLessEqual(call_count, 5)
62+
63+
def test_zero_timeout(self):
64+
"""Test wait_for with zero timeout."""
65+
func = mock.Mock(return_value=False)
66+
result = wait.wait_for(func, timeout=0)
67+
self.assertIsNone(result)
68+
69+
def test_with_positional_args(self):
70+
"""Test wait_for passes positional arguments to function."""
71+
func = mock.Mock(return_value=True)
72+
wait.wait_for(func, timeout=5, args=["arg1", "arg2", 3])
73+
func.assert_called_with("arg1", "arg2", 3)
74+
75+
def test_with_keyword_args(self):
76+
"""Test wait_for passes keyword arguments to function."""
77+
func = mock.Mock(return_value=True)
78+
wait.wait_for(func, timeout=5, kwargs={"key1": "value1", "key2": 42})
79+
func.assert_called_with(key1="value1", key2=42)
80+
81+
def test_with_both_args_and_kwargs(self):
82+
"""Test wait_for passes both positional and keyword arguments."""
83+
func = mock.Mock(return_value=True)
84+
wait.wait_for(
85+
func, timeout=5, args=["pos1", "pos2"], kwargs={"kw1": "val1", "kw2": 99}
86+
)
87+
func.assert_called_with("pos1", "pos2", kw1="val1", kw2=99)
88+
89+
def test_text_logging(self):
90+
"""Test wait_for logs debug messages when text is provided."""
91+
func = mock.Mock(return_value=False)
92+
with self.assertLogs("avocado.utils.wait", level="DEBUG") as log_context:
93+
wait.wait_for(func, timeout=0.3, step=0.1, text="Waiting for condition")
94+
# Should have logged at least once
95+
self.assertGreater(len(log_context.output), 0)
96+
self.assertIn("Waiting for condition", log_context.output[0])
97+
98+
def test_no_logging_without_text(self):
99+
"""Test wait_for does not log when text is None."""
100+
func = mock.Mock(return_value=False)
101+
with self.assertRaises(AssertionError):
102+
# Should not log anything
103+
with self.assertLogs("avocado.utils.wait", level="DEBUG"):
104+
wait.wait_for(func, timeout=0.2, step=0.1, text=None)
105+
106+
def test_returns_first_truthy_value(self):
107+
"""Test wait_for returns first truthy value encountered."""
108+
values = [False, 0, None, "", "found"]
109+
110+
def func_with_sequence():
111+
return values.pop(0)
112+
113+
result = wait.wait_for(func_with_sequence, timeout=5, step=0.05)
114+
self.assertEqual(result, "found")
115+
# Should have values left since it stopped early
116+
self.assertEqual(len(values), 0)
117+
118+
def test_function_returns_zero(self):
119+
"""Test wait_for treats zero as falsy and continues waiting."""
120+
func = mock.Mock(return_value=0)
121+
result = wait.wait_for(func, timeout=0.2, step=0.05)
122+
self.assertIsNone(result)
123+
self.assertGreater(func.call_count, 1)
124+
125+
def test_function_returns_empty_string(self):
126+
"""Test wait_for treats empty string as falsy."""
127+
func = mock.Mock(return_value="")
128+
result = wait.wait_for(func, timeout=0.2, step=0.05)
129+
self.assertIsNone(result)
130+
131+
def test_function_returns_list(self):
132+
"""Test wait_for can return list when function returns truthy list."""
133+
func = mock.Mock(return_value=["item1", "item2"])
134+
result = wait.wait_for(func, timeout=5)
135+
self.assertEqual(result, ["item1", "item2"])
136+
137+
def test_function_returns_dict(self):
138+
"""Test wait_for can return dict when function returns truthy dict."""
139+
expected = {"key": "value"}
140+
func = mock.Mock(return_value=expected)
141+
result = wait.wait_for(func, timeout=5)
142+
self.assertEqual(result, expected)
143+
144+
def test_function_raises_exception(self):
145+
"""Test wait_for propagates exceptions from the function."""
146+
func = mock.Mock(side_effect=ValueError("Test error"))
147+
with self.assertRaises(ValueError) as context:
148+
wait.wait_for(func, timeout=5)
149+
self.assertEqual(str(context.exception), "Test error")
150+
151+
def test_negative_timeout(self):
152+
"""Test wait_for with negative timeout."""
153+
func = mock.Mock(return_value=False)
154+
result = wait.wait_for(func, timeout=-1)
155+
self.assertIsNone(result)
156+
157+
def test_large_step_vs_timeout(self):
158+
"""Test wait_for when step is larger than timeout.
159+
160+
Note: The function sleeps for full step duration even if it exceeds timeout.
161+
"""
162+
func = mock.Mock(return_value=False)
163+
start = time.time()
164+
result = wait.wait_for(func, timeout=0.2, step=5.0)
165+
elapsed = time.time() - start
166+
self.assertIsNone(result)
167+
# Function will sleep for full step duration after first attempt
168+
self.assertGreaterEqual(elapsed, 5.0)
169+
# Should be called once before sleeping
170+
self.assertEqual(func.call_count, 1)
171+
172+
def test_very_small_timeout(self):
173+
"""Test wait_for with very small timeout value."""
174+
func = mock.Mock(return_value=False)
175+
result = wait.wait_for(func, timeout=0.01, step=0.001)
176+
self.assertIsNone(result)
177+
178+
def test_none_args_and_kwargs(self):
179+
"""Test wait_for with None values for args and kwargs."""
180+
func = mock.Mock(return_value=True)
181+
result = wait.wait_for(func, timeout=5, args=None, kwargs=None)
182+
self.assertTrue(result)
183+
func.assert_called_once_with()
184+
185+
def test_empty_args_and_kwargs(self):
186+
"""Test wait_for with empty args and kwargs."""
187+
func = mock.Mock(return_value=True)
188+
result = wait.wait_for(func, timeout=5, args=[], kwargs={})
189+
self.assertTrue(result)
190+
func.assert_called_once_with()
191+
192+
def test_callable_object(self):
193+
"""Test wait_for works with callable objects, not just functions."""
194+
195+
class CallableCounter:
196+
def __init__(self):
197+
self.count = 0
198+
199+
def __call__(self):
200+
self.count += 1
201+
return self.count >= 3
202+
203+
callable_obj = CallableCounter()
204+
result = wait.wait_for(callable_obj, timeout=5, step=0.1)
205+
self.assertTrue(result)
206+
self.assertEqual(callable_obj.count, 3)
207+
208+
def test_lambda_function(self):
209+
"""Test wait_for works with lambda functions."""
210+
counter = {"value": 0}
211+
212+
def increment():
213+
counter["value"] += 1
214+
return counter["value"] >= 2
215+
216+
result = wait.wait_for(increment, timeout=5, step=0.1)
217+
self.assertTrue(result)
218+
219+
def test_timing_precision(self):
220+
"""Test wait_for timeout is reasonably accurate."""
221+
func = mock.Mock(return_value=False)
222+
timeout_val = 1.0
223+
start = time.time()
224+
wait.wait_for(func, timeout=timeout_val, step=0.1)
225+
elapsed = time.time() - start
226+
# Should be close to timeout (within 20% tolerance for system variance)
227+
self.assertGreaterEqual(elapsed, timeout_val)
228+
self.assertLess(elapsed, timeout_val * 1.2)
229+
230+
231+
if __name__ == "__main__":
232+
unittest.main()

0 commit comments

Comments
 (0)