robotframework-parallelrunner 0.3.1__py3-none-any.whl → 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ParallelRunner/__init__.py +2 -2
- ParallelRunner/parallel_library.py +567 -50
- ParallelRunner/retry.py +114 -0
- {robotframework_parallelrunner-0.3.1.dist-info → robotframework_parallelrunner-0.5.0.dist-info}/METADATA +39 -19
- robotframework_parallelrunner-0.5.0.dist-info/RECORD +10 -0
- robotframework_parallelrunner-0.3.1.dist-info/RECORD +0 -9
- {robotframework_parallelrunner-0.3.1.dist-info → robotframework_parallelrunner-0.5.0.dist-info}/WHEEL +0 -0
- {robotframework_parallelrunner-0.3.1.dist-info → robotframework_parallelrunner-0.5.0.dist-info}/licenses/LICENSE +0 -0
ParallelRunner/__init__.py
CHANGED
|
@@ -13,7 +13,7 @@ Keyword documentation:
|
|
|
13
13
|
https://cristiangarciavd.github.io/robotframework-parallelrunner/ParallelRunner.html
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
|
-
from .parallel_library import ParallelLibrary, ParallelRunner, ParallelTaskError
|
|
16
|
+
from .parallel_library import ParallelLibrary, ParallelRunner, ParallelTaskError, log_to_robot
|
|
17
17
|
from .version import __version__
|
|
18
18
|
|
|
19
|
-
__all__ = ["ParallelRunner", "ParallelLibrary", "ParallelTaskError", "__version__"]
|
|
19
|
+
__all__ = ["ParallelRunner", "ParallelLibrary", "ParallelTaskError", "log_to_robot", "__version__"]
|
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import importlib
|
|
3
|
+
import inspect
|
|
3
4
|
import queue
|
|
4
5
|
import threading
|
|
5
6
|
import time
|
|
6
7
|
from typing import Iterable, Any, List, Dict, Optional, Tuple, Union
|
|
7
8
|
from robot.api import logger
|
|
9
|
+
from robot.errors import DataError
|
|
8
10
|
try:
|
|
9
11
|
from robot.errors import TimeoutExceeded
|
|
10
12
|
except ImportError: # Robot Framework < 7.0
|
|
11
13
|
from robot.errors import TimeoutError as TimeoutExceeded
|
|
12
14
|
from robot.libraries.BuiltIn import BuiltIn
|
|
15
|
+
from robot.running.arguments import PythonArgumentParser
|
|
16
|
+
from robot.utils import secs_to_timestr, timestr_to_secs
|
|
13
17
|
|
|
18
|
+
from .retry import NO_RETRIES, RetryPolicy
|
|
14
19
|
from .version import __version__
|
|
15
20
|
|
|
16
21
|
|
|
@@ -35,6 +40,59 @@ def _normalize_level(level: Any) -> Optional[str]:
|
|
|
35
40
|
return name if name in LOG_LEVELS or name == "IGNORE" else None
|
|
36
41
|
|
|
37
42
|
|
|
43
|
+
def _normalize_name(name: str) -> str:
|
|
44
|
+
"""Keyword name comparison the way Robot does it: ignoring case, spaces and underscores."""
|
|
45
|
+
return name.lower().replace(" ", "").replace("_", "")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _not_given(value: Any) -> bool:
|
|
49
|
+
"""None, an empty string or "None" (any case - Robot's usual spelling of None)."""
|
|
50
|
+
return value is None or (isinstance(value, str) and value.strip().upper() in ("", "NONE"))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Who owns the result of a call that may time out: the worker that ran it, or
|
|
54
|
+
# the main thread that timed it out. See `ParallelRunner._run_in_threads`.
|
|
55
|
+
_WORKER, _MAIN = "worker", "main"
|
|
56
|
+
_NOT_SET = object()
|
|
57
|
+
|
|
58
|
+
# Worker threads left running a call that was abandoned - timed out by
|
|
59
|
+
# `call_timeout` or interrupted by a Robot `[Timeout]` - across every keyword
|
|
60
|
+
# call of the run, so the limit (ROBOT_MAX_ABANDONED_THREADS) holds even when
|
|
61
|
+
# the hung calls come from earlier tests. Only the thread running the keywords
|
|
62
|
+
# changes it. Dead threads are dropped by `_abandoned_threads_alive`.
|
|
63
|
+
_abandoned_threads: List[threading.Thread] = []
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _abandoned_threads_alive() -> int:
|
|
67
|
+
_abandoned_threads[:] = [thread for thread in _abandoned_threads if thread.is_alive()]
|
|
68
|
+
return len(_abandoned_threads)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def log_to_robot(msg: Any, level: str = "INFO") -> None:
|
|
72
|
+
"""Logs ``msg`` straight to Robot Framework, accepting the same levels as
|
|
73
|
+
the ``_logger`` that `Run Parallel Scenarios` injects.
|
|
74
|
+
|
|
75
|
+
Use it as the fallback when a parallel-ready keyword is called directly
|
|
76
|
+
(without `Run Parallel Scenarios`)::
|
|
77
|
+
|
|
78
|
+
from ParallelRunner import log_to_robot
|
|
79
|
+
|
|
80
|
+
def verify_agent(self, agent_id, _logger=None):
|
|
81
|
+
log = _logger or log_to_robot
|
|
82
|
+
log("details", "DEBUG")
|
|
83
|
+
|
|
84
|
+
Unlike ``robot.api.logger.write``, it accepts ``IGNORE`` (dropped), any
|
|
85
|
+
case and Python ``logging`` names (``WARNING``), and logs a message with an
|
|
86
|
+
unknown level at INFO, prefixed with the level, instead of failing.
|
|
87
|
+
"""
|
|
88
|
+
name = _normalize_level(level)
|
|
89
|
+
if name == "IGNORE":
|
|
90
|
+
return
|
|
91
|
+
if name is None:
|
|
92
|
+
name, msg = "INFO", f"[{level}] {msg}"
|
|
93
|
+
logger.write(msg, name)
|
|
94
|
+
|
|
95
|
+
|
|
38
96
|
class ParallelTaskError(RuntimeError):
|
|
39
97
|
"""
|
|
40
98
|
Raised when at least one parallel task failed: by ``run_parallel_scenarios``
|
|
@@ -93,18 +151,33 @@ class ParallelRunner:
|
|
|
93
151
|
index) as its first argument and an injected ``_logger`` callable that
|
|
94
152
|
it should use instead of ``robot.api.logger``:
|
|
95
153
|
|
|
96
|
-
| from
|
|
154
|
+
| from ParallelRunner import log_to_robot
|
|
97
155
|
|
|
|
98
156
|
| class MyLibrary:
|
|
99
|
-
| def verify_agent(self, agent_id, _logger=None, **kwargs):
|
|
100
|
-
| log = _logger or
|
|
157
|
+
| def verify_agent(self, agent_id: int, timeout: float = 10, _logger=None, **kwargs):
|
|
158
|
+
| log = _logger or log_to_robot
|
|
101
159
|
| log(f"Checking agent {agent_id}")
|
|
102
160
|
| ...
|
|
103
161
|
| return result
|
|
104
162
|
|
|
105
163
|
``_logger(msg, level)`` accepts every Robot Framework log level (see
|
|
106
|
-
`Log levels`) plus ``IGNORE`` (dropped).
|
|
107
|
-
|
|
164
|
+
`Log levels`) plus ``IGNORE`` (dropped). ``log_to_robot`` is the fallback
|
|
165
|
+
for when the keyword is called directly: it accepts the same levels
|
|
166
|
+
(``robot.api.logger.write`` fails on ``IGNORE`` or ``WARNING``).
|
|
167
|
+
|
|
168
|
+
Any extra named argument given to `Run Parallel Scenarios` is forwarded
|
|
169
|
+
to every call as ``**kwargs``. Arguments are converted by the method's
|
|
170
|
+
type hints (and default values) exactly as when the keyword is called
|
|
171
|
+
directly: ``timeout=5`` arrives as the number ``5.0`` above. Items that
|
|
172
|
+
are strings are converted the same way; other items (numbers, lists,
|
|
173
|
+
dictionaries from a variable) are passed as they are.
|
|
174
|
+
|
|
175
|
+
The keyword is found by its method name (``Verify Agent`` ->
|
|
176
|
+
``verify_agent``) or by a custom name given with ``@keyword("...")``,
|
|
177
|
+
ignoring case, spaces and underscores, like Robot does.
|
|
178
|
+
|
|
179
|
+
To be stoppable when it times out, the method can also declare a
|
|
180
|
+
``_cancelled`` parameter: see `Call timeouts and cancellation`.
|
|
108
181
|
|
|
109
182
|
Logging is made thread-safe for you; your keyword's own side effects are
|
|
110
183
|
not. Protect shared state (files, shared objects) with a lock.
|
|
@@ -118,7 +191,8 @@ class ParallelRunner:
|
|
|
118
191
|
| item | The item (or repeat index) the call received. |
|
|
119
192
|
| logs | List of ``(level, message)`` tuples captured during the call. |
|
|
120
193
|
| result | The return value of the call (only when ``status`` is ``PASS``). |
|
|
121
|
-
| error | The error message (only when ``status`` is ``FAIL``). |
|
|
194
|
+
| error | The error message (only when ``status`` is ``FAIL``), e.g. ``Timed out after 10 seconds`` with ``call_timeout``. |
|
|
195
|
+
| attempts | How many times the call ran: ``1``, or more with ``call_retries`` (see `Retries`). ``0`` for a call that never ran. |
|
|
122
196
|
|
|
123
197
|
= Failures =
|
|
124
198
|
|
|
@@ -168,10 +242,86 @@ class ParallelRunner:
|
|
|
168
242
|
|
|
169
243
|
Python threads can't be killed, so calls that are already running keep
|
|
170
244
|
running in the background until they return (possibly while the next test
|
|
171
|
-
runs)
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
245
|
+
runs) - unless they cooperate, see `Call timeouts and cancellation`.
|
|
246
|
+
Calls that had not started yet are not started. The worker threads are
|
|
247
|
+
daemon threads, so they never keep the Robot process alive after the run.
|
|
248
|
+
A keyword that may hang (e.g. network I/O) should set its own timeouts
|
|
249
|
+
(e.g. ``requests.get(url, timeout=10)``).
|
|
250
|
+
|
|
251
|
+
A Robot timeout fails the whole test and can't be handled with
|
|
252
|
+
`Run Keyword And Ignore Error`. To fail only the call that hangs, use
|
|
253
|
+
``call_timeout``.
|
|
254
|
+
|
|
255
|
+
= Call timeouts and cancellation =
|
|
256
|
+
|
|
257
|
+
``call_timeout`` limits how long *each call* may run, e.g.
|
|
258
|
+
``call_timeout=10s`` (Robot's time format). A call still running after
|
|
259
|
+
that becomes a failed call - error ``Timed out after 10 seconds``, with its
|
|
260
|
+
partial logs - while the other calls keep running and finish normally. It
|
|
261
|
+
is then reported like any other failed call: `Run Parallel Scenarios`
|
|
262
|
+
fails with ``ParallelTaskError`` (which, unlike a Robot timeout, can be
|
|
263
|
+
handled), or returns it with ``status`` ``FAIL`` when
|
|
264
|
+
``fail_on_error=False``.
|
|
265
|
+
|
|
266
|
+
| ${results}= | Run Parallel Scenarios | keyword=Verify Agent | library=MyLibrary | for_loop_iterable=${agents} | call_timeout=10s | fail_on_error=False |
|
|
267
|
+
|
|
268
|
+
The timed-out call can't be killed either: it keeps running in the
|
|
269
|
+
background, and a new worker thread takes its place so the remaining
|
|
270
|
+
calls still run ``ROBOT_THREAD_WORKERS`` at a time.
|
|
271
|
+
|
|
272
|
+
So that calls that hang can't pile up hundreds of threads (and open
|
|
273
|
+
connections), the abandoned threads still running are limited by
|
|
274
|
+
``ROBOT_MAX_ABANDONED_THREADS`` (default: twice ``ROBOT_THREAD_WORKERS``).
|
|
275
|
+
They are counted across the whole run - calls abandoned by earlier tests,
|
|
276
|
+
or interrupted by a Robot ``[Timeout]``, count too - and a thread stops
|
|
277
|
+
counting as soon as its call returns. Above the limit, a warning is logged
|
|
278
|
+
and timed-out workers are no longer replaced: the remaining calls run with
|
|
279
|
+
fewer workers, and if none is left they fail without starting, with error
|
|
280
|
+
``Not started: abandoned thread limit (8) reached``. Every keyword call
|
|
281
|
+
still starts with its normal number of workers, whatever earlier tests
|
|
282
|
+
left running.
|
|
283
|
+
|
|
284
|
+
A keyword that can stop early declares a ``_cancelled`` parameter.
|
|
285
|
+
`Run Parallel Scenarios` then passes it a ``threading.Event`` that is set
|
|
286
|
+
when that call times out, or when a Robot ``[Timeout]`` interrupts the
|
|
287
|
+
batch. The keyword checks it between steps, or waits on it instead of
|
|
288
|
+
sleeping:
|
|
289
|
+
|
|
290
|
+
| def poll_job(self, job_id, _logger=None, _cancelled=None):
|
|
291
|
+
| while not job_done(job_id):
|
|
292
|
+
| if _cancelled is not None and _cancelled.wait(1): # sleeps 1 s, wakes up if cancelled
|
|
293
|
+
| _logger(f"Stopped waiting for {job_id}", "WARN")
|
|
294
|
+
| return None
|
|
295
|
+
|
|
296
|
+
``_cancelled`` is only passed to methods that declare it by that name, so
|
|
297
|
+
existing keywords keep working unchanged. It can't interrupt a call that
|
|
298
|
+
is blocked in a single operation (a network request without a timeout).
|
|
299
|
+
|
|
300
|
+
= Retries =
|
|
301
|
+
|
|
302
|
+
``call_retries=N`` runs a failed call up to ``N`` more times before it
|
|
303
|
+
counts as failed. Only that call is retried; the others are not affected.
|
|
304
|
+
|
|
305
|
+
| ${results}= | Run Parallel Scenarios | keyword=Verify Agent | library=MyLibrary | for_loop_iterable=${agents} | call_retries=2 | retry_delay=1s | retry_backoff=2 |
|
|
306
|
+
|
|
307
|
+
- Between attempts the call waits ``retry_delay`` (default ``1s``), multiplied by
|
|
308
|
+
``retry_backoff`` after every failed retry: above, 1 second before the second
|
|
309
|
+
attempt and 2 seconds before the third. The wait happens in a worker
|
|
310
|
+
thread, after the calls that were already waiting.
|
|
311
|
+
- A call that exceeds ``call_timeout`` is retried too, and ``call_timeout``
|
|
312
|
+
applies to each attempt. The timed-out attempt keeps running in the
|
|
313
|
+
background, so it is only retried while the abandoned threads are within
|
|
314
|
+
``ROBOT_MAX_ABANDONED_THREADS``; otherwise it fails with its timeout error.
|
|
315
|
+
- Every attempt's logs are kept, separated by a ``WARN`` such as
|
|
316
|
+
``Attempt 1 of 3 failed: <error>; retrying in 1 second``, so ``log.html``
|
|
317
|
+
shows why a call needed retries even when it passed in the end. Only the
|
|
318
|
+
last failure is logged as ``ERROR``.
|
|
319
|
+
- The ``attempts`` key of the result tells how many attempts the call took,
|
|
320
|
+
to spot calls that are flaky even when the test passes.
|
|
321
|
+
- A Robot ``[Timeout]`` stops the retries along with the rest of the batch.
|
|
322
|
+
|
|
323
|
+
Retry only what is safe to repeat: a call that failed half way may already
|
|
324
|
+
have changed something (a created record, a sent message).
|
|
175
325
|
|
|
176
326
|
= Custom logger mappers =
|
|
177
327
|
|
|
@@ -196,7 +346,8 @@ class ParallelRunner:
|
|
|
196
346
|
= Environment variables =
|
|
197
347
|
|
|
198
348
|
| =Variable= | =Description= |
|
|
199
|
-
| ROBOT_THREAD_WORKERS | Number of worker threads. Read when the library is imported. Default ``4``. |
|
|
349
|
+
| ROBOT_THREAD_WORKERS | Number of worker threads. Read when the library is imported. Default ``4``. An invalid value is ignored with a warning. |
|
|
350
|
+
| ROBOT_MAX_ABANDONED_THREADS | Maximum number of threads left running by timed-out calls before they are no longer replaced. Read when the library is imported. Default: twice ``ROBOT_THREAD_WORKERS``. ``0`` never replaces them. An invalid value is ignored with a warning. See `Call timeouts and cancellation`. |
|
|
200
351
|
| ROBOT_LOGGER_MAPPER | Default ``logger_mapper`` as a ``module.function`` path, used when the argument is not given. See `Custom logger mappers`. |
|
|
201
352
|
|
|
202
353
|
= When not to use it =
|
|
@@ -210,9 +361,55 @@ class ParallelRunner:
|
|
|
210
361
|
ROBOT_LIBRARY_VERSION = __version__
|
|
211
362
|
ROBOT_LIBRARY_DOC_FORMAT = 'ROBOT'
|
|
212
363
|
|
|
364
|
+
DEFAULT_WORKERS = 4
|
|
365
|
+
|
|
213
366
|
def __init__(self):
|
|
214
|
-
|
|
215
|
-
|
|
367
|
+
self.max_workers = self._workers_from_environment()
|
|
368
|
+
# None: twice the number of workers (see `_abandoned_thread_limit`).
|
|
369
|
+
self.max_abandoned_threads = self._abandoned_limit_from_environment()
|
|
370
|
+
|
|
371
|
+
def _workers_from_environment(self) -> int:
|
|
372
|
+
"""``ROBOT_THREAD_WORKERS``, or 4. An invalid value is ignored with a
|
|
373
|
+
warning (it used to make the library import fail with a bare
|
|
374
|
+
``invalid literal for int()``)."""
|
|
375
|
+
value = os.getenv("ROBOT_THREAD_WORKERS")
|
|
376
|
+
if _not_given(value):
|
|
377
|
+
return self.DEFAULT_WORKERS
|
|
378
|
+
try:
|
|
379
|
+
workers = int(value)
|
|
380
|
+
except ValueError:
|
|
381
|
+
workers = 0
|
|
382
|
+
if workers < 1:
|
|
383
|
+
logger.warn(
|
|
384
|
+
f"Ignoring ROBOT_THREAD_WORKERS {value!r}: expected a whole number of at least 1. "
|
|
385
|
+
f"Using {self.DEFAULT_WORKERS} workers."
|
|
386
|
+
)
|
|
387
|
+
return self.DEFAULT_WORKERS
|
|
388
|
+
return workers
|
|
389
|
+
|
|
390
|
+
@staticmethod
|
|
391
|
+
def _abandoned_limit_from_environment() -> Optional[int]:
|
|
392
|
+
"""``ROBOT_MAX_ABANDONED_THREADS``, or None for the default. An invalid
|
|
393
|
+
value is ignored with a warning, like ``ROBOT_THREAD_WORKERS``."""
|
|
394
|
+
value = os.getenv("ROBOT_MAX_ABANDONED_THREADS")
|
|
395
|
+
if _not_given(value):
|
|
396
|
+
return None
|
|
397
|
+
try:
|
|
398
|
+
limit = int(value)
|
|
399
|
+
except ValueError:
|
|
400
|
+
limit = -1
|
|
401
|
+
if limit < 0:
|
|
402
|
+
logger.warn(
|
|
403
|
+
f"Ignoring ROBOT_MAX_ABANDONED_THREADS {value!r}: expected a whole number of at least 0. "
|
|
404
|
+
"Using twice the number of workers."
|
|
405
|
+
)
|
|
406
|
+
return None
|
|
407
|
+
return limit
|
|
408
|
+
|
|
409
|
+
def _abandoned_thread_limit(self) -> int:
|
|
410
|
+
if self.max_abandoned_threads is None:
|
|
411
|
+
return 2 * self.max_workers
|
|
412
|
+
return self.max_abandoned_threads
|
|
216
413
|
|
|
217
414
|
def run_parallel_scenarios(
|
|
218
415
|
self,
|
|
@@ -225,6 +422,10 @@ class ParallelRunner:
|
|
|
225
422
|
logger_mapper: Optional[Any] = None,
|
|
226
423
|
return_values_only: bool = False,
|
|
227
424
|
fail_on_error: bool = True,
|
|
425
|
+
call_timeout: Union[str, float, None] = None,
|
|
426
|
+
call_retries: Union[int, str, None] = 0,
|
|
427
|
+
retry_delay: Union[str, float, None] = "1s",
|
|
428
|
+
retry_backoff: Union[float, str, None] = 1,
|
|
228
429
|
**kwargs
|
|
229
430
|
) -> Union[List[Dict[str, Any]], Tuple[Any, ...]]:
|
|
230
431
|
"""Runs ``keyword`` from ``library`` concurrently, once per item or N times.
|
|
@@ -263,7 +464,21 @@ class ParallelRunner:
|
|
|
263
464
|
when any call failed, after all calls have finished and their logs
|
|
264
465
|
have been replayed. If false, return the results anyway and leave
|
|
265
466
|
checking ``status`` to the caller. See `Failures`.
|
|
266
|
-
-
|
|
467
|
+
- ``call_timeout``: Maximum time each call may run, in Robot's time
|
|
468
|
+
format (``10s``, ``1 minute 30 seconds``, ``2.5``). A call still
|
|
469
|
+
running after it fails with ``Timed out after ...`` while the other
|
|
470
|
+
calls keep running; calls that declare ``_cancelled`` are told to
|
|
471
|
+
stop. Not given (default): no limit. See
|
|
472
|
+
`Call timeouts and cancellation`.
|
|
473
|
+
- ``call_retries``: How many more times a failed call is tried
|
|
474
|
+
(``0``, default: none). A call that exceeds ``call_timeout`` is
|
|
475
|
+
retried too. See `Retries`.
|
|
476
|
+
- ``retry_delay``: Wait before the first retry, in Robot's time format.
|
|
477
|
+
Default ``1s``; ``0`` retries right away.
|
|
478
|
+
- ``retry_backoff``: Multiplies the wait after every failed retry:
|
|
479
|
+
``2`` waits 1 s, 2 s, 4 s... Default ``1`` (always the same wait).
|
|
480
|
+
- ``**kwargs``: Any other named argument is passed to every call,
|
|
481
|
+
converted by the method's type hints like in a direct call.
|
|
267
482
|
|
|
268
483
|
Returns a list of result dictionaries (see `Result format`), in call
|
|
269
484
|
order: entry ``i`` belongs to ``for_loop_iterable[i]`` / repeat
|
|
@@ -276,6 +491,8 @@ class ParallelRunner:
|
|
|
276
491
|
| ${results}= | Run Parallel Scenarios | keyword=Check Health | library=MyLibrary | repeat=8 | agent_id=1 |
|
|
277
492
|
| ${id1} ${id2} ${id3}= | Run Parallel Scenarios | keyword=Seed Record | library=MyLibrary | repeat=3 | return_values_only=True |
|
|
278
493
|
| ${results}= | Run Parallel Scenarios | keyword=Verify Agent Data | library=MyLibrary | for_loop_iterable=${agents} | fail_on_error=False |
|
|
494
|
+
| ${results}= | Run Parallel Scenarios | keyword=Verify Agent Data | library=MyLibrary | for_loop_iterable=${agents} | call_timeout=30s |
|
|
495
|
+
| ${results}= | Run Parallel Scenarios | keyword=Verify Agent Data | library=MyLibrary | for_loop_iterable=${agents} | call_retries=2 | retry_delay=500ms |
|
|
279
496
|
"""
|
|
280
497
|
# Determine items to process. NOTE: `repeat or 1` would be wrong here -
|
|
281
498
|
# 0 is falsy in Python, so an explicit repeat=0 would silently fall
|
|
@@ -294,13 +511,17 @@ class ParallelRunner:
|
|
|
294
511
|
effective_mapper = self._load_mapper_from_environment()
|
|
295
512
|
|
|
296
513
|
min_level = self._resolve_thread_log_level(thread_log_level)
|
|
514
|
+
timeout_seconds = self._resolve_call_timeout(call_timeout)
|
|
515
|
+
retry = RetryPolicy.from_arguments(call_retries, retry_delay, retry_backoff)
|
|
297
516
|
|
|
298
517
|
# Note: We must call the underlying Python method, not BuiltIn().run_keyword
|
|
299
518
|
# because run_keyword is not thread-safe.
|
|
300
519
|
lib_instance = self._get_library_instance_owning_keyword(keyword, library)
|
|
301
|
-
method =
|
|
520
|
+
method = self._get_keyword_method(lib_instance, keyword)
|
|
302
521
|
|
|
303
|
-
results = self._run_in_threads(
|
|
522
|
+
results = self._run_in_threads(
|
|
523
|
+
method, list(items), effective_mapper, remove_passing_logs, min_level, kwargs, timeout_seconds, retry
|
|
524
|
+
)
|
|
304
525
|
|
|
305
526
|
# Step: Re-play logs into Robot Framework sequentially
|
|
306
527
|
self._replay_logs(results, remove_passing_logs, min_level)
|
|
@@ -393,17 +614,45 @@ class ParallelRunner:
|
|
|
393
614
|
return self._resolve_mapper(os.getenv("ROBOT_LOGGER_MAPPER"), source="ROBOT_LOGGER_MAPPER")
|
|
394
615
|
|
|
395
616
|
def _get_library_instance_owning_keyword(self, keyword_name: str, library: str) -> Any:
|
|
396
|
-
method_name = keyword_name.replace(" ", "_").lower()
|
|
397
617
|
lib_instance = BuiltIn().get_library_instance(library)
|
|
398
|
-
if
|
|
618
|
+
if self._find_keyword_method(lib_instance, keyword_name) is not None:
|
|
399
619
|
return lib_instance
|
|
400
620
|
raise ValueError(f"Keyword '{keyword_name}' not found in library '{library}'")
|
|
401
621
|
|
|
622
|
+
def _get_keyword_method(self, lib_instance: Any, keyword_name: str):
|
|
623
|
+
method = self._find_keyword_method(lib_instance, keyword_name)
|
|
624
|
+
if method is None:
|
|
625
|
+
raise ValueError(f"Keyword '{keyword_name}' not found in library '{type(lib_instance).__name__}'")
|
|
626
|
+
return method
|
|
627
|
+
|
|
628
|
+
@staticmethod
|
|
629
|
+
def _find_keyword_method(lib_instance: Any, keyword_name: str):
|
|
630
|
+
"""Finds the method implementing ``keyword_name``: by method name
|
|
631
|
+
(``Verify Agent Data`` -> ``verify_agent_data``), or, like Robot
|
|
632
|
+
itself, by a custom name given with ``@keyword("...")``. Names are
|
|
633
|
+
compared ignoring case, spaces and underscores."""
|
|
634
|
+
method = getattr(lib_instance, keyword_name.replace(" ", "_").lower(), None)
|
|
635
|
+
if callable(method):
|
|
636
|
+
return method
|
|
637
|
+
wanted = _normalize_name(keyword_name)
|
|
638
|
+
for attribute in dir(lib_instance):
|
|
639
|
+
# getattr_static: don't run properties just to look at them.
|
|
640
|
+
raw = inspect.getattr_static(lib_instance, attribute, None)
|
|
641
|
+
function = raw.__func__ if isinstance(raw, (staticmethod, classmethod)) else raw
|
|
642
|
+
robot_name = getattr(function, "robot_name", None)
|
|
643
|
+
if robot_name is not None:
|
|
644
|
+
matches = _normalize_name(robot_name) == wanted
|
|
645
|
+
else:
|
|
646
|
+
matches = not attribute.startswith("_") and _normalize_name(attribute) == wanted
|
|
647
|
+
if matches and callable(getattr(lib_instance, attribute, None)):
|
|
648
|
+
return getattr(lib_instance, attribute)
|
|
649
|
+
return None
|
|
650
|
+
|
|
402
651
|
def _resolve_thread_log_level(self, thread_log_level: Any) -> int:
|
|
403
652
|
"""Returns the numeric minimum level for ``thread_log_level``. Not
|
|
404
653
|
given (None, empty, "None") means every level; an unknown value is
|
|
405
654
|
ignored with a warning instead of silently filtering by INFO."""
|
|
406
|
-
if
|
|
655
|
+
if _not_given(thread_log_level):
|
|
407
656
|
return LOG_LEVELS["TRACE"]
|
|
408
657
|
level = _normalize_level(thread_log_level)
|
|
409
658
|
if level in LOG_LEVELS:
|
|
@@ -414,6 +663,41 @@ class ParallelRunner:
|
|
|
414
663
|
)
|
|
415
664
|
return LOG_LEVELS["TRACE"]
|
|
416
665
|
|
|
666
|
+
@staticmethod
|
|
667
|
+
def _resolve_call_timeout(call_timeout: Any) -> Optional[float]:
|
|
668
|
+
"""Returns ``call_timeout`` in seconds, or None when not given. Accepts
|
|
669
|
+
Robot's time format (``10s``, ``1 minute 30 seconds``, ``1.5``)."""
|
|
670
|
+
if _not_given(call_timeout):
|
|
671
|
+
return None
|
|
672
|
+
try:
|
|
673
|
+
seconds = timestr_to_secs(call_timeout)
|
|
674
|
+
except (ValueError, DataError) as error:
|
|
675
|
+
raise ValueError(f"Invalid call_timeout {call_timeout!r}: {error}") from None
|
|
676
|
+
if seconds <= 0:
|
|
677
|
+
raise ValueError(f"call_timeout must be positive, got {call_timeout!r}.")
|
|
678
|
+
return seconds
|
|
679
|
+
|
|
680
|
+
@staticmethod
|
|
681
|
+
def _converter_for(method):
|
|
682
|
+
"""Returns a function converting ``(positional, named)`` arguments by
|
|
683
|
+
the method's type hints and default values, exactly like Robot does
|
|
684
|
+
when the keyword is called directly - or None if Robot can't parse the
|
|
685
|
+
method's signature (then arguments are passed as given)."""
|
|
686
|
+
try:
|
|
687
|
+
spec = PythonArgumentParser().parse(method)
|
|
688
|
+
except Exception:
|
|
689
|
+
return None
|
|
690
|
+
return lambda positional, named: spec.convert(positional, named)
|
|
691
|
+
|
|
692
|
+
@staticmethod
|
|
693
|
+
def _accepts_cancel_event(method) -> bool:
|
|
694
|
+
"""``_cancelled`` is only passed to keywords that declare it, so that
|
|
695
|
+
keywords with a fixed signature keep working."""
|
|
696
|
+
try:
|
|
697
|
+
return "_cancelled" in inspect.signature(method).parameters
|
|
698
|
+
except (TypeError, ValueError):
|
|
699
|
+
return False
|
|
700
|
+
|
|
417
701
|
def _run_in_threads(
|
|
418
702
|
self,
|
|
419
703
|
method,
|
|
@@ -422,11 +706,27 @@ class ParallelRunner:
|
|
|
422
706
|
remove_passing_logs: bool,
|
|
423
707
|
min_level: int,
|
|
424
708
|
kwargs: Dict[str, Any],
|
|
709
|
+
call_timeout: Optional[float] = None,
|
|
710
|
+
retry: RetryPolicy = NO_RETRIES,
|
|
425
711
|
) -> List[Dict[str, Any]]:
|
|
426
712
|
"""
|
|
427
713
|
Runs ``method`` once per item on up to ``max_workers`` daemon threads
|
|
428
714
|
and returns the result dicts in call order (NOT completion order).
|
|
429
715
|
|
|
716
|
+
A failed attempt is tried again while ``retry`` allows it: the worker
|
|
717
|
+
puts the call back in the queue with the time it may start again, and
|
|
718
|
+
whichever worker takes it waits until then. ``call_timeout`` applies
|
|
719
|
+
to each attempt, and a timed-out attempt is retried only while the
|
|
720
|
+
abandoned threads are within their limit.
|
|
721
|
+
|
|
722
|
+
With ``call_timeout``, a call still running after that many seconds
|
|
723
|
+
becomes a ``FAIL`` result: its thread is abandoned (it can't be
|
|
724
|
+
killed) and a new worker takes its place, so the other calls keep
|
|
725
|
+
running ``max_workers`` at a time - while no more than the limit of
|
|
726
|
+
abandoned threads (``_abandoned_thread_limit``) are alive. Past it,
|
|
727
|
+
no replacements are started; if every worker is gone, the calls that
|
|
728
|
+
did not start fail right away.
|
|
729
|
+
|
|
430
730
|
If the wait is interrupted (mostly by a Robot ``[Timeout]``), the
|
|
431
731
|
workers stop starting new calls, the logs collected so far are
|
|
432
732
|
replayed and the exception is re-raised. Calls already running can't
|
|
@@ -437,43 +737,251 @@ class ParallelRunner:
|
|
|
437
737
|
if self.max_workers < 1:
|
|
438
738
|
raise ValueError(f"ROBOT_THREAD_WORKERS must be at least 1, got {self.max_workers}.")
|
|
439
739
|
|
|
740
|
+
count = len(items)
|
|
741
|
+
convert = self._converter_for(method)
|
|
742
|
+
if convert is not None:
|
|
743
|
+
# Once, before any thread starts: a bad named argument fails the
|
|
744
|
+
# keyword instead of every call.
|
|
745
|
+
kwargs = dict(convert([], list(kwargs.items()))[1])
|
|
746
|
+
cancel_events = [threading.Event() for _ in items] if self._accepts_cancel_event(method) else None
|
|
747
|
+
|
|
440
748
|
# Created here, not in the workers, so the logs of calls that are still
|
|
441
|
-
# running can be replayed if the keyword is interrupted.
|
|
749
|
+
# running can be replayed if the keyword is interrupted or times out.
|
|
442
750
|
log_buffers: List[list] = [[] for _ in items]
|
|
443
|
-
|
|
444
|
-
|
|
751
|
+
# Written by the workers. When an attempt of call i starts, a worker
|
|
752
|
+
# writes `threads[i]`, `attempts[i]` (its number, 1-based) and then
|
|
753
|
+
# `attempt_started[i]` - in that order. `attempt_started[i]` is None
|
|
754
|
+
# while the call waits for a retry (it is cleared before the retry is
|
|
755
|
+
# queued), and the main thread reads `attempts[i]` first: so it never
|
|
756
|
+
# pairs an attempt with the start time of an earlier one, which would
|
|
757
|
+
# time the new attempt out too soon. `started_at[i]` is the start of
|
|
758
|
+
# the first attempt. `finished[i]`
|
|
759
|
+
# is written when the last attempt has returned - unless the main
|
|
760
|
+
# thread has timed the attempt out first. `claims`, keyed by (call,
|
|
761
|
+
# attempt), decides which of the two wins: dict.setdefault is atomic,
|
|
762
|
+
# so the first claim sticks.
|
|
763
|
+
started_at: List[Optional[float]] = [None] * count
|
|
764
|
+
attempt_started: List[Optional[float]] = [None] * count
|
|
765
|
+
attempts: List[int] = [0] * count
|
|
766
|
+
threads: List[Optional[threading.Thread]] = [None] * count
|
|
767
|
+
finished: List[Optional[Dict[str, Any]]] = [None] * count
|
|
768
|
+
claims: Dict[Tuple[int, int], str] = {}
|
|
769
|
+
# One entry per worker that ended because there was nothing left to
|
|
770
|
+
# run (or the batch was interrupted), not because it was abandoned.
|
|
771
|
+
exited: List[int] = []
|
|
772
|
+
# Written by the main thread only.
|
|
773
|
+
results: List[Optional[Dict[str, Any]]] = [None] * count
|
|
774
|
+
|
|
775
|
+
# What each call receives as its first argument. Only strings are
|
|
776
|
+
# converted (by the type hint, as when Robot calls the keyword with a
|
|
777
|
+
# value from a test file); other objects are passed as they are. The
|
|
778
|
+
# results keep the original item either way.
|
|
779
|
+
arguments = list(items)
|
|
445
780
|
pending = queue.SimpleQueue()
|
|
446
|
-
for index in
|
|
447
|
-
|
|
781
|
+
for index, item in enumerate(items):
|
|
782
|
+
if convert is not None and isinstance(item, str):
|
|
783
|
+
try:
|
|
784
|
+
(arguments[index],), _ = convert([item], [])
|
|
785
|
+
except Exception as error:
|
|
786
|
+
# Not worth starting: Robot would fail this call before running it, too.
|
|
787
|
+
message = str(error)
|
|
788
|
+
log_buffers[index].append(("ERROR", f"Thread failed for item {item}: {message}"))
|
|
789
|
+
started_at[index] = time.monotonic()
|
|
790
|
+
finished[index] = {
|
|
791
|
+
"status": "FAIL", "item": item, "logs": log_buffers[index], "error": message, "attempts": 0,
|
|
792
|
+
}
|
|
793
|
+
continue
|
|
794
|
+
# (call, attempt, earliest start): retries go back in the queue too.
|
|
795
|
+
pending.put((index, 1, 0.0))
|
|
448
796
|
stop = threading.Event()
|
|
797
|
+
workers_started = 0
|
|
449
798
|
|
|
450
799
|
def worker():
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
800
|
+
abandoned = False
|
|
801
|
+
try:
|
|
802
|
+
while not stop.is_set():
|
|
803
|
+
try:
|
|
804
|
+
index, attempt, ready_at = pending.get_nowait()
|
|
805
|
+
except queue.Empty:
|
|
806
|
+
return
|
|
807
|
+
wait = ready_at - time.monotonic()
|
|
808
|
+
if wait > 0 and stop.wait(wait):
|
|
809
|
+
return
|
|
810
|
+
call_kwargs = kwargs if cancel_events is None else {**kwargs, "_cancelled": cancel_events[index]}
|
|
811
|
+
log_buffer = log_buffers[index]
|
|
812
|
+
threads[index] = threading.current_thread()
|
|
813
|
+
attempts[index] = attempt
|
|
814
|
+
now = time.monotonic()
|
|
815
|
+
attempt_started[index] = now
|
|
816
|
+
if started_at[index] is None:
|
|
817
|
+
started_at[index] = now
|
|
818
|
+
last = not retry.can_retry(attempt)
|
|
819
|
+
result = self._execute_and_capture(
|
|
820
|
+
method, items[index], logger_mapper, log_buffer, call_kwargs, arguments[index],
|
|
821
|
+
log_failure=last,
|
|
822
|
+
)
|
|
823
|
+
if claims.setdefault((index, attempt), _WORKER) != _WORKER:
|
|
824
|
+
# The attempt timed out and a new worker took this
|
|
825
|
+
# thread's place: stop here, so no more than
|
|
826
|
+
# max_workers calls run.
|
|
827
|
+
abandoned = True
|
|
828
|
+
return
|
|
829
|
+
if result["status"] == "FAIL" and not last:
|
|
830
|
+
attempt_started[index] = None
|
|
831
|
+
log_buffer.append(("WARN", retry.failed_message(attempt, result["error"])))
|
|
832
|
+
pending.put((index, attempt + 1, time.monotonic() + retry.delay_after(attempt)))
|
|
833
|
+
else:
|
|
834
|
+
result["attempts"] = attempt
|
|
835
|
+
finished[index] = result
|
|
836
|
+
finally:
|
|
837
|
+
if not abandoned:
|
|
838
|
+
exited.append(1)
|
|
839
|
+
|
|
840
|
+
def start_worker():
|
|
841
|
+
nonlocal workers_started
|
|
842
|
+
workers_started += 1
|
|
843
|
+
threading.Thread(target=worker, name=f"ParallelRunner-worker-{workers_started}", daemon=True).start()
|
|
844
|
+
|
|
845
|
+
workers_wanted = min(self.max_workers, count)
|
|
846
|
+
limit = self._abandoned_thread_limit()
|
|
847
|
+
abandoned_here = 0
|
|
848
|
+
limit_warned = False
|
|
461
849
|
try:
|
|
462
|
-
for
|
|
463
|
-
|
|
850
|
+
for _ in range(workers_wanted):
|
|
851
|
+
start_worker()
|
|
464
852
|
# Deliberately no lock, Event or Future wait here: a timeout raised
|
|
465
853
|
# asynchronously while such a wait holds an internal lock could
|
|
466
854
|
# leave it locked forever. Short sleeps and plain reads are safe.
|
|
467
|
-
|
|
468
|
-
|
|
855
|
+
remaining = count
|
|
856
|
+
next_unstarted = 0
|
|
857
|
+
running: List[int] = []
|
|
858
|
+
while remaining:
|
|
859
|
+
# Calls start in index order (FIFO queue), so started calls are
|
|
860
|
+
# picked up by moving a pointer instead of scanning every slot.
|
|
861
|
+
while next_unstarted < count and started_at[next_unstarted] is not None:
|
|
862
|
+
running.append(next_unstarted)
|
|
863
|
+
next_unstarted += 1
|
|
864
|
+
now = time.monotonic()
|
|
865
|
+
still_running = []
|
|
866
|
+
for index in running:
|
|
867
|
+
if finished[index] is not None:
|
|
868
|
+
results[index] = finished[index]
|
|
869
|
+
remaining -= 1
|
|
870
|
+
continue
|
|
871
|
+
attempt = attempts[index] # before attempt_started: see above
|
|
872
|
+
began = attempt_started[index]
|
|
873
|
+
if (
|
|
874
|
+
call_timeout is None
|
|
875
|
+
or began is None
|
|
876
|
+
or now - began < call_timeout
|
|
877
|
+
or claims.setdefault((index, attempt), _MAIN) != _MAIN
|
|
878
|
+
):
|
|
879
|
+
still_running.append(index)
|
|
880
|
+
continue
|
|
881
|
+
if cancel_events is not None:
|
|
882
|
+
cancel_events[index].set()
|
|
883
|
+
_abandoned_threads.append(threads[index])
|
|
884
|
+
abandoned_here += 1
|
|
885
|
+
retrying = retry.can_retry(attempt)
|
|
886
|
+
if retrying and _abandoned_threads_alive() <= limit:
|
|
887
|
+
# The abandoned attempt keeps the old buffer and event:
|
|
888
|
+
# its late logs don't mix with the next attempt's.
|
|
889
|
+
duration = secs_to_timestr(call_timeout)
|
|
890
|
+
log_buffers[index] = log_buffers[index] + [
|
|
891
|
+
("WARN", retry.timed_out_message(attempt, duration))
|
|
892
|
+
]
|
|
893
|
+
if cancel_events is not None:
|
|
894
|
+
cancel_events[index] = threading.Event()
|
|
895
|
+
attempt_started[index] = None
|
|
896
|
+
pending.put((index, attempt + 1, now + retry.delay_after(attempt)))
|
|
897
|
+
still_running.append(index)
|
|
898
|
+
else:
|
|
899
|
+
results[index] = self._timed_out_result(
|
|
900
|
+
items[index], log_buffers[index], call_timeout, attempt,
|
|
901
|
+
not_retried_limit=limit if retrying else None,
|
|
902
|
+
)
|
|
903
|
+
remaining -= 1
|
|
904
|
+
running = still_running
|
|
905
|
+
# Replace the workers abandoned by timed-out calls (or ended
|
|
906
|
+
# while a timed-out call was put back in the queue) while
|
|
907
|
+
# calls are still waiting - unless too many abandoned threads
|
|
908
|
+
# are alive (from this keyword call or earlier ones). Checked
|
|
909
|
+
# on every round, so a slot freed by a hung call that finally
|
|
910
|
+
# returns is used again.
|
|
911
|
+
active = workers_started - abandoned_here - len(exited)
|
|
912
|
+
missing = workers_wanted - active
|
|
913
|
+
if missing > 0 and not pending.empty():
|
|
914
|
+
alive = _abandoned_threads_alive()
|
|
915
|
+
if alive <= limit:
|
|
916
|
+
for _ in range(missing):
|
|
917
|
+
start_worker()
|
|
918
|
+
else:
|
|
919
|
+
if not limit_warned:
|
|
920
|
+
limit_warned = True
|
|
921
|
+
logger.warn(
|
|
922
|
+
f"{alive} thread(s) abandoned by timed-out calls are still running, more than the "
|
|
923
|
+
f"limit of {limit} (ROBOT_MAX_ABANDONED_THREADS): timed-out workers are no longer "
|
|
924
|
+
"replaced, so the remaining calls run with fewer workers."
|
|
925
|
+
)
|
|
926
|
+
if active == 0:
|
|
927
|
+
# No worker left to run them: fail them now instead of waiting forever.
|
|
928
|
+
self._fail_not_started(pending, items, started_at, finished, log_buffers, limit)
|
|
929
|
+
if remaining:
|
|
469
930
|
time.sleep(_POLL_INTERVAL)
|
|
470
931
|
except BaseException as error:
|
|
471
932
|
stop.set()
|
|
472
|
-
|
|
933
|
+
if cancel_events is not None:
|
|
934
|
+
for event in cancel_events:
|
|
935
|
+
event.set()
|
|
936
|
+
# The calls still running are abandoned too: they count towards the limit.
|
|
937
|
+
for index in range(count):
|
|
938
|
+
if attempt_started[index] is not None and results[index] is None and finished[index] is None:
|
|
939
|
+
_abandoned_threads.append(threads[index])
|
|
940
|
+
self._replay_interrupted(
|
|
941
|
+
items, results, finished, started_at, log_buffers, error, remove_passing_logs, min_level
|
|
942
|
+
)
|
|
473
943
|
raise
|
|
474
944
|
return results
|
|
475
945
|
|
|
476
|
-
|
|
946
|
+
@staticmethod
|
|
947
|
+
def _timed_out_result(
|
|
948
|
+
item, log_buffer: list, call_timeout: float, attempt: int = 1, not_retried_limit: Optional[int] = None
|
|
949
|
+
) -> Dict[str, Any]:
|
|
950
|
+
"""``not_retried_limit``: the abandoned thread limit, when it is what
|
|
951
|
+
kept the call from being retried (not given: no retries were left)."""
|
|
952
|
+
duration = secs_to_timestr(call_timeout)
|
|
953
|
+
logs = list(log_buffer)
|
|
954
|
+
logs.append(("ERROR", f"Call for item {item} timed out after {duration} (call_timeout); "
|
|
955
|
+
"it keeps running in the background"))
|
|
956
|
+
if not_retried_limit is not None:
|
|
957
|
+
logs.append(("ERROR", f"Call for item {item} not retried: the abandoned thread limit "
|
|
958
|
+
f"({not_retried_limit}, ROBOT_MAX_ABANDONED_THREADS) was reached"))
|
|
959
|
+
return {"status": "FAIL", "item": item, "logs": logs, "error": f"Timed out after {duration}", "attempts": attempt}
|
|
960
|
+
|
|
961
|
+
@staticmethod
|
|
962
|
+
def _fail_not_started(pending, items, started_at, finished, log_buffers, limit: int) -> None:
|
|
963
|
+
"""Fails every call still waiting in ``pending``. Only called when no
|
|
964
|
+
worker is left, so nothing else reads the queue. The results are
|
|
965
|
+
picked up by the main loop like those of finished calls."""
|
|
966
|
+
while True:
|
|
967
|
+
try:
|
|
968
|
+
index, attempt, _ = pending.get_nowait()
|
|
969
|
+
except queue.Empty:
|
|
970
|
+
return
|
|
971
|
+
item = items[index]
|
|
972
|
+
log_buffers[index].append((
|
|
973
|
+
"ERROR",
|
|
974
|
+
f"Call for item {item} not started: every worker was abandoned by a timed-out call and "
|
|
975
|
+
f"the abandoned thread limit ({limit}, ROBOT_MAX_ABANDONED_THREADS) was reached",
|
|
976
|
+
))
|
|
977
|
+
finished[index] = {
|
|
978
|
+
"status": "FAIL", "item": item, "logs": log_buffers[index],
|
|
979
|
+
"error": f"Not started: abandoned thread limit ({limit}) reached", "attempts": attempt - 1,
|
|
980
|
+
}
|
|
981
|
+
if started_at[index] is None:
|
|
982
|
+
started_at[index] = time.monotonic()
|
|
983
|
+
|
|
984
|
+
def _replay_interrupted(self, items, results, finished, started_at, log_buffers, error, remove_passing_logs, min_level):
|
|
477
985
|
"""Replays what the calls logged before the keyword was interrupted.
|
|
478
986
|
Calls that never started are only counted, not listed one by one."""
|
|
479
987
|
# On Windows Robot raises the timeout as a bare class, without message.
|
|
@@ -481,12 +989,12 @@ class ParallelRunner:
|
|
|
481
989
|
partial = []
|
|
482
990
|
running = not_started = 0
|
|
483
991
|
for index, item in enumerate(items):
|
|
484
|
-
# Read the
|
|
485
|
-
# is then reported as still running, never as not started.
|
|
486
|
-
result = results[index]
|
|
992
|
+
# Read the results before `started_at`: a call that finishes in
|
|
993
|
+
# between is then reported as still running, never as not started.
|
|
994
|
+
result = results[index] or finished[index]
|
|
487
995
|
if result is not None:
|
|
488
996
|
partial.append(result)
|
|
489
|
-
elif
|
|
997
|
+
elif started_at[index] is not None:
|
|
490
998
|
running += 1
|
|
491
999
|
logs = list(log_buffers[index])
|
|
492
1000
|
logs.append(("ERROR", f"Call for item {item} did not finish: still running when the keyword was interrupted ({reason})"))
|
|
@@ -500,14 +1008,22 @@ class ParallelRunner:
|
|
|
500
1008
|
self._replay_logs(partial, remove_passing_logs, min_level)
|
|
501
1009
|
|
|
502
1010
|
def _execute_and_capture(
|
|
503
|
-
self, method, item, logger_mapper: Optional[Any], log_buffer: list, kwargs: Dict[str, Any]
|
|
1011
|
+
self, method, item, logger_mapper: Optional[Any], log_buffer: list, kwargs: Dict[str, Any],
|
|
1012
|
+
argument: Any = _NOT_SET, log_failure: bool = True,
|
|
504
1013
|
) -> Dict[str, Any]:
|
|
505
1014
|
"""Worker wrapper to capture logs and results.
|
|
506
1015
|
|
|
507
|
-
``
|
|
508
|
-
|
|
509
|
-
|
|
1016
|
+
``log_failure=False`` (an attempt that will be retried) leaves out the
|
|
1017
|
+
``ERROR`` line of a failure; the caller logs the attempt as a warning.
|
|
1018
|
+
|
|
1019
|
+
``argument`` is what the call receives as its first argument (``item``
|
|
1020
|
+
converted by its type hint), ``item`` what the result reports. ``kwargs``
|
|
1021
|
+
is a dict, not ``**kwargs``, so a keyword argument that happens to be
|
|
1022
|
+
called ``method``, ``item`` or ``log_buffer`` can't clash with this
|
|
1023
|
+
method's own parameters.
|
|
510
1024
|
"""
|
|
1025
|
+
if argument is _NOT_SET:
|
|
1026
|
+
argument = item
|
|
511
1027
|
# Injection of a custom logger into the thread
|
|
512
1028
|
def thread_log(msg, level="INFO"):
|
|
513
1029
|
# Filter out IGNORE (sv=0) level logs. Other levels are kept as
|
|
@@ -519,13 +1035,14 @@ class ParallelRunner:
|
|
|
519
1035
|
effective_logger = logger_mapper if logger_mapper else thread_log
|
|
520
1036
|
|
|
521
1037
|
try:
|
|
522
|
-
result = method(
|
|
1038
|
+
result = method(argument, _logger=effective_logger, **kwargs)
|
|
523
1039
|
return {"status": "PASS", "item": item, "logs": log_buffer, "result": result}
|
|
524
1040
|
# BaseException, not Exception: whatever the call raises (even
|
|
525
1041
|
# SystemExit), the worker must report a result, or the main thread
|
|
526
1042
|
# would wait for this call forever.
|
|
527
1043
|
except BaseException as e:
|
|
528
|
-
|
|
1044
|
+
if log_failure:
|
|
1045
|
+
thread_log(f"Thread failed for item {item}: {str(e)}", "ERROR")
|
|
529
1046
|
return {"status": "FAIL", "item": item, "logs": log_buffer, "error": str(e)}
|
|
530
1047
|
|
|
531
1048
|
def _replay_logs(self, results: List[Dict[str, Any]], remove_passing_logs: bool = False, min_level: int = 0):
|
ParallelRunner/retry.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Retries of failed calls in `Run Parallel Scenarios`: the ``call_retries``,
|
|
2
|
+
``retry_delay`` and ``retry_backoff`` arguments.
|
|
3
|
+
|
|
4
|
+
This module only decides *whether* and *when* a call is retried, and what is
|
|
5
|
+
logged between attempts. Running the attempts on the worker threads (and
|
|
6
|
+
retrying calls that exceeded ``call_timeout``) is done by
|
|
7
|
+
``ParallelRunner._run_in_threads``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from robot.errors import DataError
|
|
15
|
+
from robot.utils import secs_to_timestr, timestr_to_secs
|
|
16
|
+
|
|
17
|
+
DEFAULT_RETRY_DELAY = 1.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _not_given(value: Any) -> bool:
|
|
21
|
+
"""None, an empty string or "None" (any case), like in parallel_library."""
|
|
22
|
+
return value is None or (isinstance(value, str) and value.strip().upper() in ("", "NONE"))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class RetryPolicy:
|
|
27
|
+
"""How often and how long apart a failed call is tried again.
|
|
28
|
+
|
|
29
|
+
``retries`` extra attempts after the first one; before attempt ``n + 1``
|
|
30
|
+
the call waits ``delay * backoff ** (n - 1)`` seconds (``backoff=1``: a
|
|
31
|
+
fixed delay; ``backoff=2``: 1 s, 2 s, 4 s...).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
retries: int = 0
|
|
35
|
+
delay: float = DEFAULT_RETRY_DELAY
|
|
36
|
+
backoff: float = 1.0
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def attempts(self) -> int:
|
|
40
|
+
"""Maximum number of attempts per call."""
|
|
41
|
+
return self.retries + 1
|
|
42
|
+
|
|
43
|
+
def can_retry(self, attempt: int) -> bool:
|
|
44
|
+
"""Whether a call whose attempt number ``attempt`` (1-based) failed may run again."""
|
|
45
|
+
return attempt < self.attempts
|
|
46
|
+
|
|
47
|
+
def delay_after(self, attempt: int) -> float:
|
|
48
|
+
"""Seconds to wait between failed attempt ``attempt`` and the next one."""
|
|
49
|
+
return self.delay * self.backoff ** (attempt - 1)
|
|
50
|
+
|
|
51
|
+
def failed_message(self, attempt: int, error: str) -> str:
|
|
52
|
+
return f"Attempt {attempt} of {self.attempts} failed: {error}; {self._retrying(attempt)}"
|
|
53
|
+
|
|
54
|
+
def timed_out_message(self, attempt: int, duration: str) -> str:
|
|
55
|
+
return (
|
|
56
|
+
f"Attempt {attempt} of {self.attempts} timed out after {duration} (call_timeout), "
|
|
57
|
+
f"it keeps running in the background; {self._retrying(attempt)}"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def _retrying(self, attempt: int) -> str:
|
|
61
|
+
delay = self.delay_after(attempt)
|
|
62
|
+
return f"retrying in {secs_to_timestr(delay)}" if delay > 0 else "retrying now"
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_arguments(cls, call_retries: Any = None, retry_delay: Any = None, retry_backoff: Any = None) -> "RetryPolicy":
|
|
66
|
+
"""Validates the keyword arguments, in Robot's formats: ``call_retries``
|
|
67
|
+
a whole number, ``retry_delay`` a Robot time (``1s``, ``500ms``),
|
|
68
|
+
``retry_backoff`` a number. Not given: no retries, a 1 second delay,
|
|
69
|
+
no backoff. An invalid value raises ``ValueError`` before anything runs."""
|
|
70
|
+
return cls(
|
|
71
|
+
retries=cls._resolve_retries(call_retries),
|
|
72
|
+
delay=cls._resolve_delay(retry_delay),
|
|
73
|
+
backoff=cls._resolve_backoff(retry_backoff),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _resolve_retries(value: Any) -> int:
|
|
78
|
+
if _not_given(value):
|
|
79
|
+
return 0
|
|
80
|
+
try:
|
|
81
|
+
# Through str: rejects 1.5 (and True) instead of truncating it.
|
|
82
|
+
retries = int(str(value).strip())
|
|
83
|
+
except ValueError:
|
|
84
|
+
retries = -1
|
|
85
|
+
if retries < 0:
|
|
86
|
+
raise ValueError(f"Invalid call_retries {value!r}: expected a whole number of at least 0.")
|
|
87
|
+
return retries
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def _resolve_delay(value: Any) -> float:
|
|
91
|
+
if _not_given(value):
|
|
92
|
+
return DEFAULT_RETRY_DELAY
|
|
93
|
+
try:
|
|
94
|
+
seconds = timestr_to_secs(value)
|
|
95
|
+
except (ValueError, DataError) as error:
|
|
96
|
+
raise ValueError(f"Invalid retry_delay {value!r}: {error}") from None
|
|
97
|
+
if seconds < 0:
|
|
98
|
+
raise ValueError(f"retry_delay can't be negative, got {value!r}.")
|
|
99
|
+
return seconds
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _resolve_backoff(value: Any) -> float:
|
|
103
|
+
if _not_given(value):
|
|
104
|
+
return 1.0
|
|
105
|
+
try:
|
|
106
|
+
backoff = float(str(value).strip())
|
|
107
|
+
except ValueError:
|
|
108
|
+
raise ValueError(f"Invalid retry_backoff {value!r}: expected a number of at least 1.") from None
|
|
109
|
+
if not 1 <= backoff < math.inf: # also rejects NaN
|
|
110
|
+
raise ValueError(f"retry_backoff must be at least 1, got {value!r}.")
|
|
111
|
+
return backoff
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
NO_RETRIES = RetryPolicy()
|
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: robotframework-parallelrunner
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Run a keyword in parallel inside a single Robot Framework test case, with one clean, ordered log.html.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
7
7
|
Keywords: robotframework,robot-framework,parallel,threading,testing,automation
|
|
8
8
|
Author: Cristian Garcia
|
|
9
9
|
Author-email: cristian.garcia.vd@gmail.com
|
|
10
|
-
Requires-Python: >=3.
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
11
|
Classifier: Development Status :: 4 - Beta
|
|
12
12
|
Classifier: Framework :: Robot Framework
|
|
13
13
|
Classifier: Framework :: Robot Framework :: Library
|
|
14
14
|
Classifier: Intended Audience :: Developers
|
|
15
15
|
Classifier: Operating System :: OS Independent
|
|
16
16
|
Classifier: Programming Language :: Python :: 3
|
|
17
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
18
17
|
Classifier: Programming Language :: Python :: 3.10
|
|
19
18
|
Classifier: Programming Language :: Python :: 3.11
|
|
20
19
|
Classifier: Programming Language :: Python :: 3.12
|
|
@@ -53,9 +52,10 @@ correctly ordered `log.html` plus a structured list of per-item results.
|
|
|
53
52
|
pip install robotframework-parallelrunner
|
|
54
53
|
```
|
|
55
54
|
|
|
56
|
-
Requires Python 3.
|
|
57
|
-
See [docs/INSTALLATION.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/docs/INSTALLATION.md) for development installs
|
|
58
|
-
|
|
55
|
+
Requires Python 3.10+ and Robot Framework 5.0+ (installed automatically).
|
|
56
|
+
See [docs/INSTALLATION.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/docs/INSTALLATION.md) for development installs,
|
|
57
|
+
upgrading from the pre-release import path, and Python 3.8/3.9 (no longer
|
|
58
|
+
supported, but 0.5.0 was tested and works on them).
|
|
59
59
|
|
|
60
60
|
## Quickstart
|
|
61
61
|
|
|
@@ -113,8 +113,8 @@ threading, buffering, and replay logic stay out of your `.robot` files.
|
|
|
113
113
|
## Summary of Benefits
|
|
114
114
|
|
|
115
115
|
- **Speed.** Waiting time is overlapped instead of added up. In this repo's
|
|
116
|
-
examples, 9 HTTP checks with a 1 s delay each drop from 13 s to 2 s with
|
|
117
|
-
9 workers, and 4 suites × 8 items × 1 s drop from 32.
|
|
116
|
+
examples, 9 HTTP checks with a 1 s delay each drop from 13.9 s to 2.2 s with
|
|
117
|
+
9 workers, and 4 suites × 8 items × 1 s drop from 32.9 s to 5.1 s with
|
|
118
118
|
8 workers (see [Using It Together With pabot](#using-it-together-with-pabot)).
|
|
119
119
|
- **Integrity.** Your `log.html` remains the single source of truth — no
|
|
120
120
|
broken XML tags from concurrent writes.
|
|
@@ -183,7 +183,7 @@ pabot --processes 4 --pythonpath . atest/
|
|
|
183
183
|
pabot --processes 4 --testlevelsplit --pythonpath . atest/
|
|
184
184
|
```
|
|
185
185
|
|
|
186
|
-
Both commands pass the full acceptance suite of this repository (
|
|
186
|
+
Both commands pass the full acceptance suite of this repository (82 tests;
|
|
187
187
|
checked with pabot 5.2.2).
|
|
188
188
|
|
|
189
189
|
How much time each one saves, from the offline
|
|
@@ -192,14 +192,14 @@ How much time each one saves, from the offline
|
|
|
192
192
|
|
|
193
193
|
| Run | Wall-clock time | Speedup |
|
|
194
194
|
|---|---|---|
|
|
195
|
-
| robot, sequential loop | 32.
|
|
196
|
-
| robot + ParallelRunner (8 workers) |
|
|
197
|
-
| pabot (4 processes), sequential loop |
|
|
198
|
-
| pabot (4 processes) + ParallelRunner (8 workers) |
|
|
195
|
+
| robot, sequential loop | 32.9 s | ×1.0 |
|
|
196
|
+
| robot + ParallelRunner (8 workers) | 5.1 s | ×6.5 |
|
|
197
|
+
| pabot (4 processes), sequential loop | 12.4 s | ×2.7 |
|
|
198
|
+
| pabot (4 processes) + ParallelRunner (8 workers) | 4.6 s | ×7.2 |
|
|
199
199
|
|
|
200
200
|
ParallelRunner removes the waiting *inside* each test, and pabot removes it
|
|
201
201
|
*between* suites. With suites this short, pabot's process start-up (about
|
|
202
|
-
|
|
202
|
+
3.5 s here) is most of what's left, so the combination pays off more as
|
|
203
203
|
suites get longer.
|
|
204
204
|
|
|
205
205
|
Things to keep in mind:
|
|
@@ -221,6 +221,31 @@ Things to keep in mind:
|
|
|
221
221
|
could interfere with each other. Keep shared state per process, or split
|
|
222
222
|
by suite.
|
|
223
223
|
|
|
224
|
+
## How Is This Different From AsyncLibrary?
|
|
225
|
+
|
|
226
|
+
[AsyncLibrary](https://github.com/rlehfeld/robotframework-async-keyword)
|
|
227
|
+
(`pip install robotframework-async-keyword`) also runs keywords in threads
|
|
228
|
+
inside one test case. It runs *any* keyword, user keywords included: you
|
|
229
|
+
start each call with `Async Run` and collect it later with `Async Get`. It
|
|
230
|
+
buffers each call's output and replays it, so `log.html` keeps the full
|
|
231
|
+
nested keyword structure. To do that, it works with Robot Framework's
|
|
232
|
+
internal execution context and output.
|
|
233
|
+
|
|
234
|
+
| | **AsyncLibrary** | **ParallelRunner** |
|
|
235
|
+
|---|---|---|
|
|
236
|
+
| Runs | Any keyword, including user keywords | A method of a Python library that accepts `_logger` |
|
|
237
|
+
| Usage | `Async Run` per call, `Async Get` later | One `Run Parallel Scenarios` call over a list, or `repeat=N` |
|
|
238
|
+
| Concurrency | Python's default thread pool size | `ROBOT_THREAD_WORKERS` |
|
|
239
|
+
| Hung calls | `Async Get timeout=...` stops waiting for them | `call_timeout` fails only that call and frees its slot for the next item |
|
|
240
|
+
| Failures | `Async Get` raises the call's error | One failure listing every failed item, or a `PASS`/`FAIL` per item with `fail_on_error=False` |
|
|
241
|
+
| Robot internals | Works with the internal execution context and output | Not patched; logs only from the main thread, through the public logger API; tested on Robot Framework 5, 6 and 7 |
|
|
242
|
+
|
|
243
|
+
Use AsyncLibrary when the work lives in user keywords (or in library
|
|
244
|
+
keywords you can't change), or when you want to start something in the
|
|
245
|
+
background, run other steps, and collect it later. Use ParallelRunner to fan
|
|
246
|
+
one Python keyword out over many items as a single batch, with a bounded
|
|
247
|
+
worker pool, per-call timeouts and one failure report for the whole batch.
|
|
248
|
+
|
|
224
249
|
## When Not To Use This
|
|
225
250
|
|
|
226
251
|
- **CPU-bound work.** Threads share Python's GIL — number crunching won't
|
|
@@ -230,11 +255,6 @@ Things to keep in mind:
|
|
|
230
255
|
made thread-safe for you; your own keyword's side effects are not. If it
|
|
231
256
|
writes to a shared variable, file, or object without a lock, running it
|
|
232
257
|
concurrently can race the same way any multi-threaded code can.
|
|
233
|
-
- **You need per-item retries or a per-item timeout.** Not implemented yet
|
|
234
|
-
(see [ROADMAP.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/ROADMAP.md)). A Robot `[Timeout]` on the test or keyword does
|
|
235
|
-
interrupt the whole batch when it expires and replays the logs collected so
|
|
236
|
-
far, but calls that are already running can't be stopped: they keep running
|
|
237
|
-
in the background until they return.
|
|
238
258
|
- **You need process-level isolation** (a crash in one call shouldn't be
|
|
239
259
|
able to affect another) or cross-machine parallelism — that's pabot's
|
|
240
260
|
domain, not this library's.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
ParallelRunner/__init__.py,sha256=x74aR4t2SZvD81MepoTobACqyLfX_rKaEWPJY08kvTA,671
|
|
2
|
+
ParallelRunner/parallel_library.py,sha256=KN38cWRcvwHn-xqwC98GfJ_Xdqpy7Rw46z0Cl8yJ9-0,55535
|
|
3
|
+
ParallelRunner/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
ParallelRunner/retry.py,sha256=uhnDzuVELxAeF6bm02wSFnvnvXpMV42KLRZ_LnMK-Go,4363
|
|
5
|
+
ParallelRunner/version.py,sha256=W8E8ymdTLqfKl_1qBIOrjGpO8kSbqPw30aYZpqYUNMk,632
|
|
6
|
+
parallelrunner.py,sha256=2oyKOVeKlOaZUZ3f2i_l5xCAYFKVm11bSQZOtzSSJO8,1132
|
|
7
|
+
robotframework_parallelrunner-0.5.0.dist-info/METADATA,sha256=YtAS0UxexW3dk1ta9V7auqMyGvtFR_iujD_u1tkPx-w,15829
|
|
8
|
+
robotframework_parallelrunner-0.5.0.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
|
|
9
|
+
robotframework_parallelrunner-0.5.0.dist-info/licenses/LICENSE,sha256=xxQh3uvlk5jC1i0eoH5Nyo9cDz2Fzu0DqOzAZw8XxFs,1072
|
|
10
|
+
robotframework_parallelrunner-0.5.0.dist-info/RECORD,,
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
ParallelRunner/__init__.py,sha256=ifsoO1rXf4kL3i75NokrD_KHxnlvE3IuH69Fb-ByfNg,641
|
|
2
|
-
ParallelRunner/parallel_library.py,sha256=wF-gIBbVl2D0qD9GGL5dnC49KpGl1Sfy_fq50kKMHyQ,28018
|
|
3
|
-
ParallelRunner/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
-
ParallelRunner/version.py,sha256=W8E8ymdTLqfKl_1qBIOrjGpO8kSbqPw30aYZpqYUNMk,632
|
|
5
|
-
parallelrunner.py,sha256=2oyKOVeKlOaZUZ3f2i_l5xCAYFKVm11bSQZOtzSSJO8,1132
|
|
6
|
-
robotframework_parallelrunner-0.3.1.dist-info/METADATA,sha256=qkPgEByTDt4P8tbArMmtXEuTmqmczGrBPJDTUFdj5iM,14501
|
|
7
|
-
robotframework_parallelrunner-0.3.1.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
|
|
8
|
-
robotframework_parallelrunner-0.3.1.dist-info/licenses/LICENSE,sha256=xxQh3uvlk5jC1i0eoH5Nyo9cDz2Fzu0DqOzAZw8XxFs,1072
|
|
9
|
-
robotframework_parallelrunner-0.3.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|