robotframework-parallelrunner 0.2.1__py3-none-any.whl → 0.3.1__py3-none-any.whl

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