robotframework-parallelrunner 0.3.1__tar.gz → 0.4.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: robotframework-parallelrunner
3
- Version: 0.3.1
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 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.
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.
@@ -196,11 +196,11 @@ Things to keep in mind:
196
196
  made thread-safe for you; your own keyword's side effects are not. If it
197
197
  writes to a shared variable, file, or object without a lock, running it
198
198
  concurrently can race the same way any multi-threaded code can.
199
- - **You need per-item retries or a per-item timeout.** Not implemented yet
200
- (see [ROADMAP.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/ROADMAP.md)). A Robot `[Timeout]` on the test or keyword does
201
- interrupt the whole batch when it expires and replays the logs collected so
202
- far, but calls that are already running can't be stopped: they keep running
203
- in the background until they return.
199
+ - **You need per-item retries.** Not implemented yet (see
200
+ [ROADMAP.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/ROADMAP.md)). A per-call limit is: `call_timeout=10s` fails
201
+ only the call that hangs and lets the others finish. Note that a timed-out
202
+ call can't be killed - it keeps running in the background unless the
203
+ keyword declares `_cancelled` and stops when it is set.
204
204
  - **You need process-level isolation** (a crash in one call shouldn't be
205
205
  able to affect another) or cross-machine parallelism — that's pabot's
206
206
  domain, not this library's.
@@ -6,7 +6,7 @@ build-backend = "poetry.core.masonry.api"
6
6
  name = "robotframework-parallelrunner"
7
7
  # Single source of truth for the version: bump with `poetry version patch|minor|major`.
8
8
  # ParallelRunner.__version__ / ROBOT_LIBRARY_VERSION read it back from the installed metadata.
9
- version = "0.3.1"
9
+ version = "0.4.0"
10
10
  description = "Run a keyword in parallel inside a single Robot Framework test case, with one clean, ordered log.html."
11
11
  readme = "README.md"
12
12
  license = "MIT"
@@ -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 robot.api import logger
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 (lambda msg, level="INFO": logger.write(msg, level))
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). Any extra named argument given to
107
- `Run Parallel Scenarios` is forwarded to every call as ``**kwargs``.
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); 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)``).
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
- # Workers count from Env Var or default to 4
215
- self.max_workers = int(os.getenv("ROBOT_THREAD_WORKERS", "4"))
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
- - ``**kwargs``: Any other named argument is passed to every call.
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 = getattr(lib_instance, keyword.replace(" ", "_").lower())
429
+ method = self._get_keyword_method(lib_instance, keyword)
302
430
 
303
- results = self._run_in_threads(method, list(items), effective_mapper, remove_passing_logs, min_level, kwargs)
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 hasattr(lib_instance, method_name):
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 thread_log_level is None or str(thread_log_level).strip().upper() in ("", "NONE"):
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
- results: List[Optional[Dict[str, Any]]] = [None] * len(items)
444
- started = [False] * len(items)
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 range(len(items)):
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
- started[index] = True
457
- results[index] = self._execute_and_capture(
458
- method, items[index], logger_mapper, log_buffers[index], kwargs
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 number in range(min(self.max_workers, len(items))):
463
- threading.Thread(target=worker, name=f"ParallelRunner-worker-{number + 1}", daemon=True).start()
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
- for index in range(len(items)):
468
- while results[index] is None:
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
- self._replay_interrupted(items, results, started, log_buffers, error, remove_passing_logs, min_level)
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
- def _replay_interrupted(self, items, results, started, log_buffers, error, remove_passing_logs, min_level):
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 result before `started`: a call that finishes in between
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 started[index]:
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
- ``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.
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(item, _logger=effective_logger, **kwargs)
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