robotframework-parallelrunner 0.3.0__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,13 +1,85 @@
1
1
  import os
2
- import concurrent.futures
3
2
  import importlib
3
+ import inspect
4
+ import queue
5
+ import threading
6
+ import time
4
7
  from typing import Iterable, Any, List, Dict, Optional, Tuple, Union
5
8
  from robot.api import logger
9
+ from robot.errors import DataError
10
+ try:
11
+ from robot.errors import TimeoutExceeded
12
+ except ImportError: # Robot Framework < 7.0
13
+ from robot.errors import TimeoutError as TimeoutExceeded
6
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
7
17
 
8
18
  from .version import __version__
9
19
 
10
20
 
21
+ # Robot Framework log levels, in increasing severity. HTML and CONSOLE are
22
+ # INFO messages (rendered as HTML / also written to the console).
23
+ LOG_LEVELS = {"TRACE": 0, "DEBUG": 1, "INFO": 2, "HTML": 2, "CONSOLE": 2, "WARN": 3, "ERROR": 4}
24
+ # Spellings from Python's `logging` module, mapped to the Robot level.
25
+ LOG_LEVEL_ALIASES = {"WARNING": "WARN", "CRITICAL": "ERROR", "FATAL": "ERROR"}
26
+
27
+ # How often the main thread checks whether the calls have finished. Waiting in
28
+ # short sleeps (instead of blocking on a lock until the end) is what lets a
29
+ # Robot `[Timeout]` interrupt the keyword: on Windows the timeout is raised
30
+ # asynchronously and only arrives while Python code runs.
31
+ _POLL_INTERVAL = 0.01
32
+
33
+
34
+ def _normalize_level(level: Any) -> Optional[str]:
35
+ """Returns the Robot level name for ``level`` (any case, or a Python
36
+ ``logging`` alias), ``"IGNORE"``, or None if it's not a known level."""
37
+ name = str(level).strip().upper()
38
+ name = LOG_LEVEL_ALIASES.get(name, name)
39
+ return name if name in LOG_LEVELS or name == "IGNORE" else None
40
+
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
+
11
83
  class ParallelTaskError(RuntimeError):
12
84
  """
13
85
  Raised when at least one parallel task failed: by ``run_parallel_scenarios``
@@ -47,7 +119,8 @@ class ParallelRunner:
47
119
 
48
120
  = How it works =
49
121
 
50
- - Each call runs on a worker thread of a ``ThreadPoolExecutor``.
122
+ - Each call runs on one of a pool of worker threads (see
123
+ ``ROBOT_THREAD_WORKERS``), created for that keyword call.
51
124
  ``BuiltIn().run_keyword`` is *not* used inside threads (it is not
52
125
  thread-safe); the underlying Python method of the target library is
53
126
  called directly instead.
@@ -65,18 +138,33 @@ class ParallelRunner:
65
138
  index) as its first argument and an injected ``_logger`` callable that
66
139
  it should use instead of ``robot.api.logger``:
67
140
 
68
- | from robot.api import logger
141
+ | from ParallelRunner import log_to_robot
69
142
  |
70
143
  | class MyLibrary:
71
- | def verify_agent(self, agent_id, _logger=None, **kwargs):
72
- | 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
73
146
  | log(f"Checking agent {agent_id}")
74
147
  | ...
75
148
  | return result
76
149
 
77
- ``_logger(msg, level)`` accepts the levels ``INFO``, ``WARN``, ``ERROR``
78
- and ``IGNORE`` (dropped). Any extra named argument given to
79
- `Run Parallel Scenarios` is forwarded to every call as ``**kwargs``.
150
+ ``_logger(msg, level)`` accepts every Robot Framework log level (see
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`.
80
168
 
81
169
  Logging is made thread-safe for you; your keyword's own side effects are
82
170
  not. Protect shared state (files, shared objects) with a lock.
@@ -90,7 +178,7 @@ class ParallelRunner:
90
178
  | item | The item (or repeat index) the call received. |
91
179
  | logs | List of ``(level, message)`` tuples captured during the call. |
92
180
  | result | The return value of the call (only when ``status`` is ``PASS``). |
93
- | 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``. |
94
182
 
95
183
  = Failures =
96
184
 
@@ -111,6 +199,78 @@ class ParallelRunner:
111
199
  ``return_values_only=True`` and `Get Result Values` always fail if any
112
200
  call failed, because a failed call has no return value.
113
201
 
202
+ = Log levels =
203
+
204
+ The injected ``_logger`` accepts the same levels as Robot Framework's
205
+ ``logger.write``: ``TRACE``, ``DEBUG``, ``INFO``, ``HTML`` (an ``INFO``
206
+ message rendered as HTML), ``CONSOLE`` (an ``INFO`` message also written
207
+ to the console), ``WARN`` and ``ERROR``. Levels are case-insensitive, and
208
+ Python ``logging`` names are accepted too: ``WARNING`` is ``WARN``,
209
+ ``CRITICAL`` and ``FATAL`` are ``ERROR``. ``IGNORE`` drops the message.
210
+
211
+ Replayed messages go through Robot's normal filtering, so ``TRACE`` and
212
+ ``DEBUG`` messages only show up in ``log.html`` when the run uses a low
213
+ enough ``--loglevel`` (e.g. ``--loglevel DEBUG``), exactly as they would
214
+ from a normal keyword. ``thread_log_level`` filters the threads' messages
215
+ on top of that (by default it lets every level through).
216
+
217
+ A message with an unknown level is not lost: it is logged at ``INFO``
218
+ with the level as a prefix (``[VERBOSE] message``), and a warning names
219
+ the unknown level once per keyword call.
220
+
221
+ = Timeouts =
222
+
223
+ A Robot ``[Timeout]`` (test or keyword) interrupts `Run Parallel Scenarios`
224
+ as soon as it expires, not when the whole batch has finished. Before the
225
+ timeout failure is reported, the logs collected so far are replayed:
226
+ finished calls as usual, and every call that did not finish with its
227
+ partial logs and an ``ERROR`` saying it was interrupted.
228
+
229
+ Python threads can't be killed, so calls that are already running keep
230
+ running in the background until they return (possibly while the next test
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).
273
+
114
274
  = Custom logger mappers =
115
275
 
116
276
  A logger mapper replaces the default buffering logger: the target keyword
@@ -134,7 +294,7 @@ class ParallelRunner:
134
294
  = Environment variables =
135
295
 
136
296
  | =Variable= | =Description= |
137
- | 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. |
138
298
  | ROBOT_LOGGER_MAPPER | Default ``logger_mapper`` as a ``module.function`` path, used when the argument is not given. See `Custom logger mappers`. |
139
299
 
140
300
  = When not to use it =
@@ -148,9 +308,29 @@ class ParallelRunner:
148
308
  ROBOT_LIBRARY_VERSION = __version__
149
309
  ROBOT_LIBRARY_DOC_FORMAT = 'ROBOT'
150
310
 
311
+ DEFAULT_WORKERS = 4
312
+
151
313
  def __init__(self):
152
- # Workers count from Env Var or default to 4
153
- 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
154
334
 
155
335
  def run_parallel_scenarios(
156
336
  self,
@@ -159,10 +339,11 @@ class ParallelRunner:
159
339
  for_loop_iterable: Optional[Iterable[Any]] = None,
160
340
  repeat: Optional[int] = None,
161
341
  remove_passing_logs: bool = False,
162
- thread_log_level: str = "INFO",
342
+ thread_log_level: str = "TRACE",
163
343
  logger_mapper: Optional[Any] = None,
164
344
  return_values_only: bool = False,
165
345
  fail_on_error: bool = True,
346
+ call_timeout: Union[str, float, None] = None,
166
347
  **kwargs
167
348
  ) -> Union[List[Dict[str, Any]], Tuple[Any, ...]]:
168
349
  """Runs ``keyword`` from ``library`` concurrently, once per item or N times.
@@ -183,7 +364,9 @@ class ParallelRunner:
183
364
  - ``remove_passing_logs``: If true, logs of passing calls are not
184
365
  replayed into ``log.html``; only failed calls are shown.
185
366
  - ``thread_log_level``: Minimum level replayed from the threads:
186
- ``INFO`` (default), ``WARN`` or ``ERROR``.
367
+ ``TRACE`` (default, every message), ``DEBUG``, ``INFO``, ``WARN``
368
+ or ``ERROR``. Robot's own ``--loglevel`` still applies on top of it
369
+ (see `Log levels`). An unknown value is ignored with a warning.
187
370
  - ``logger_mapper``: Optional callable ``mapper(msg, level)`` - or a
188
371
  ``module.function`` path to one, such as
189
372
  ``my_package.my_module.my_mapper`` - injected as ``_logger`` instead
@@ -199,7 +382,14 @@ class ParallelRunner:
199
382
  when any call failed, after all calls have finished and their logs
200
383
  have been replayed. If false, return the results anyway and leave
201
384
  checking ``status`` to the caller. See `Failures`.
202
- - ``**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.
203
393
 
204
394
  Returns a list of result dictionaries (see `Result format`), in call
205
395
  order: entry ``i`` belongs to ``for_loop_iterable[i]`` / repeat
@@ -212,6 +402,7 @@ class ParallelRunner:
212
402
  | ${results}= | Run Parallel Scenarios | keyword=Check Health | library=MyLibrary | repeat=8 | agent_id=1 |
213
403
  | ${id1} ${id2} ${id3}= | Run Parallel Scenarios | keyword=Seed Record | library=MyLibrary | repeat=3 | return_values_only=True |
214
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 |
215
406
  """
216
407
  # Determine items to process. NOTE: `repeat or 1` would be wrong here -
217
408
  # 0 is falsy in Python, so an explicit repeat=0 would silently fall
@@ -229,25 +420,20 @@ class ParallelRunner:
229
420
  if effective_mapper is None:
230
421
  effective_mapper = self._load_mapper_from_environment()
231
422
 
232
- with concurrent.futures.ThreadPoolExecutor(max_workers=self.max_workers) as executor:
233
- # We map the execution.
234
- # Note: We must call the underlying Python method, not BuiltIn().run_keyword
235
- # because run_keyword is not thread-safe.
236
- lib_instance = self._get_library_instance_owning_keyword(keyword, library)
237
- method = getattr(lib_instance, keyword.replace(" ", "_").lower())
238
-
239
- # Submit every task up front so they all start running concurrently,
240
- # then collect results in submission order (NOT as_completed order) -
241
- # tasks[i].result() blocks only until the i-th task finishes, it
242
- # doesn't force tasks to run one at a time.
243
- tasks = [
244
- executor.submit(self._execute_and_capture, method, item, effective_mapper, **kwargs)
245
- for item in items
246
- ]
247
- results = [task.result() for task in tasks]
423
+ min_level = self._resolve_thread_log_level(thread_log_level)
424
+ timeout_seconds = self._resolve_call_timeout(call_timeout)
425
+
426
+ # Note: We must call the underlying Python method, not BuiltIn().run_keyword
427
+ # because run_keyword is not thread-safe.
428
+ lib_instance = self._get_library_instance_owning_keyword(keyword, library)
429
+ method = self._get_keyword_method(lib_instance, keyword)
430
+
431
+ results = self._run_in_threads(
432
+ method, list(items), effective_mapper, remove_passing_logs, min_level, kwargs, timeout_seconds
433
+ )
248
434
 
249
435
  # Step: Re-play logs into Robot Framework sequentially
250
- self._replay_logs(results, remove_passing_logs, thread_log_level)
436
+ self._replay_logs(results, remove_passing_logs, min_level)
251
437
 
252
438
  if return_values_only:
253
439
  return self.get_result_values(results)
@@ -337,50 +523,331 @@ class ParallelRunner:
337
523
  return self._resolve_mapper(os.getenv("ROBOT_LOGGER_MAPPER"), source="ROBOT_LOGGER_MAPPER")
338
524
 
339
525
  def _get_library_instance_owning_keyword(self, keyword_name: str, library: str) -> Any:
340
- method_name = keyword_name.replace(" ", "_").lower()
341
526
  lib_instance = BuiltIn().get_library_instance(library)
342
- if hasattr(lib_instance, method_name):
527
+ if self._find_keyword_method(lib_instance, keyword_name) is not None:
343
528
  return lib_instance
344
529
  raise ValueError(f"Keyword '{keyword_name}' not found in library '{library}'")
345
530
 
346
- def _execute_and_capture(self, method, item, logger_mapper: Optional[Any] = None, **kwargs) -> Dict[str, Any]:
347
- """Worker wrapper to capture logs and results."""
348
- log_buffer = []
349
-
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
+
560
+ def _resolve_thread_log_level(self, thread_log_level: Any) -> int:
561
+ """Returns the numeric minimum level for ``thread_log_level``. Not
562
+ given (None, empty, "None") means every level; an unknown value is
563
+ ignored with a warning instead of silently filtering by INFO."""
564
+ if _not_given(thread_log_level):
565
+ return LOG_LEVELS["TRACE"]
566
+ level = _normalize_level(thread_log_level)
567
+ if level in LOG_LEVELS:
568
+ return LOG_LEVELS[level]
569
+ logger.warn(
570
+ f"Ignoring thread_log_level {thread_log_level!r}: expected TRACE, DEBUG, INFO, WARN or ERROR. "
571
+ "Replaying every level."
572
+ )
573
+ return LOG_LEVELS["TRACE"]
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
+
610
+ def _run_in_threads(
611
+ self,
612
+ method,
613
+ items: List[Any],
614
+ logger_mapper: Optional[Any],
615
+ remove_passing_logs: bool,
616
+ min_level: int,
617
+ kwargs: Dict[str, Any],
618
+ call_timeout: Optional[float] = None,
619
+ ) -> List[Dict[str, Any]]:
620
+ """
621
+ Runs ``method`` once per item on up to ``max_workers`` daemon threads
622
+ and returns the result dicts in call order (NOT completion order).
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
+
629
+ If the wait is interrupted (mostly by a Robot ``[Timeout]``), the
630
+ workers stop starting new calls, the logs collected so far are
631
+ replayed and the exception is re-raised. Calls already running can't
632
+ be stopped: they finish in the background. The threads are daemon
633
+ threads (unlike the ones of ``ThreadPoolExecutor``), so a call that
634
+ never returns can't keep the Robot process alive at exit.
635
+ """
636
+ if self.max_workers < 1:
637
+ raise ValueError(f"ROBOT_THREAD_WORKERS must be at least 1, got {self.max_workers}.")
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
+
647
+ # Created here, not in the workers, so the logs of calls that are still
648
+ # running can be replayed if the keyword is interrupted or times out.
649
+ log_buffers: List[list] = [[] for _ in 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)
665
+ pending = queue.SimpleQueue()
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
677
+ pending.put(index)
678
+ stop = threading.Event()
679
+ workers_started = 0
680
+
681
+ def worker():
682
+ while not stop.is_set():
683
+ try:
684
+ index = pending.get_nowait()
685
+ except queue.Empty:
686
+ return
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]
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()
702
+
703
+ try:
704
+ for _ in range(min(self.max_workers, count)):
705
+ start_worker()
706
+ # Deliberately no lock, Event or Future wait here: a timeout raised
707
+ # asynchronously while such a wait holds an internal lock could
708
+ # leave it locked forever. Short sleeps and plain reads are safe.
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:
739
+ time.sleep(_POLL_INTERVAL)
740
+ except BaseException as error:
741
+ stop.set()
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
+ )
748
+ raise
749
+ return results
750
+
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):
760
+ """Replays what the calls logged before the keyword was interrupted.
761
+ Calls that never started are only counted, not listed one by one."""
762
+ # On Windows Robot raises the timeout as a bare class, without message.
763
+ reason = str(error) or ("Robot timeout exceeded" if isinstance(error, TimeoutExceeded) else type(error).__name__)
764
+ partial = []
765
+ running = not_started = 0
766
+ for index, item in enumerate(items):
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]
770
+ if result is not None:
771
+ partial.append(result)
772
+ elif started_at[index] is not None:
773
+ running += 1
774
+ logs = list(log_buffers[index])
775
+ logs.append(("ERROR", f"Call for item {item} did not finish: still running when the keyword was interrupted ({reason})"))
776
+ partial.append({"status": "FAIL", "item": item, "logs": logs, "error": f"Did not finish: interrupted ({reason})"})
777
+ else:
778
+ not_started += 1
779
+ logger.info(
780
+ f"Run Parallel Scenarios was interrupted ({reason}): {len(partial) - running} call(s) finished, "
781
+ f"{running} still running (left running in the background), {not_started} not started."
782
+ )
783
+ self._replay_logs(partial, remove_passing_logs, min_level)
784
+
785
+ def _execute_and_capture(
786
+ self, method, item, logger_mapper: Optional[Any], log_buffer: list, kwargs: Dict[str, Any],
787
+ argument: Any = _NOT_SET,
788
+ ) -> Dict[str, Any]:
789
+ """Worker wrapper to capture logs and results.
790
+
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.
796
+ """
797
+ if argument is _NOT_SET:
798
+ argument = item
350
799
  # Injection of a custom logger into the thread
351
800
  def thread_log(msg, level="INFO"):
352
- # Filter out IGNORE (sv=0) level logs
353
- if level != "IGNORE":
801
+ # Filter out IGNORE (sv=0) level logs. Other levels are kept as
802
+ # given and checked when the logs are replayed.
803
+ if _normalize_level(level) != "IGNORE":
354
804
  log_buffer.append((level, msg))
355
-
805
+
356
806
  # Use mapper if provided, otherwise use thread_log
357
807
  effective_logger = logger_mapper if logger_mapper else thread_log
358
808
 
359
809
  try:
360
- # We pass the custom logger as an extra kwarg if the method supports it
361
- # or rely on the method returning its own logs.
362
- result = method(item, _logger=effective_logger, **kwargs)
810
+ result = method(argument, _logger=effective_logger, **kwargs)
363
811
  return {"status": "PASS", "item": item, "logs": log_buffer, "result": result}
364
- except Exception as e:
812
+ # BaseException, not Exception: whatever the call raises (even
813
+ # SystemExit), the worker must report a result, or the main thread
814
+ # would wait for this call forever.
815
+ except BaseException as e:
365
816
  thread_log(f"Thread failed for item {item}: {str(e)}", "ERROR")
366
817
  return {"status": "FAIL", "item": item, "logs": log_buffer, "error": str(e)}
367
818
 
368
- def _replay_logs(self, results: List[Dict[str, Any]], remove_passing_logs: bool = False, thread_log_level: str = "INFO"):
369
- """Sequential dump to the real RF Logger. Filters out IGNORE level logs."""
370
- level_order = {"INFO": 1, "WARN": 2, "ERROR": 3}
371
- min_level = level_order.get(thread_log_level, 1)
819
+ def _replay_logs(self, results: List[Dict[str, Any]], remove_passing_logs: bool = False, min_level: int = 0):
820
+ """Sequential dump to the real RF Logger, in call order.
821
+
822
+ Every Robot level goes through ``logger.write``, so Robot's
823
+ ``--loglevel`` filters it as usual. IGNORE messages and levels below
824
+ ``min_level`` are skipped. A message with an unknown level is kept at
825
+ INFO, prefixed with its level, plus one warning per unknown level.
826
+ (Before 0.3.1 only INFO/WARN/ERROR were written; everything else,
827
+ DEBUG included, was dropped silently.)
828
+ """
829
+ unknown_levels = []
372
830
  for entry in results:
373
831
  if remove_passing_logs and entry['status'] == 'PASS':
374
832
  continue
375
833
  logger.info(f"--- Logs for Item: {entry['item']} ---")
376
- for level, msg in entry['logs']:
377
- # Skip IGNORE level and logs below minimum level
378
- if level == "IGNORE" or level_order.get(level, 1) < min_level:
834
+ # A copy: after an interruption, a call still running may append to it.
835
+ for level, msg in list(entry['logs']):
836
+ name = _normalize_level(level)
837
+ if name == "IGNORE":
379
838
  continue
380
- if level == "INFO": logger.info(msg)
381
- elif level == "WARN": logger.warn(msg)
382
- elif level == "ERROR": logger.error(msg)
839
+ if name is None:
840
+ if level not in unknown_levels:
841
+ unknown_levels.append(level)
842
+ name, msg = "INFO", f"[{level}] {msg}"
843
+ if LOG_LEVELS[name] >= min_level:
844
+ logger.write(msg, name)
383
845
  logger.info(f"Status: {entry['status']}")
846
+ for level in unknown_levels:
847
+ logger.warn(
848
+ f"Unknown log level {level!r} used with _logger: its messages were logged as INFO, "
849
+ "prefixed with the level. Use TRACE, DEBUG, INFO, HTML, CONSOLE, WARN, ERROR or IGNORE."
850
+ )
384
851
 
385
852
  # Backwards-compatible name: the class was called ParallelLibrary before the
386
853
  # 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.0
3
+ Version: 0.4.0
4
4
  Summary: Run a keyword in parallel inside a single Robot Framework test case, with one clean, ordered log.html.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -155,7 +155,7 @@ Concretely:
155
155
  that needs to do the same kind of work many times (loop over a list of IDs,
156
156
  or repeat something N times) — how do I parallelize the body of that loop
157
157
  without corrupting the log or needing to merge anything?" It uses a
158
- `ThreadPoolExecutor` inside a single process, and produces one
158
+ pool of threads inside a single process, and produces one
159
159
  already-merged, already-ordered log for that test case.
160
160
  - Because ParallelRunner uses **threads**, not processes, it shares memory and
161
161
  is subject to the GIL — it is a good fit for **I/O-bound** work (network
@@ -230,9 +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 timeout.** Not implemented yet (see
234
- [ROADMAP.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/ROADMAP.md)) — one hung call currently blocks the whole batch
235
- from returning.
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.
236
238
  - **You need process-level isolation** (a crash in one call shouldn't be
237
239
  able to affect another) or cross-machine parallelism — that's pabot's
238
240
  domain, not this library's.
@@ -242,7 +244,7 @@ Things to keep in mind:
242
244
  ```
243
245
  robotframework-parallelrunner/
244
246
  ├── src/ParallelRunner/ # The installable library (do not depend on internals prefixed with `_`)
245
- ├── examples/ # Example libraries used by the tests, plus the pabot timing demo
247
+ ├── examples/ # Example libraries used by the tests, plus the pabot and log levels demos
246
248
  ├── atest/ # Robot Framework acceptance suites
247
249
  ├── utest/ # pytest unit tests
248
250
  ├── docs/ # INSTALLATION, QUICKSTART, API_REFERENCE, generated keyword docs
@@ -0,0 +1,9 @@
1
+ ParallelRunner/__init__.py,sha256=x74aR4t2SZvD81MepoTobACqyLfX_rKaEWPJY08kvTA,671
2
+ ParallelRunner/parallel_library.py,sha256=oXaLDC09KAJ0RzLooIw-fP1nZ-iuVZ7gzTs0bbck5kA,42180
3
+ ParallelRunner/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ ParallelRunner/version.py,sha256=W8E8ymdTLqfKl_1qBIOrjGpO8kSbqPw30aYZpqYUNMk,632
5
+ parallelrunner.py,sha256=2oyKOVeKlOaZUZ3f2i_l5xCAYFKVm11bSQZOtzSSJO8,1132
6
+ robotframework_parallelrunner-0.4.0.dist-info/METADATA,sha256=XxHtM1t5mGkD_W5NDfP_K6ckulVLodz2eRnbjzHbzak,14488
7
+ robotframework_parallelrunner-0.4.0.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
8
+ robotframework_parallelrunner-0.4.0.dist-info/licenses/LICENSE,sha256=xxQh3uvlk5jC1i0eoH5Nyo9cDz2Fzu0DqOzAZw8XxFs,1072
9
+ robotframework_parallelrunner-0.4.0.dist-info/RECORD,,
@@ -1,9 +0,0 @@
1
- ParallelRunner/__init__.py,sha256=ifsoO1rXf4kL3i75NokrD_KHxnlvE3IuH69Fb-ByfNg,641
2
- ParallelRunner/parallel_library.py,sha256=qagok_v9Kw_OpsXy5yRb2hvwBpwadfgwhfC5bL1boVE,19123
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.0.dist-info/METADATA,sha256=6Ph0tfBiO_I6mZ4BP606SDGfJYwCUzOICu5LHMoq1kY,14313
7
- robotframework_parallelrunner-0.3.0.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
8
- robotframework_parallelrunner-0.3.0.dist-info/licenses/LICENSE,sha256=xxQh3uvlk5jC1i0eoH5Nyo9cDz2Fzu0DqOzAZw8XxFs,1072
9
- robotframework_parallelrunner-0.3.0.dist-info/RECORD,,