robotframework-parallelrunner 0.3.1__py3-none-any.whl → 0.4.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 +329 -41
- {robotframework_parallelrunner-0.3.1.dist-info → robotframework_parallelrunner-0.4.0.dist-info}/METADATA +6 -6
- robotframework_parallelrunner-0.4.0.dist-info/RECORD +9 -0
- robotframework_parallelrunner-0.3.1.dist-info/RECORD +0 -9
- {robotframework_parallelrunner-0.3.1.dist-info → robotframework_parallelrunner-0.4.0.dist-info}/WHEEL +0 -0
- {robotframework_parallelrunner-0.3.1.dist-info → robotframework_parallelrunner-0.4.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,15 +1,19 @@
|
|
|
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
|
|
|
14
18
|
from .version import __version__
|
|
15
19
|
|
|
@@ -35,6 +39,47 @@ def _normalize_level(level: Any) -> Optional[str]:
|
|
|
35
39
|
return name if name in LOG_LEVELS or name == "IGNORE" else None
|
|
36
40
|
|
|
37
41
|
|
|
42
|
+
def _normalize_name(name: str) -> str:
|
|
43
|
+
"""Keyword name comparison the way Robot does it: ignoring case, spaces and underscores."""
|
|
44
|
+
return name.lower().replace(" ", "").replace("_", "")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _not_given(value: Any) -> bool:
|
|
48
|
+
"""None, an empty string or "None" (any case - Robot's usual spelling of None)."""
|
|
49
|
+
return value is None or (isinstance(value, str) and value.strip().upper() in ("", "NONE"))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Who owns the result of a call that may time out: the worker that ran it, or
|
|
53
|
+
# the main thread that timed it out. See `ParallelRunner._run_in_threads`.
|
|
54
|
+
_WORKER, _MAIN = "worker", "main"
|
|
55
|
+
_NOT_SET = object()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def log_to_robot(msg: Any, level: str = "INFO") -> None:
|
|
59
|
+
"""Logs ``msg`` straight to Robot Framework, accepting the same levels as
|
|
60
|
+
the ``_logger`` that `Run Parallel Scenarios` injects.
|
|
61
|
+
|
|
62
|
+
Use it as the fallback when a parallel-ready keyword is called directly
|
|
63
|
+
(without `Run Parallel Scenarios`)::
|
|
64
|
+
|
|
65
|
+
from ParallelRunner import log_to_robot
|
|
66
|
+
|
|
67
|
+
def verify_agent(self, agent_id, _logger=None):
|
|
68
|
+
log = _logger or log_to_robot
|
|
69
|
+
log("details", "DEBUG")
|
|
70
|
+
|
|
71
|
+
Unlike ``robot.api.logger.write``, it accepts ``IGNORE`` (dropped), any
|
|
72
|
+
case and Python ``logging`` names (``WARNING``), and logs a message with an
|
|
73
|
+
unknown level at INFO, prefixed with the level, instead of failing.
|
|
74
|
+
"""
|
|
75
|
+
name = _normalize_level(level)
|
|
76
|
+
if name == "IGNORE":
|
|
77
|
+
return
|
|
78
|
+
if name is None:
|
|
79
|
+
name, msg = "INFO", f"[{level}] {msg}"
|
|
80
|
+
logger.write(msg, name)
|
|
81
|
+
|
|
82
|
+
|
|
38
83
|
class ParallelTaskError(RuntimeError):
|
|
39
84
|
"""
|
|
40
85
|
Raised when at least one parallel task failed: by ``run_parallel_scenarios``
|
|
@@ -93,18 +138,33 @@ class ParallelRunner:
|
|
|
93
138
|
index) as its first argument and an injected ``_logger`` callable that
|
|
94
139
|
it should use instead of ``robot.api.logger``:
|
|
95
140
|
|
|
96
|
-
| from
|
|
141
|
+
| from ParallelRunner import log_to_robot
|
|
97
142
|
|
|
|
98
143
|
| class MyLibrary:
|
|
99
|
-
| def verify_agent(self, agent_id, _logger=None, **kwargs):
|
|
100
|
-
| log = _logger or
|
|
144
|
+
| def verify_agent(self, agent_id: int, timeout: float = 10, _logger=None, **kwargs):
|
|
145
|
+
| log = _logger or log_to_robot
|
|
101
146
|
| log(f"Checking agent {agent_id}")
|
|
102
147
|
| ...
|
|
103
148
|
| return result
|
|
104
149
|
|
|
105
150
|
``_logger(msg, level)`` accepts every Robot Framework log level (see
|
|
106
|
-
`Log levels`) plus ``IGNORE`` (dropped).
|
|
107
|
-
|
|
151
|
+
`Log levels`) plus ``IGNORE`` (dropped). ``log_to_robot`` is the fallback
|
|
152
|
+
for when the keyword is called directly: it accepts the same levels
|
|
153
|
+
(``robot.api.logger.write`` fails on ``IGNORE`` or ``WARNING``).
|
|
154
|
+
|
|
155
|
+
Any extra named argument given to `Run Parallel Scenarios` is forwarded
|
|
156
|
+
to every call as ``**kwargs``. Arguments are converted by the method's
|
|
157
|
+
type hints (and default values) exactly as when the keyword is called
|
|
158
|
+
directly: ``timeout=5`` arrives as the number ``5.0`` above. Items that
|
|
159
|
+
are strings are converted the same way; other items (numbers, lists,
|
|
160
|
+
dictionaries from a variable) are passed as they are.
|
|
161
|
+
|
|
162
|
+
The keyword is found by its method name (``Verify Agent`` ->
|
|
163
|
+
``verify_agent``) or by a custom name given with ``@keyword("...")``,
|
|
164
|
+
ignoring case, spaces and underscores, like Robot does.
|
|
165
|
+
|
|
166
|
+
To be stoppable when it times out, the method can also declare a
|
|
167
|
+
``_cancelled`` parameter: see `Call timeouts and cancellation`.
|
|
108
168
|
|
|
109
169
|
Logging is made thread-safe for you; your keyword's own side effects are
|
|
110
170
|
not. Protect shared state (files, shared objects) with a lock.
|
|
@@ -118,7 +178,7 @@ class ParallelRunner:
|
|
|
118
178
|
| item | The item (or repeat index) the call received. |
|
|
119
179
|
| logs | List of ``(level, message)`` tuples captured during the call. |
|
|
120
180
|
| result | The return value of the call (only when ``status`` is ``PASS``). |
|
|
121
|
-
| error | The error message (only when ``status`` is ``FAIL``). |
|
|
181
|
+
| error | The error message (only when ``status`` is ``FAIL``), e.g. ``Timed out after 10 seconds`` with ``call_timeout``. |
|
|
122
182
|
|
|
123
183
|
= Failures =
|
|
124
184
|
|
|
@@ -168,10 +228,48 @@ class ParallelRunner:
|
|
|
168
228
|
|
|
169
229
|
Python threads can't be killed, so calls that are already running keep
|
|
170
230
|
running in the background until they return (possibly while the next test
|
|
171
|
-
runs)
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
231
|
+
runs) - unless they cooperate, see `Call timeouts and cancellation`.
|
|
232
|
+
Calls that had not started yet are not started. The worker threads are
|
|
233
|
+
daemon threads, so they never keep the Robot process alive after the run.
|
|
234
|
+
A keyword that may hang (e.g. network I/O) should set its own timeouts
|
|
235
|
+
(e.g. ``requests.get(url, timeout=10)``).
|
|
236
|
+
|
|
237
|
+
A Robot timeout fails the whole test and can't be handled with
|
|
238
|
+
`Run Keyword And Ignore Error`. To fail only the call that hangs, use
|
|
239
|
+
``call_timeout``.
|
|
240
|
+
|
|
241
|
+
= Call timeouts and cancellation =
|
|
242
|
+
|
|
243
|
+
``call_timeout`` limits how long *each call* may run, e.g.
|
|
244
|
+
``call_timeout=10s`` (Robot's time format). A call still running after
|
|
245
|
+
that becomes a failed call - error ``Timed out after 10 seconds``, with its
|
|
246
|
+
partial logs - while the other calls keep running and finish normally. It
|
|
247
|
+
is then reported like any other failed call: `Run Parallel Scenarios`
|
|
248
|
+
fails with ``ParallelTaskError`` (which, unlike a Robot timeout, can be
|
|
249
|
+
handled), or returns it with ``status`` ``FAIL`` when
|
|
250
|
+
``fail_on_error=False``.
|
|
251
|
+
|
|
252
|
+
| ${results}= | Run Parallel Scenarios | keyword=Verify Agent | library=MyLibrary | for_loop_iterable=${agents} | call_timeout=10s | fail_on_error=False |
|
|
253
|
+
|
|
254
|
+
The timed-out call can't be killed either: it keeps running in the
|
|
255
|
+
background, and a new worker thread takes its place so the remaining
|
|
256
|
+
calls still run ``ROBOT_THREAD_WORKERS`` at a time.
|
|
257
|
+
|
|
258
|
+
A keyword that can stop early declares a ``_cancelled`` parameter.
|
|
259
|
+
`Run Parallel Scenarios` then passes it a ``threading.Event`` that is set
|
|
260
|
+
when that call times out, or when a Robot ``[Timeout]`` interrupts the
|
|
261
|
+
batch. The keyword checks it between steps, or waits on it instead of
|
|
262
|
+
sleeping:
|
|
263
|
+
|
|
264
|
+
| def poll_job(self, job_id, _logger=None, _cancelled=None):
|
|
265
|
+
| while not job_done(job_id):
|
|
266
|
+
| if _cancelled is not None and _cancelled.wait(1): # sleeps 1 s, wakes up if cancelled
|
|
267
|
+
| _logger(f"Stopped waiting for {job_id}", "WARN")
|
|
268
|
+
| return None
|
|
269
|
+
|
|
270
|
+
``_cancelled`` is only passed to methods that declare it by that name, so
|
|
271
|
+
existing keywords keep working unchanged. It can't interrupt a call that
|
|
272
|
+
is blocked in a single operation (a network request without a timeout).
|
|
175
273
|
|
|
176
274
|
= Custom logger mappers =
|
|
177
275
|
|
|
@@ -196,7 +294,7 @@ class ParallelRunner:
|
|
|
196
294
|
= Environment variables =
|
|
197
295
|
|
|
198
296
|
| =Variable= | =Description= |
|
|
199
|
-
| ROBOT_THREAD_WORKERS | Number of worker threads. Read when the library is imported. Default ``4``. |
|
|
297
|
+
| ROBOT_THREAD_WORKERS | Number of worker threads. Read when the library is imported. Default ``4``. An invalid value is ignored with a warning. |
|
|
200
298
|
| ROBOT_LOGGER_MAPPER | Default ``logger_mapper`` as a ``module.function`` path, used when the argument is not given. See `Custom logger mappers`. |
|
|
201
299
|
|
|
202
300
|
= When not to use it =
|
|
@@ -210,9 +308,29 @@ class ParallelRunner:
|
|
|
210
308
|
ROBOT_LIBRARY_VERSION = __version__
|
|
211
309
|
ROBOT_LIBRARY_DOC_FORMAT = 'ROBOT'
|
|
212
310
|
|
|
311
|
+
DEFAULT_WORKERS = 4
|
|
312
|
+
|
|
213
313
|
def __init__(self):
|
|
214
|
-
|
|
215
|
-
|
|
314
|
+
self.max_workers = self._workers_from_environment()
|
|
315
|
+
|
|
316
|
+
def _workers_from_environment(self) -> int:
|
|
317
|
+
"""``ROBOT_THREAD_WORKERS``, or 4. An invalid value is ignored with a
|
|
318
|
+
warning (it used to make the library import fail with a bare
|
|
319
|
+
``invalid literal for int()``)."""
|
|
320
|
+
value = os.getenv("ROBOT_THREAD_WORKERS")
|
|
321
|
+
if _not_given(value):
|
|
322
|
+
return self.DEFAULT_WORKERS
|
|
323
|
+
try:
|
|
324
|
+
workers = int(value)
|
|
325
|
+
except ValueError:
|
|
326
|
+
workers = 0
|
|
327
|
+
if workers < 1:
|
|
328
|
+
logger.warn(
|
|
329
|
+
f"Ignoring ROBOT_THREAD_WORKERS {value!r}: expected a whole number of at least 1. "
|
|
330
|
+
f"Using {self.DEFAULT_WORKERS} workers."
|
|
331
|
+
)
|
|
332
|
+
return self.DEFAULT_WORKERS
|
|
333
|
+
return workers
|
|
216
334
|
|
|
217
335
|
def run_parallel_scenarios(
|
|
218
336
|
self,
|
|
@@ -225,6 +343,7 @@ class ParallelRunner:
|
|
|
225
343
|
logger_mapper: Optional[Any] = None,
|
|
226
344
|
return_values_only: bool = False,
|
|
227
345
|
fail_on_error: bool = True,
|
|
346
|
+
call_timeout: Union[str, float, None] = None,
|
|
228
347
|
**kwargs
|
|
229
348
|
) -> Union[List[Dict[str, Any]], Tuple[Any, ...]]:
|
|
230
349
|
"""Runs ``keyword`` from ``library`` concurrently, once per item or N times.
|
|
@@ -263,7 +382,14 @@ class ParallelRunner:
|
|
|
263
382
|
when any call failed, after all calls have finished and their logs
|
|
264
383
|
have been replayed. If false, return the results anyway and leave
|
|
265
384
|
checking ``status`` to the caller. See `Failures`.
|
|
266
|
-
-
|
|
385
|
+
- ``call_timeout``: Maximum time each call may run, in Robot's time
|
|
386
|
+
format (``10s``, ``1 minute 30 seconds``, ``2.5``). A call still
|
|
387
|
+
running after it fails with ``Timed out after ...`` while the other
|
|
388
|
+
calls keep running; calls that declare ``_cancelled`` are told to
|
|
389
|
+
stop. Not given (default): no limit. See
|
|
390
|
+
`Call timeouts and cancellation`.
|
|
391
|
+
- ``**kwargs``: Any other named argument is passed to every call,
|
|
392
|
+
converted by the method's type hints like in a direct call.
|
|
267
393
|
|
|
268
394
|
Returns a list of result dictionaries (see `Result format`), in call
|
|
269
395
|
order: entry ``i`` belongs to ``for_loop_iterable[i]`` / repeat
|
|
@@ -276,6 +402,7 @@ class ParallelRunner:
|
|
|
276
402
|
| ${results}= | Run Parallel Scenarios | keyword=Check Health | library=MyLibrary | repeat=8 | agent_id=1 |
|
|
277
403
|
| ${id1} ${id2} ${id3}= | Run Parallel Scenarios | keyword=Seed Record | library=MyLibrary | repeat=3 | return_values_only=True |
|
|
278
404
|
| ${results}= | Run Parallel Scenarios | keyword=Verify Agent Data | library=MyLibrary | for_loop_iterable=${agents} | fail_on_error=False |
|
|
405
|
+
| ${results}= | Run Parallel Scenarios | keyword=Verify Agent Data | library=MyLibrary | for_loop_iterable=${agents} | call_timeout=30s |
|
|
279
406
|
"""
|
|
280
407
|
# Determine items to process. NOTE: `repeat or 1` would be wrong here -
|
|
281
408
|
# 0 is falsy in Python, so an explicit repeat=0 would silently fall
|
|
@@ -294,13 +421,16 @@ class ParallelRunner:
|
|
|
294
421
|
effective_mapper = self._load_mapper_from_environment()
|
|
295
422
|
|
|
296
423
|
min_level = self._resolve_thread_log_level(thread_log_level)
|
|
424
|
+
timeout_seconds = self._resolve_call_timeout(call_timeout)
|
|
297
425
|
|
|
298
426
|
# Note: We must call the underlying Python method, not BuiltIn().run_keyword
|
|
299
427
|
# because run_keyword is not thread-safe.
|
|
300
428
|
lib_instance = self._get_library_instance_owning_keyword(keyword, library)
|
|
301
|
-
method =
|
|
429
|
+
method = self._get_keyword_method(lib_instance, keyword)
|
|
302
430
|
|
|
303
|
-
results = self._run_in_threads(
|
|
431
|
+
results = self._run_in_threads(
|
|
432
|
+
method, list(items), effective_mapper, remove_passing_logs, min_level, kwargs, timeout_seconds
|
|
433
|
+
)
|
|
304
434
|
|
|
305
435
|
# Step: Re-play logs into Robot Framework sequentially
|
|
306
436
|
self._replay_logs(results, remove_passing_logs, min_level)
|
|
@@ -393,17 +523,45 @@ class ParallelRunner:
|
|
|
393
523
|
return self._resolve_mapper(os.getenv("ROBOT_LOGGER_MAPPER"), source="ROBOT_LOGGER_MAPPER")
|
|
394
524
|
|
|
395
525
|
def _get_library_instance_owning_keyword(self, keyword_name: str, library: str) -> Any:
|
|
396
|
-
method_name = keyword_name.replace(" ", "_").lower()
|
|
397
526
|
lib_instance = BuiltIn().get_library_instance(library)
|
|
398
|
-
if
|
|
527
|
+
if self._find_keyword_method(lib_instance, keyword_name) is not None:
|
|
399
528
|
return lib_instance
|
|
400
529
|
raise ValueError(f"Keyword '{keyword_name}' not found in library '{library}'")
|
|
401
530
|
|
|
531
|
+
def _get_keyword_method(self, lib_instance: Any, keyword_name: str):
|
|
532
|
+
method = self._find_keyword_method(lib_instance, keyword_name)
|
|
533
|
+
if method is None:
|
|
534
|
+
raise ValueError(f"Keyword '{keyword_name}' not found in library '{type(lib_instance).__name__}'")
|
|
535
|
+
return method
|
|
536
|
+
|
|
537
|
+
@staticmethod
|
|
538
|
+
def _find_keyword_method(lib_instance: Any, keyword_name: str):
|
|
539
|
+
"""Finds the method implementing ``keyword_name``: by method name
|
|
540
|
+
(``Verify Agent Data`` -> ``verify_agent_data``), or, like Robot
|
|
541
|
+
itself, by a custom name given with ``@keyword("...")``. Names are
|
|
542
|
+
compared ignoring case, spaces and underscores."""
|
|
543
|
+
method = getattr(lib_instance, keyword_name.replace(" ", "_").lower(), None)
|
|
544
|
+
if callable(method):
|
|
545
|
+
return method
|
|
546
|
+
wanted = _normalize_name(keyword_name)
|
|
547
|
+
for attribute in dir(lib_instance):
|
|
548
|
+
# getattr_static: don't run properties just to look at them.
|
|
549
|
+
raw = inspect.getattr_static(lib_instance, attribute, None)
|
|
550
|
+
function = raw.__func__ if isinstance(raw, (staticmethod, classmethod)) else raw
|
|
551
|
+
robot_name = getattr(function, "robot_name", None)
|
|
552
|
+
if robot_name is not None:
|
|
553
|
+
matches = _normalize_name(robot_name) == wanted
|
|
554
|
+
else:
|
|
555
|
+
matches = not attribute.startswith("_") and _normalize_name(attribute) == wanted
|
|
556
|
+
if matches and callable(getattr(lib_instance, attribute, None)):
|
|
557
|
+
return getattr(lib_instance, attribute)
|
|
558
|
+
return None
|
|
559
|
+
|
|
402
560
|
def _resolve_thread_log_level(self, thread_log_level: Any) -> int:
|
|
403
561
|
"""Returns the numeric minimum level for ``thread_log_level``. Not
|
|
404
562
|
given (None, empty, "None") means every level; an unknown value is
|
|
405
563
|
ignored with a warning instead of silently filtering by INFO."""
|
|
406
|
-
if
|
|
564
|
+
if _not_given(thread_log_level):
|
|
407
565
|
return LOG_LEVELS["TRACE"]
|
|
408
566
|
level = _normalize_level(thread_log_level)
|
|
409
567
|
if level in LOG_LEVELS:
|
|
@@ -414,6 +572,41 @@ class ParallelRunner:
|
|
|
414
572
|
)
|
|
415
573
|
return LOG_LEVELS["TRACE"]
|
|
416
574
|
|
|
575
|
+
@staticmethod
|
|
576
|
+
def _resolve_call_timeout(call_timeout: Any) -> Optional[float]:
|
|
577
|
+
"""Returns ``call_timeout`` in seconds, or None when not given. Accepts
|
|
578
|
+
Robot's time format (``10s``, ``1 minute 30 seconds``, ``1.5``)."""
|
|
579
|
+
if _not_given(call_timeout):
|
|
580
|
+
return None
|
|
581
|
+
try:
|
|
582
|
+
seconds = timestr_to_secs(call_timeout)
|
|
583
|
+
except (ValueError, DataError) as error:
|
|
584
|
+
raise ValueError(f"Invalid call_timeout {call_timeout!r}: {error}") from None
|
|
585
|
+
if seconds <= 0:
|
|
586
|
+
raise ValueError(f"call_timeout must be positive, got {call_timeout!r}.")
|
|
587
|
+
return seconds
|
|
588
|
+
|
|
589
|
+
@staticmethod
|
|
590
|
+
def _converter_for(method):
|
|
591
|
+
"""Returns a function converting ``(positional, named)`` arguments by
|
|
592
|
+
the method's type hints and default values, exactly like Robot does
|
|
593
|
+
when the keyword is called directly - or None if Robot can't parse the
|
|
594
|
+
method's signature (then arguments are passed as given)."""
|
|
595
|
+
try:
|
|
596
|
+
spec = PythonArgumentParser().parse(method)
|
|
597
|
+
except Exception:
|
|
598
|
+
return None
|
|
599
|
+
return lambda positional, named: spec.convert(positional, named)
|
|
600
|
+
|
|
601
|
+
@staticmethod
|
|
602
|
+
def _accepts_cancel_event(method) -> bool:
|
|
603
|
+
"""``_cancelled`` is only passed to keywords that declare it, so that
|
|
604
|
+
keywords with a fixed signature keep working."""
|
|
605
|
+
try:
|
|
606
|
+
return "_cancelled" in inspect.signature(method).parameters
|
|
607
|
+
except (TypeError, ValueError):
|
|
608
|
+
return False
|
|
609
|
+
|
|
417
610
|
def _run_in_threads(
|
|
418
611
|
self,
|
|
419
612
|
method,
|
|
@@ -422,11 +615,17 @@ class ParallelRunner:
|
|
|
422
615
|
remove_passing_logs: bool,
|
|
423
616
|
min_level: int,
|
|
424
617
|
kwargs: Dict[str, Any],
|
|
618
|
+
call_timeout: Optional[float] = None,
|
|
425
619
|
) -> List[Dict[str, Any]]:
|
|
426
620
|
"""
|
|
427
621
|
Runs ``method`` once per item on up to ``max_workers`` daemon threads
|
|
428
622
|
and returns the result dicts in call order (NOT completion order).
|
|
429
623
|
|
|
624
|
+
With ``call_timeout``, a call still running after that many seconds
|
|
625
|
+
becomes a ``FAIL`` result: its thread is abandoned (it can't be
|
|
626
|
+
killed) and a new worker takes its place, so the other calls keep
|
|
627
|
+
running ``max_workers`` at a time.
|
|
628
|
+
|
|
430
629
|
If the wait is interrupted (mostly by a Robot ``[Timeout]``), the
|
|
431
630
|
workers stop starting new calls, the logs collected so far are
|
|
432
631
|
replayed and the exception is re-raised. Calls already running can't
|
|
@@ -437,15 +636,47 @@ class ParallelRunner:
|
|
|
437
636
|
if self.max_workers < 1:
|
|
438
637
|
raise ValueError(f"ROBOT_THREAD_WORKERS must be at least 1, got {self.max_workers}.")
|
|
439
638
|
|
|
639
|
+
count = len(items)
|
|
640
|
+
convert = self._converter_for(method)
|
|
641
|
+
if convert is not None:
|
|
642
|
+
# Once, before any thread starts: a bad named argument fails the
|
|
643
|
+
# keyword instead of every call.
|
|
644
|
+
kwargs = dict(convert([], list(kwargs.items()))[1])
|
|
645
|
+
cancel_events = [threading.Event() for _ in items] if self._accepts_cancel_event(method) else None
|
|
646
|
+
|
|
440
647
|
# Created here, not in the workers, so the logs of calls that are still
|
|
441
|
-
# running can be replayed if the keyword is interrupted.
|
|
648
|
+
# running can be replayed if the keyword is interrupted or times out.
|
|
442
649
|
log_buffers: List[list] = [[] for _ in items]
|
|
443
|
-
|
|
444
|
-
|
|
650
|
+
# Written by the workers. A worker writes `started_at[i]` when call i
|
|
651
|
+
# starts and `finished[i]` when it has returned - unless the main
|
|
652
|
+
# thread has timed the call out first. `claims` decides which of the
|
|
653
|
+
# two wins: dict.setdefault is atomic, so the first claim sticks.
|
|
654
|
+
started_at: List[Optional[float]] = [None] * count
|
|
655
|
+
finished: List[Optional[Dict[str, Any]]] = [None] * count
|
|
656
|
+
claims: Dict[int, str] = {}
|
|
657
|
+
# Written by the main thread only.
|
|
658
|
+
results: List[Optional[Dict[str, Any]]] = [None] * count
|
|
659
|
+
|
|
660
|
+
# What each call receives as its first argument. Only strings are
|
|
661
|
+
# converted (by the type hint, as when Robot calls the keyword with a
|
|
662
|
+
# value from a test file); other objects are passed as they are. The
|
|
663
|
+
# results keep the original item either way.
|
|
664
|
+
arguments = list(items)
|
|
445
665
|
pending = queue.SimpleQueue()
|
|
446
|
-
for index in
|
|
666
|
+
for index, item in enumerate(items):
|
|
667
|
+
if convert is not None and isinstance(item, str):
|
|
668
|
+
try:
|
|
669
|
+
(arguments[index],), _ = convert([item], [])
|
|
670
|
+
except Exception as error:
|
|
671
|
+
# Not worth starting: Robot would fail this call before running it, too.
|
|
672
|
+
message = str(error)
|
|
673
|
+
log_buffers[index].append(("ERROR", f"Thread failed for item {item}: {message}"))
|
|
674
|
+
started_at[index] = time.monotonic()
|
|
675
|
+
finished[index] = {"status": "FAIL", "item": item, "logs": log_buffers[index], "error": message}
|
|
676
|
+
continue
|
|
447
677
|
pending.put(index)
|
|
448
678
|
stop = threading.Event()
|
|
679
|
+
workers_started = 0
|
|
449
680
|
|
|
450
681
|
def worker():
|
|
451
682
|
while not stop.is_set():
|
|
@@ -453,27 +684,79 @@ class ParallelRunner:
|
|
|
453
684
|
index = pending.get_nowait()
|
|
454
685
|
except queue.Empty:
|
|
455
686
|
return
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
687
|
+
call_kwargs = kwargs if cancel_events is None else {**kwargs, "_cancelled": cancel_events[index]}
|
|
688
|
+
started_at[index] = time.monotonic()
|
|
689
|
+
result = self._execute_and_capture(
|
|
690
|
+
method, items[index], logger_mapper, log_buffers[index], call_kwargs, arguments[index]
|
|
459
691
|
)
|
|
692
|
+
if claims.setdefault(index, _WORKER) != _WORKER:
|
|
693
|
+
# The call timed out and a new worker took this thread's
|
|
694
|
+
# place: stop here, so no more than max_workers calls run.
|
|
695
|
+
return
|
|
696
|
+
finished[index] = result
|
|
697
|
+
|
|
698
|
+
def start_worker():
|
|
699
|
+
nonlocal workers_started
|
|
700
|
+
workers_started += 1
|
|
701
|
+
threading.Thread(target=worker, name=f"ParallelRunner-worker-{workers_started}", daemon=True).start()
|
|
460
702
|
|
|
461
703
|
try:
|
|
462
|
-
for
|
|
463
|
-
|
|
704
|
+
for _ in range(min(self.max_workers, count)):
|
|
705
|
+
start_worker()
|
|
464
706
|
# Deliberately no lock, Event or Future wait here: a timeout raised
|
|
465
707
|
# asynchronously while such a wait holds an internal lock could
|
|
466
708
|
# leave it locked forever. Short sleeps and plain reads are safe.
|
|
467
|
-
|
|
468
|
-
|
|
709
|
+
remaining = count
|
|
710
|
+
next_unstarted = 0
|
|
711
|
+
running: List[int] = []
|
|
712
|
+
while remaining:
|
|
713
|
+
# Calls start in index order (FIFO queue), so started calls are
|
|
714
|
+
# picked up by moving a pointer instead of scanning every slot.
|
|
715
|
+
while next_unstarted < count and started_at[next_unstarted] is not None:
|
|
716
|
+
running.append(next_unstarted)
|
|
717
|
+
next_unstarted += 1
|
|
718
|
+
now = time.monotonic()
|
|
719
|
+
still_running = []
|
|
720
|
+
for index in running:
|
|
721
|
+
if finished[index] is not None:
|
|
722
|
+
results[index] = finished[index]
|
|
723
|
+
remaining -= 1
|
|
724
|
+
elif (
|
|
725
|
+
call_timeout is not None
|
|
726
|
+
and now - started_at[index] >= call_timeout
|
|
727
|
+
and claims.setdefault(index, _MAIN) == _MAIN
|
|
728
|
+
):
|
|
729
|
+
results[index] = self._timed_out_result(items[index], log_buffers[index], call_timeout)
|
|
730
|
+
remaining -= 1
|
|
731
|
+
if cancel_events is not None:
|
|
732
|
+
cancel_events[index].set()
|
|
733
|
+
if not pending.empty():
|
|
734
|
+
start_worker()
|
|
735
|
+
else:
|
|
736
|
+
still_running.append(index)
|
|
737
|
+
running = still_running
|
|
738
|
+
if remaining:
|
|
469
739
|
time.sleep(_POLL_INTERVAL)
|
|
470
740
|
except BaseException as error:
|
|
471
741
|
stop.set()
|
|
472
|
-
|
|
742
|
+
if cancel_events is not None:
|
|
743
|
+
for event in cancel_events:
|
|
744
|
+
event.set()
|
|
745
|
+
self._replay_interrupted(
|
|
746
|
+
items, results, finished, started_at, log_buffers, error, remove_passing_logs, min_level
|
|
747
|
+
)
|
|
473
748
|
raise
|
|
474
749
|
return results
|
|
475
750
|
|
|
476
|
-
|
|
751
|
+
@staticmethod
|
|
752
|
+
def _timed_out_result(item, log_buffer: list, call_timeout: float) -> Dict[str, Any]:
|
|
753
|
+
duration = secs_to_timestr(call_timeout)
|
|
754
|
+
logs = list(log_buffer)
|
|
755
|
+
logs.append(("ERROR", f"Call for item {item} timed out after {duration} (call_timeout); "
|
|
756
|
+
"it keeps running in the background"))
|
|
757
|
+
return {"status": "FAIL", "item": item, "logs": logs, "error": f"Timed out after {duration}"}
|
|
758
|
+
|
|
759
|
+
def _replay_interrupted(self, items, results, finished, started_at, log_buffers, error, remove_passing_logs, min_level):
|
|
477
760
|
"""Replays what the calls logged before the keyword was interrupted.
|
|
478
761
|
Calls that never started are only counted, not listed one by one."""
|
|
479
762
|
# On Windows Robot raises the timeout as a bare class, without message.
|
|
@@ -481,12 +764,12 @@ class ParallelRunner:
|
|
|
481
764
|
partial = []
|
|
482
765
|
running = not_started = 0
|
|
483
766
|
for index, item in enumerate(items):
|
|
484
|
-
# Read the
|
|
485
|
-
# is then reported as still running, never as not started.
|
|
486
|
-
result = results[index]
|
|
767
|
+
# Read the results before `started_at`: a call that finishes in
|
|
768
|
+
# between is then reported as still running, never as not started.
|
|
769
|
+
result = results[index] or finished[index]
|
|
487
770
|
if result is not None:
|
|
488
771
|
partial.append(result)
|
|
489
|
-
elif
|
|
772
|
+
elif started_at[index] is not None:
|
|
490
773
|
running += 1
|
|
491
774
|
logs = list(log_buffers[index])
|
|
492
775
|
logs.append(("ERROR", f"Call for item {item} did not finish: still running when the keyword was interrupted ({reason})"))
|
|
@@ -500,14 +783,19 @@ class ParallelRunner:
|
|
|
500
783
|
self._replay_logs(partial, remove_passing_logs, min_level)
|
|
501
784
|
|
|
502
785
|
def _execute_and_capture(
|
|
503
|
-
self, method, item, logger_mapper: Optional[Any], log_buffer: list, kwargs: Dict[str, Any]
|
|
786
|
+
self, method, item, logger_mapper: Optional[Any], log_buffer: list, kwargs: Dict[str, Any],
|
|
787
|
+
argument: Any = _NOT_SET,
|
|
504
788
|
) -> Dict[str, Any]:
|
|
505
789
|
"""Worker wrapper to capture logs and results.
|
|
506
790
|
|
|
507
|
-
``
|
|
508
|
-
|
|
509
|
-
|
|
791
|
+
``argument`` is what the call receives as its first argument (``item``
|
|
792
|
+
converted by its type hint), ``item`` what the result reports. ``kwargs``
|
|
793
|
+
is a dict, not ``**kwargs``, so a keyword argument that happens to be
|
|
794
|
+
called ``method``, ``item`` or ``log_buffer`` can't clash with this
|
|
795
|
+
method's own parameters.
|
|
510
796
|
"""
|
|
797
|
+
if argument is _NOT_SET:
|
|
798
|
+
argument = item
|
|
511
799
|
# Injection of a custom logger into the thread
|
|
512
800
|
def thread_log(msg, level="INFO"):
|
|
513
801
|
# Filter out IGNORE (sv=0) level logs. Other levels are kept as
|
|
@@ -519,7 +807,7 @@ class ParallelRunner:
|
|
|
519
807
|
effective_logger = logger_mapper if logger_mapper else thread_log
|
|
520
808
|
|
|
521
809
|
try:
|
|
522
|
-
result = method(
|
|
810
|
+
result = method(argument, _logger=effective_logger, **kwargs)
|
|
523
811
|
return {"status": "PASS", "item": item, "logs": log_buffer, "result": result}
|
|
524
812
|
# BaseException, not Exception: whatever the call raises (even
|
|
525
813
|
# SystemExit), the worker must report a result, or the main thread
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: robotframework-parallelrunner
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -230,11 +230,11 @@ Things to keep in mind:
|
|
|
230
230
|
made thread-safe for you; your own keyword's side effects are not. If it
|
|
231
231
|
writes to a shared variable, file, or object without a lock, running it
|
|
232
232
|
concurrently can race the same way any multi-threaded code can.
|
|
233
|
-
- **You need per-item retries
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
233
|
+
- **You need per-item retries.** Not implemented yet (see
|
|
234
|
+
[ROADMAP.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/ROADMAP.md)). A per-call limit is: `call_timeout=10s` fails
|
|
235
|
+
only the call that hangs and lets the others finish. Note that a timed-out
|
|
236
|
+
call can't be killed - it keeps running in the background unless the
|
|
237
|
+
keyword declares `_cancelled` and stops when it is set.
|
|
238
238
|
- **You need process-level isolation** (a crash in one call shouldn't be
|
|
239
239
|
able to affect another) or cross-machine parallelism — that's pabot's
|
|
240
240
|
domain, not this library's.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
ParallelRunner/__init__.py,sha256=x74aR4t2SZvD81MepoTobACqyLfX_rKaEWPJY08kvTA,671
|
|
2
|
+
ParallelRunner/parallel_library.py,sha256=oXaLDC09KAJ0RzLooIw-fP1nZ-iuVZ7gzTs0bbck5kA,42180
|
|
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.4.0.dist-info/METADATA,sha256=XxHtM1t5mGkD_W5NDfP_K6ckulVLodz2eRnbjzHbzak,14488
|
|
7
|
+
robotframework_parallelrunner-0.4.0.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
|
|
8
|
+
robotframework_parallelrunner-0.4.0.dist-info/licenses/LICENSE,sha256=xxQh3uvlk5jC1i0eoH5Nyo9cDz2Fzu0DqOzAZw8XxFs,1072
|
|
9
|
+
robotframework_parallelrunner-0.4.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
|