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.
@@ -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 robot.api import logger
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 (lambda msg, level="INFO": logger.write(msg, level))
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). Any extra named argument given to
107
- `Run Parallel Scenarios` is forwarded to every call as ``**kwargs``.
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); 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)``).
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
- # Workers count from Env Var or default to 4
215
- self.max_workers = int(os.getenv("ROBOT_THREAD_WORKERS", "4"))
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
- - ``**kwargs``: Any other named argument is passed to every call.
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 = getattr(lib_instance, keyword.replace(" ", "_").lower())
520
+ method = self._get_keyword_method(lib_instance, keyword)
302
521
 
303
- results = self._run_in_threads(method, list(items), effective_mapper, remove_passing_logs, min_level, kwargs)
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 hasattr(lib_instance, method_name):
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 thread_log_level is None or str(thread_log_level).strip().upper() in ("", "NONE"):
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
- results: List[Optional[Dict[str, Any]]] = [None] * len(items)
444
- started = [False] * len(items)
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 range(len(items)):
447
- pending.put(index)
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
- 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
-
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 number in range(min(self.max_workers, len(items))):
463
- threading.Thread(target=worker, name=f"ParallelRunner-worker-{number + 1}", daemon=True).start()
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
- for index in range(len(items)):
468
- while results[index] is None:
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
- self._replay_interrupted(items, results, started, log_buffers, error, remove_passing_logs, min_level)
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
- def _replay_interrupted(self, items, results, started, log_buffers, error, remove_passing_logs, min_level):
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 result before `started`: a call that finishes in between
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 started[index]:
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
- ``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.
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(item, _logger=effective_logger, **kwargs)
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
- thread_log(f"Thread failed for item {item}: {str(e)}", "ERROR")
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):
@@ -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.1
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.9
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.9+ and Robot Framework 5.0+ (installed automatically).
57
- See [docs/INSTALLATION.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/docs/INSTALLATION.md) for development installs
58
- and upgrading from the pre-release import path.
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.7 s to 4.6 s with
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 (52 tests;
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.7 s | ×1.0 |
196
- | robot + ParallelRunner (8 workers) | 4.6 s | ×7.1 |
197
- | pabot (4 processes), sequential loop | 10.6 s | ×3.1 |
198
- | pabot (4 processes) + ParallelRunner (8 workers) | 3.5 s | ×9.2 |
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
- 2.5 s here) is most of what's left, so the combination pays off more as
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,,