robotframework-parallelrunner 0.2.1__py3-none-any.whl → 0.3.1__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/parallel_library.py +264 -55
- {robotframework_parallelrunner-0.2.1.dist-info → robotframework_parallelrunner-0.3.1.dist-info}/METADATA +13 -7
- {robotframework_parallelrunner-0.2.1.dist-info → robotframework_parallelrunner-0.3.1.dist-info}/RECORD +5 -5
- {robotframework_parallelrunner-0.2.1.dist-info → robotframework_parallelrunner-0.3.1.dist-info}/WHEEL +0 -0
- {robotframework_parallelrunner-0.2.1.dist-info → robotframework_parallelrunner-0.3.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,22 +1,50 @@
|
|
|
1
1
|
import os
|
|
2
|
-
import concurrent.futures
|
|
3
2
|
import importlib
|
|
3
|
+
import queue
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
4
6
|
from typing import Iterable, Any, List, Dict, Optional, Tuple, Union
|
|
5
7
|
from robot.api import logger
|
|
8
|
+
try:
|
|
9
|
+
from robot.errors import TimeoutExceeded
|
|
10
|
+
except ImportError: # Robot Framework < 7.0
|
|
11
|
+
from robot.errors import TimeoutError as TimeoutExceeded
|
|
6
12
|
from robot.libraries.BuiltIn import BuiltIn
|
|
7
13
|
|
|
8
14
|
from .version import __version__
|
|
9
15
|
|
|
10
16
|
|
|
17
|
+
# Robot Framework log levels, in increasing severity. HTML and CONSOLE are
|
|
18
|
+
# INFO messages (rendered as HTML / also written to the console).
|
|
19
|
+
LOG_LEVELS = {"TRACE": 0, "DEBUG": 1, "INFO": 2, "HTML": 2, "CONSOLE": 2, "WARN": 3, "ERROR": 4}
|
|
20
|
+
# Spellings from Python's `logging` module, mapped to the Robot level.
|
|
21
|
+
LOG_LEVEL_ALIASES = {"WARNING": "WARN", "CRITICAL": "ERROR", "FATAL": "ERROR"}
|
|
22
|
+
|
|
23
|
+
# How often the main thread checks whether the calls have finished. Waiting in
|
|
24
|
+
# short sleeps (instead of blocking on a lock until the end) is what lets a
|
|
25
|
+
# Robot `[Timeout]` interrupt the keyword: on Windows the timeout is raised
|
|
26
|
+
# asynchronously and only arrives while Python code runs.
|
|
27
|
+
_POLL_INTERVAL = 0.01
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _normalize_level(level: Any) -> Optional[str]:
|
|
31
|
+
"""Returns the Robot level name for ``level`` (any case, or a Python
|
|
32
|
+
``logging`` alias), ``"IGNORE"``, or None if it's not a known level."""
|
|
33
|
+
name = str(level).strip().upper()
|
|
34
|
+
name = LOG_LEVEL_ALIASES.get(name, name)
|
|
35
|
+
return name if name in LOG_LEVELS or name == "IGNORE" else None
|
|
36
|
+
|
|
37
|
+
|
|
11
38
|
class ParallelTaskError(RuntimeError):
|
|
12
39
|
"""
|
|
13
|
-
Raised when at least one parallel task failed
|
|
14
|
-
|
|
15
|
-
|
|
40
|
+
Raised when at least one parallel task failed: by ``run_parallel_scenarios``
|
|
41
|
+
(unless called with ``fail_on_error=False``), and by ``get_result_values``,
|
|
42
|
+
which has no meaningful return value to hand back for a failed task.
|
|
16
43
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
this is raised instead
|
|
44
|
+
Returning normally after a failed call would let a test keep going (and
|
|
45
|
+
pass) with bad or missing data instead of failing for the right reason,
|
|
46
|
+
so this is raised instead - after every task has finished and all logs
|
|
47
|
+
have been replayed.
|
|
20
48
|
|
|
21
49
|
Attributes:
|
|
22
50
|
failures: the subset of result dicts with ``status == "FAIL"``,
|
|
@@ -46,7 +74,8 @@ class ParallelRunner:
|
|
|
46
74
|
|
|
47
75
|
= How it works =
|
|
48
76
|
|
|
49
|
-
- Each call runs on a
|
|
77
|
+
- Each call runs on one of a pool of worker threads (see
|
|
78
|
+
``ROBOT_THREAD_WORKERS``), created for that keyword call.
|
|
50
79
|
``BuiltIn().run_keyword`` is *not* used inside threads (it is not
|
|
51
80
|
thread-safe); the underlying Python method of the target library is
|
|
52
81
|
called directly instead.
|
|
@@ -73,8 +102,8 @@ class ParallelRunner:
|
|
|
73
102
|
| ...
|
|
74
103
|
| return result
|
|
75
104
|
|
|
76
|
-
``_logger(msg, level)`` accepts
|
|
77
|
-
|
|
105
|
+
``_logger(msg, level)`` accepts every Robot Framework log level (see
|
|
106
|
+
`Log levels`) plus ``IGNORE`` (dropped). Any extra named argument given to
|
|
78
107
|
`Run Parallel Scenarios` is forwarded to every call as ``**kwargs``.
|
|
79
108
|
|
|
80
109
|
Logging is made thread-safe for you; your keyword's own side effects are
|
|
@@ -91,9 +120,58 @@ class ParallelRunner:
|
|
|
91
120
|
| result | The return value of the call (only when ``status`` is ``PASS``). |
|
|
92
121
|
| error | The error message (only when ``status`` is ``FAIL``). |
|
|
93
122
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
123
|
+
= Failures =
|
|
124
|
+
|
|
125
|
+
If any call raises, `Run Parallel Scenarios` still waits for every other
|
|
126
|
+
call, replays all logs, and then *fails* with a ``ParallelTaskError``
|
|
127
|
+
that lists each failed item and its error, e.g.
|
|
128
|
+
``2 of 5 parallel task(s) failed: item='4': ...; item='5': ...``.
|
|
129
|
+
One failed call is never hidden behind a passing keyword.
|
|
130
|
+
|
|
131
|
+
To inspect failures yourself instead, pass ``fail_on_error=False``: the
|
|
132
|
+
keyword then always returns the result list, failed calls get
|
|
133
|
+
``status`` ``FAIL`` (their error is also logged at ``ERROR`` level), and
|
|
134
|
+
it is up to you to assert on them:
|
|
135
|
+
|
|
136
|
+
| ${results}= | Run Parallel Scenarios | keyword=Verify Agent Data | library=MyLibrary | for_loop_iterable=${agents} | fail_on_error=False |
|
|
137
|
+
| Should Be Equal | ${results}[1][status] | PASS |
|
|
138
|
+
|
|
139
|
+
``return_values_only=True`` and `Get Result Values` always fail if any
|
|
140
|
+
call failed, because a failed call has no return value.
|
|
141
|
+
|
|
142
|
+
= Log levels =
|
|
143
|
+
|
|
144
|
+
The injected ``_logger`` accepts the same levels as Robot Framework's
|
|
145
|
+
``logger.write``: ``TRACE``, ``DEBUG``, ``INFO``, ``HTML`` (an ``INFO``
|
|
146
|
+
message rendered as HTML), ``CONSOLE`` (an ``INFO`` message also written
|
|
147
|
+
to the console), ``WARN`` and ``ERROR``. Levels are case-insensitive, and
|
|
148
|
+
Python ``logging`` names are accepted too: ``WARNING`` is ``WARN``,
|
|
149
|
+
``CRITICAL`` and ``FATAL`` are ``ERROR``. ``IGNORE`` drops the message.
|
|
150
|
+
|
|
151
|
+
Replayed messages go through Robot's normal filtering, so ``TRACE`` and
|
|
152
|
+
``DEBUG`` messages only show up in ``log.html`` when the run uses a low
|
|
153
|
+
enough ``--loglevel`` (e.g. ``--loglevel DEBUG``), exactly as they would
|
|
154
|
+
from a normal keyword. ``thread_log_level`` filters the threads' messages
|
|
155
|
+
on top of that (by default it lets every level through).
|
|
156
|
+
|
|
157
|
+
A message with an unknown level is not lost: it is logged at ``INFO``
|
|
158
|
+
with the level as a prefix (``[VERBOSE] message``), and a warning names
|
|
159
|
+
the unknown level once per keyword call.
|
|
160
|
+
|
|
161
|
+
= Timeouts =
|
|
162
|
+
|
|
163
|
+
A Robot ``[Timeout]`` (test or keyword) interrupts `Run Parallel Scenarios`
|
|
164
|
+
as soon as it expires, not when the whole batch has finished. Before the
|
|
165
|
+
timeout failure is reported, the logs collected so far are replayed:
|
|
166
|
+
finished calls as usual, and every call that did not finish with its
|
|
167
|
+
partial logs and an ``ERROR`` saying it was interrupted.
|
|
168
|
+
|
|
169
|
+
Python threads can't be killed, so calls that are already running keep
|
|
170
|
+
running in the background until they return (possibly while the next test
|
|
171
|
+
runs); calls that had not started yet are not started. The worker threads
|
|
172
|
+
are daemon threads, so they never keep the Robot process alive after the
|
|
173
|
+
run. A keyword that may hang (e.g. network I/O) should set its own
|
|
174
|
+
timeouts (e.g. ``requests.get(url, timeout=10)``).
|
|
97
175
|
|
|
98
176
|
= Custom logger mappers =
|
|
99
177
|
|
|
@@ -143,9 +221,10 @@ class ParallelRunner:
|
|
|
143
221
|
for_loop_iterable: Optional[Iterable[Any]] = None,
|
|
144
222
|
repeat: Optional[int] = None,
|
|
145
223
|
remove_passing_logs: bool = False,
|
|
146
|
-
thread_log_level: str = "
|
|
224
|
+
thread_log_level: str = "TRACE",
|
|
147
225
|
logger_mapper: Optional[Any] = None,
|
|
148
226
|
return_values_only: bool = False,
|
|
227
|
+
fail_on_error: bool = True,
|
|
149
228
|
**kwargs
|
|
150
229
|
) -> Union[List[Dict[str, Any]], Tuple[Any, ...]]:
|
|
151
230
|
"""Runs ``keyword`` from ``library`` concurrently, once per item or N times.
|
|
@@ -166,7 +245,9 @@ class ParallelRunner:
|
|
|
166
245
|
- ``remove_passing_logs``: If true, logs of passing calls are not
|
|
167
246
|
replayed into ``log.html``; only failed calls are shown.
|
|
168
247
|
- ``thread_log_level``: Minimum level replayed from the threads:
|
|
169
|
-
``
|
|
248
|
+
``TRACE`` (default, every message), ``DEBUG``, ``INFO``, ``WARN``
|
|
249
|
+
or ``ERROR``. Robot's own ``--loglevel`` still applies on top of it
|
|
250
|
+
(see `Log levels`). An unknown value is ignored with a warning.
|
|
170
251
|
- ``logger_mapper``: Optional callable ``mapper(msg, level)`` - or a
|
|
171
252
|
``module.function`` path to one, such as
|
|
172
253
|
``my_package.my_module.my_mapper`` - injected as ``_logger`` instead
|
|
@@ -178,17 +259,23 @@ class ParallelRunner:
|
|
|
178
259
|
return value (in call order) instead of the result dictionaries.
|
|
179
260
|
Fails with ``ParallelTaskError`` if any call failed. Same as calling
|
|
180
261
|
`Get Result Values` on the default return value.
|
|
262
|
+
- ``fail_on_error``: If true (default), fail with ``ParallelTaskError``
|
|
263
|
+
when any call failed, after all calls have finished and their logs
|
|
264
|
+
have been replayed. If false, return the results anyway and leave
|
|
265
|
+
checking ``status`` to the caller. See `Failures`.
|
|
181
266
|
- ``**kwargs``: Any other named argument is passed to every call.
|
|
182
267
|
|
|
183
268
|
Returns a list of result dictionaries (see `Result format`), in call
|
|
184
269
|
order: entry ``i`` belongs to ``for_loop_iterable[i]`` / repeat
|
|
185
|
-
index ``i``, regardless of which thread finished first.
|
|
270
|
+
index ``i``, regardless of which thread finished first. With the
|
|
271
|
+
default ``fail_on_error=True`` it only returns when every call passed.
|
|
186
272
|
|
|
187
273
|
Examples:
|
|
188
274
|
| ${agents}= | Create List | 1 | 2 | 3 |
|
|
189
275
|
| ${results}= | Run Parallel Scenarios | keyword=Verify Agent Data | library=MyLibrary | for_loop_iterable=${agents} |
|
|
190
276
|
| ${results}= | Run Parallel Scenarios | keyword=Check Health | library=MyLibrary | repeat=8 | agent_id=1 |
|
|
191
277
|
| ${id1} ${id2} ${id3}= | Run Parallel Scenarios | keyword=Seed Record | library=MyLibrary | repeat=3 | return_values_only=True |
|
|
278
|
+
| ${results}= | Run Parallel Scenarios | keyword=Verify Agent Data | library=MyLibrary | for_loop_iterable=${agents} | fail_on_error=False |
|
|
192
279
|
"""
|
|
193
280
|
# Determine items to process. NOTE: `repeat or 1` would be wrong here -
|
|
194
281
|
# 0 is falsy in Python, so an explicit repeat=0 would silently fall
|
|
@@ -206,28 +293,22 @@ class ParallelRunner:
|
|
|
206
293
|
if effective_mapper is None:
|
|
207
294
|
effective_mapper = self._load_mapper_from_environment()
|
|
208
295
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
# then collect results in submission order (NOT as_completed order) -
|
|
218
|
-
# tasks[i].result() blocks only until the i-th task finishes, it
|
|
219
|
-
# doesn't force tasks to run one at a time.
|
|
220
|
-
tasks = [
|
|
221
|
-
executor.submit(self._execute_and_capture, method, item, effective_mapper, **kwargs)
|
|
222
|
-
for item in items
|
|
223
|
-
]
|
|
224
|
-
results = [task.result() for task in tasks]
|
|
296
|
+
min_level = self._resolve_thread_log_level(thread_log_level)
|
|
297
|
+
|
|
298
|
+
# Note: We must call the underlying Python method, not BuiltIn().run_keyword
|
|
299
|
+
# because run_keyword is not thread-safe.
|
|
300
|
+
lib_instance = self._get_library_instance_owning_keyword(keyword, library)
|
|
301
|
+
method = getattr(lib_instance, keyword.replace(" ", "_").lower())
|
|
302
|
+
|
|
303
|
+
results = self._run_in_threads(method, list(items), effective_mapper, remove_passing_logs, min_level, kwargs)
|
|
225
304
|
|
|
226
305
|
# Step: Re-play logs into Robot Framework sequentially
|
|
227
|
-
self._replay_logs(results, remove_passing_logs,
|
|
306
|
+
self._replay_logs(results, remove_passing_logs, min_level)
|
|
228
307
|
|
|
229
308
|
if return_values_only:
|
|
230
309
|
return self.get_result_values(results)
|
|
310
|
+
if fail_on_error:
|
|
311
|
+
self._raise_if_any_failed(results)
|
|
231
312
|
return results
|
|
232
313
|
|
|
233
314
|
def get_result_values(self, results: List[Dict[str, Any]]) -> Tuple[Any, ...]:
|
|
@@ -238,7 +319,9 @@ class ParallelRunner:
|
|
|
238
319
|
|
|
239
320
|
Passing ``return_values_only=True`` to `Run Parallel Scenarios` does
|
|
240
321
|
the same in one step; use this keyword when you want to inspect the
|
|
241
|
-
full results first (e.g. assert on ``status`` or ``logs``).
|
|
322
|
+
full results first (e.g. assert on ``status`` or ``logs``). Such a
|
|
323
|
+
result list may only contain failed calls if it came from a run with
|
|
324
|
+
``fail_on_error=False``.
|
|
242
325
|
|
|
243
326
|
Fails with ``ParallelTaskError`` if any entry has status ``FAIL`` -
|
|
244
327
|
a failed call has no return value to put in its slot.
|
|
@@ -247,10 +330,13 @@ class ParallelRunner:
|
|
|
247
330
|
| ${results}= | Run Parallel Scenarios | keyword=Seed Record | library=MyLibrary | repeat=3 |
|
|
248
331
|
| ${id1} ${id2} ${id3}= | Get Result Values | ${results} |
|
|
249
332
|
"""
|
|
333
|
+
self._raise_if_any_failed(results)
|
|
334
|
+
return tuple(entry["result"] for entry in results)
|
|
335
|
+
|
|
336
|
+
def _raise_if_any_failed(self, results: List[Dict[str, Any]]) -> None:
|
|
250
337
|
failures = [entry for entry in results if entry.get("status") == "FAIL"]
|
|
251
338
|
if failures:
|
|
252
339
|
raise ParallelTaskError(failures, len(results))
|
|
253
|
-
return tuple(entry["result"] for entry in results)
|
|
254
340
|
|
|
255
341
|
def _resolve_mapper(self, mapper: Optional[Any], source: str = "logger_mapper") -> Optional[Any]:
|
|
256
342
|
"""
|
|
@@ -313,44 +399,167 @@ class ParallelRunner:
|
|
|
313
399
|
return lib_instance
|
|
314
400
|
raise ValueError(f"Keyword '{keyword_name}' not found in library '{library}'")
|
|
315
401
|
|
|
316
|
-
def
|
|
317
|
-
"""
|
|
318
|
-
|
|
319
|
-
|
|
402
|
+
def _resolve_thread_log_level(self, thread_log_level: Any) -> int:
|
|
403
|
+
"""Returns the numeric minimum level for ``thread_log_level``. Not
|
|
404
|
+
given (None, empty, "None") means every level; an unknown value is
|
|
405
|
+
ignored with a warning instead of silently filtering by INFO."""
|
|
406
|
+
if thread_log_level is None or str(thread_log_level).strip().upper() in ("", "NONE"):
|
|
407
|
+
return LOG_LEVELS["TRACE"]
|
|
408
|
+
level = _normalize_level(thread_log_level)
|
|
409
|
+
if level in LOG_LEVELS:
|
|
410
|
+
return LOG_LEVELS[level]
|
|
411
|
+
logger.warn(
|
|
412
|
+
f"Ignoring thread_log_level {thread_log_level!r}: expected TRACE, DEBUG, INFO, WARN or ERROR. "
|
|
413
|
+
"Replaying every level."
|
|
414
|
+
)
|
|
415
|
+
return LOG_LEVELS["TRACE"]
|
|
416
|
+
|
|
417
|
+
def _run_in_threads(
|
|
418
|
+
self,
|
|
419
|
+
method,
|
|
420
|
+
items: List[Any],
|
|
421
|
+
logger_mapper: Optional[Any],
|
|
422
|
+
remove_passing_logs: bool,
|
|
423
|
+
min_level: int,
|
|
424
|
+
kwargs: Dict[str, Any],
|
|
425
|
+
) -> List[Dict[str, Any]]:
|
|
426
|
+
"""
|
|
427
|
+
Runs ``method`` once per item on up to ``max_workers`` daemon threads
|
|
428
|
+
and returns the result dicts in call order (NOT completion order).
|
|
429
|
+
|
|
430
|
+
If the wait is interrupted (mostly by a Robot ``[Timeout]``), the
|
|
431
|
+
workers stop starting new calls, the logs collected so far are
|
|
432
|
+
replayed and the exception is re-raised. Calls already running can't
|
|
433
|
+
be stopped: they finish in the background. The threads are daemon
|
|
434
|
+
threads (unlike the ones of ``ThreadPoolExecutor``), so a call that
|
|
435
|
+
never returns can't keep the Robot process alive at exit.
|
|
436
|
+
"""
|
|
437
|
+
if self.max_workers < 1:
|
|
438
|
+
raise ValueError(f"ROBOT_THREAD_WORKERS must be at least 1, got {self.max_workers}.")
|
|
439
|
+
|
|
440
|
+
# Created here, not in the workers, so the logs of calls that are still
|
|
441
|
+
# running can be replayed if the keyword is interrupted.
|
|
442
|
+
log_buffers: List[list] = [[] for _ in items]
|
|
443
|
+
results: List[Optional[Dict[str, Any]]] = [None] * len(items)
|
|
444
|
+
started = [False] * len(items)
|
|
445
|
+
pending = queue.SimpleQueue()
|
|
446
|
+
for index in range(len(items)):
|
|
447
|
+
pending.put(index)
|
|
448
|
+
stop = threading.Event()
|
|
449
|
+
|
|
450
|
+
def worker():
|
|
451
|
+
while not stop.is_set():
|
|
452
|
+
try:
|
|
453
|
+
index = pending.get_nowait()
|
|
454
|
+
except queue.Empty:
|
|
455
|
+
return
|
|
456
|
+
started[index] = True
|
|
457
|
+
results[index] = self._execute_and_capture(
|
|
458
|
+
method, items[index], logger_mapper, log_buffers[index], kwargs
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
try:
|
|
462
|
+
for number in range(min(self.max_workers, len(items))):
|
|
463
|
+
threading.Thread(target=worker, name=f"ParallelRunner-worker-{number + 1}", daemon=True).start()
|
|
464
|
+
# Deliberately no lock, Event or Future wait here: a timeout raised
|
|
465
|
+
# asynchronously while such a wait holds an internal lock could
|
|
466
|
+
# leave it locked forever. Short sleeps and plain reads are safe.
|
|
467
|
+
for index in range(len(items)):
|
|
468
|
+
while results[index] is None:
|
|
469
|
+
time.sleep(_POLL_INTERVAL)
|
|
470
|
+
except BaseException as error:
|
|
471
|
+
stop.set()
|
|
472
|
+
self._replay_interrupted(items, results, started, log_buffers, error, remove_passing_logs, min_level)
|
|
473
|
+
raise
|
|
474
|
+
return results
|
|
475
|
+
|
|
476
|
+
def _replay_interrupted(self, items, results, started, log_buffers, error, remove_passing_logs, min_level):
|
|
477
|
+
"""Replays what the calls logged before the keyword was interrupted.
|
|
478
|
+
Calls that never started are only counted, not listed one by one."""
|
|
479
|
+
# On Windows Robot raises the timeout as a bare class, without message.
|
|
480
|
+
reason = str(error) or ("Robot timeout exceeded" if isinstance(error, TimeoutExceeded) else type(error).__name__)
|
|
481
|
+
partial = []
|
|
482
|
+
running = not_started = 0
|
|
483
|
+
for index, item in enumerate(items):
|
|
484
|
+
# Read the result before `started`: a call that finishes in between
|
|
485
|
+
# is then reported as still running, never as not started.
|
|
486
|
+
result = results[index]
|
|
487
|
+
if result is not None:
|
|
488
|
+
partial.append(result)
|
|
489
|
+
elif started[index]:
|
|
490
|
+
running += 1
|
|
491
|
+
logs = list(log_buffers[index])
|
|
492
|
+
logs.append(("ERROR", f"Call for item {item} did not finish: still running when the keyword was interrupted ({reason})"))
|
|
493
|
+
partial.append({"status": "FAIL", "item": item, "logs": logs, "error": f"Did not finish: interrupted ({reason})"})
|
|
494
|
+
else:
|
|
495
|
+
not_started += 1
|
|
496
|
+
logger.info(
|
|
497
|
+
f"Run Parallel Scenarios was interrupted ({reason}): {len(partial) - running} call(s) finished, "
|
|
498
|
+
f"{running} still running (left running in the background), {not_started} not started."
|
|
499
|
+
)
|
|
500
|
+
self._replay_logs(partial, remove_passing_logs, min_level)
|
|
501
|
+
|
|
502
|
+
def _execute_and_capture(
|
|
503
|
+
self, method, item, logger_mapper: Optional[Any], log_buffer: list, kwargs: Dict[str, Any]
|
|
504
|
+
) -> Dict[str, Any]:
|
|
505
|
+
"""Worker wrapper to capture logs and results.
|
|
506
|
+
|
|
507
|
+
``kwargs`` is a dict, not ``**kwargs``, so a keyword argument that
|
|
508
|
+
happens to be called ``method``, ``item`` or ``log_buffer`` can't clash
|
|
509
|
+
with this method's own parameters.
|
|
510
|
+
"""
|
|
320
511
|
# Injection of a custom logger into the thread
|
|
321
512
|
def thread_log(msg, level="INFO"):
|
|
322
|
-
# Filter out IGNORE (sv=0) level logs
|
|
323
|
-
|
|
513
|
+
# Filter out IGNORE (sv=0) level logs. Other levels are kept as
|
|
514
|
+
# given and checked when the logs are replayed.
|
|
515
|
+
if _normalize_level(level) != "IGNORE":
|
|
324
516
|
log_buffer.append((level, msg))
|
|
325
|
-
|
|
517
|
+
|
|
326
518
|
# Use mapper if provided, otherwise use thread_log
|
|
327
519
|
effective_logger = logger_mapper if logger_mapper else thread_log
|
|
328
520
|
|
|
329
521
|
try:
|
|
330
|
-
# We pass the custom logger as an extra kwarg if the method supports it
|
|
331
|
-
# or rely on the method returning its own logs.
|
|
332
522
|
result = method(item, _logger=effective_logger, **kwargs)
|
|
333
523
|
return {"status": "PASS", "item": item, "logs": log_buffer, "result": result}
|
|
334
|
-
|
|
524
|
+
# BaseException, not Exception: whatever the call raises (even
|
|
525
|
+
# SystemExit), the worker must report a result, or the main thread
|
|
526
|
+
# would wait for this call forever.
|
|
527
|
+
except BaseException as e:
|
|
335
528
|
thread_log(f"Thread failed for item {item}: {str(e)}", "ERROR")
|
|
336
529
|
return {"status": "FAIL", "item": item, "logs": log_buffer, "error": str(e)}
|
|
337
530
|
|
|
338
|
-
def _replay_logs(self, results: List[Dict[str, Any]], remove_passing_logs: bool = False,
|
|
339
|
-
"""Sequential dump to the real RF Logger
|
|
340
|
-
|
|
341
|
-
|
|
531
|
+
def _replay_logs(self, results: List[Dict[str, Any]], remove_passing_logs: bool = False, min_level: int = 0):
|
|
532
|
+
"""Sequential dump to the real RF Logger, in call order.
|
|
533
|
+
|
|
534
|
+
Every Robot level goes through ``logger.write``, so Robot's
|
|
535
|
+
``--loglevel`` filters it as usual. IGNORE messages and levels below
|
|
536
|
+
``min_level`` are skipped. A message with an unknown level is kept at
|
|
537
|
+
INFO, prefixed with its level, plus one warning per unknown level.
|
|
538
|
+
(Before 0.3.1 only INFO/WARN/ERROR were written; everything else,
|
|
539
|
+
DEBUG included, was dropped silently.)
|
|
540
|
+
"""
|
|
541
|
+
unknown_levels = []
|
|
342
542
|
for entry in results:
|
|
343
543
|
if remove_passing_logs and entry['status'] == 'PASS':
|
|
344
544
|
continue
|
|
345
545
|
logger.info(f"--- Logs for Item: {entry['item']} ---")
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
546
|
+
# A copy: after an interruption, a call still running may append to it.
|
|
547
|
+
for level, msg in list(entry['logs']):
|
|
548
|
+
name = _normalize_level(level)
|
|
549
|
+
if name == "IGNORE":
|
|
349
550
|
continue
|
|
350
|
-
if
|
|
351
|
-
|
|
352
|
-
|
|
551
|
+
if name is None:
|
|
552
|
+
if level not in unknown_levels:
|
|
553
|
+
unknown_levels.append(level)
|
|
554
|
+
name, msg = "INFO", f"[{level}] {msg}"
|
|
555
|
+
if LOG_LEVELS[name] >= min_level:
|
|
556
|
+
logger.write(msg, name)
|
|
353
557
|
logger.info(f"Status: {entry['status']}")
|
|
558
|
+
for level in unknown_levels:
|
|
559
|
+
logger.warn(
|
|
560
|
+
f"Unknown log level {level!r} used with _logger: its messages were logged as INFO, "
|
|
561
|
+
"prefixed with the level. Use TRACE, DEBUG, INFO, HTML, CONSOLE, WARN, ERROR or IGNORE."
|
|
562
|
+
)
|
|
354
563
|
|
|
355
564
|
# Backwards-compatible name: the class was called ParallelLibrary before the
|
|
356
565
|
# package was renamed to ParallelRunner (imported as `Library ParallelRunner`).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: robotframework-parallelrunner
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
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
|
|
@@ -118,6 +118,10 @@ threading, buffering, and replay logic stay out of your `.robot` files.
|
|
|
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.
|
|
121
|
+
- **No hidden failures.** If any call fails, the keyword fails too (after all
|
|
122
|
+
calls finish and their logs are replayed), with a message listing every
|
|
123
|
+
failed item. Pass `fail_on_error=False` to get a `PASS`/`FAIL` status per
|
|
124
|
+
item and check them yourself.
|
|
121
125
|
- **Flexibility.** Pass `repeat=10` to stress-test a single endpoint, or
|
|
122
126
|
`for_loop_iterable=${items}` for batch validation of a whole list.
|
|
123
127
|
- **Ergonomics.** Results come back in call order (not completion order), so
|
|
@@ -151,7 +155,7 @@ Concretely:
|
|
|
151
155
|
that needs to do the same kind of work many times (loop over a list of IDs,
|
|
152
156
|
or repeat something N times) — how do I parallelize the body of that loop
|
|
153
157
|
without corrupting the log or needing to merge anything?" It uses a
|
|
154
|
-
|
|
158
|
+
pool of threads inside a single process, and produces one
|
|
155
159
|
already-merged, already-ordered log for that test case.
|
|
156
160
|
- Because ParallelRunner uses **threads**, not processes, it shares memory and
|
|
157
161
|
is subject to the GIL — it is a good fit for **I/O-bound** work (network
|
|
@@ -179,7 +183,7 @@ pabot --processes 4 --pythonpath . atest/
|
|
|
179
183
|
pabot --processes 4 --testlevelsplit --pythonpath . atest/
|
|
180
184
|
```
|
|
181
185
|
|
|
182
|
-
Both commands pass the full acceptance suite of this repository (
|
|
186
|
+
Both commands pass the full acceptance suite of this repository (52 tests;
|
|
183
187
|
checked with pabot 5.2.2).
|
|
184
188
|
|
|
185
189
|
How much time each one saves, from the offline
|
|
@@ -226,9 +230,11 @@ Things to keep in mind:
|
|
|
226
230
|
made thread-safe for you; your own keyword's side effects are not. If it
|
|
227
231
|
writes to a shared variable, file, or object without a lock, running it
|
|
228
232
|
concurrently can race the same way any multi-threaded code can.
|
|
229
|
-
- **You need per-item retries or a timeout.** Not implemented yet
|
|
230
|
-
[ROADMAP.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/ROADMAP.md))
|
|
231
|
-
|
|
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.
|
|
232
238
|
- **You need process-level isolation** (a crash in one call shouldn't be
|
|
233
239
|
able to affect another) or cross-machine parallelism — that's pabot's
|
|
234
240
|
domain, not this library's.
|
|
@@ -238,7 +244,7 @@ Things to keep in mind:
|
|
|
238
244
|
```
|
|
239
245
|
robotframework-parallelrunner/
|
|
240
246
|
├── src/ParallelRunner/ # The installable library (do not depend on internals prefixed with `_`)
|
|
241
|
-
├── examples/ # Example libraries used by the tests, plus the pabot
|
|
247
|
+
├── examples/ # Example libraries used by the tests, plus the pabot and log levels demos
|
|
242
248
|
├── atest/ # Robot Framework acceptance suites
|
|
243
249
|
├── utest/ # pytest unit tests
|
|
244
250
|
├── docs/ # INSTALLATION, QUICKSTART, API_REFERENCE, generated keyword docs
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
ParallelRunner/__init__.py,sha256=ifsoO1rXf4kL3i75NokrD_KHxnlvE3IuH69Fb-ByfNg,641
|
|
2
|
-
ParallelRunner/parallel_library.py,sha256=
|
|
2
|
+
ParallelRunner/parallel_library.py,sha256=wF-gIBbVl2D0qD9GGL5dnC49KpGl1Sfy_fq50kKMHyQ,28018
|
|
3
3
|
ParallelRunner/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
4
|
ParallelRunner/version.py,sha256=W8E8ymdTLqfKl_1qBIOrjGpO8kSbqPw30aYZpqYUNMk,632
|
|
5
5
|
parallelrunner.py,sha256=2oyKOVeKlOaZUZ3f2i_l5xCAYFKVm11bSQZOtzSSJO8,1132
|
|
6
|
-
robotframework_parallelrunner-0.
|
|
7
|
-
robotframework_parallelrunner-0.
|
|
8
|
-
robotframework_parallelrunner-0.
|
|
9
|
-
robotframework_parallelrunner-0.
|
|
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
|