robotframework-parallelrunner 0.3.0__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,13 +1,40 @@
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
40
  Raised when at least one parallel task failed: by ``run_parallel_scenarios``
@@ -47,7 +74,8 @@ class ParallelRunner:
47
74
 
48
75
  = How it works =
49
76
 
50
- - 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.
51
79
  ``BuiltIn().run_keyword`` is *not* used inside threads (it is not
52
80
  thread-safe); the underlying Python method of the target library is
53
81
  called directly instead.
@@ -74,8 +102,8 @@ class ParallelRunner:
74
102
  | ...
75
103
  | return result
76
104
 
77
- ``_logger(msg, level)`` accepts the levels ``INFO``, ``WARN``, ``ERROR``
78
- 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
79
107
  `Run Parallel Scenarios` is forwarded to every call as ``**kwargs``.
80
108
 
81
109
  Logging is made thread-safe for you; your keyword's own side effects are
@@ -111,6 +139,40 @@ class ParallelRunner:
111
139
  ``return_values_only=True`` and `Get Result Values` always fail if any
112
140
  call failed, because a failed call has no return value.
113
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)``).
175
+
114
176
  = Custom logger mappers =
115
177
 
116
178
  A logger mapper replaces the default buffering logger: the target keyword
@@ -159,7 +221,7 @@ class ParallelRunner:
159
221
  for_loop_iterable: Optional[Iterable[Any]] = None,
160
222
  repeat: Optional[int] = None,
161
223
  remove_passing_logs: bool = False,
162
- thread_log_level: str = "INFO",
224
+ thread_log_level: str = "TRACE",
163
225
  logger_mapper: Optional[Any] = None,
164
226
  return_values_only: bool = False,
165
227
  fail_on_error: bool = True,
@@ -183,7 +245,9 @@ class ParallelRunner:
183
245
  - ``remove_passing_logs``: If true, logs of passing calls are not
184
246
  replayed into ``log.html``; only failed calls are shown.
185
247
  - ``thread_log_level``: Minimum level replayed from the threads:
186
- ``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.
187
251
  - ``logger_mapper``: Optional callable ``mapper(msg, level)`` - or a
188
252
  ``module.function`` path to one, such as
189
253
  ``my_package.my_module.my_mapper`` - injected as ``_logger`` instead
@@ -229,25 +293,17 @@ class ParallelRunner:
229
293
  if effective_mapper is None:
230
294
  effective_mapper = self._load_mapper_from_environment()
231
295
 
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]
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)
248
304
 
249
305
  # Step: Re-play logs into Robot Framework sequentially
250
- self._replay_logs(results, remove_passing_logs, thread_log_level)
306
+ self._replay_logs(results, remove_passing_logs, min_level)
251
307
 
252
308
  if return_values_only:
253
309
  return self.get_result_values(results)
@@ -343,44 +399,167 @@ class ParallelRunner:
343
399
  return lib_instance
344
400
  raise ValueError(f"Keyword '{keyword_name}' not found in library '{library}'")
345
401
 
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
-
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
+ """
350
511
  # Injection of a custom logger into the thread
351
512
  def thread_log(msg, level="INFO"):
352
- # Filter out IGNORE (sv=0) level logs
353
- 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":
354
516
  log_buffer.append((level, msg))
355
-
517
+
356
518
  # Use mapper if provided, otherwise use thread_log
357
519
  effective_logger = logger_mapper if logger_mapper else thread_log
358
520
 
359
521
  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
522
  result = method(item, _logger=effective_logger, **kwargs)
363
523
  return {"status": "PASS", "item": item, "logs": log_buffer, "result": result}
364
- 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:
365
528
  thread_log(f"Thread failed for item {item}: {str(e)}", "ERROR")
366
529
  return {"status": "FAIL", "item": item, "logs": log_buffer, "error": str(e)}
367
530
 
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)
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 = []
372
542
  for entry in results:
373
543
  if remove_passing_logs and entry['status'] == 'PASS':
374
544
  continue
375
545
  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:
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":
379
550
  continue
380
- if level == "INFO": logger.info(msg)
381
- elif level == "WARN": logger.warn(msg)
382
- 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)
383
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
+ )
384
563
 
385
564
  # Backwards-compatible name: the class was called ParallelLibrary before the
386
565
  # package was renamed to ParallelRunner (imported as `Library ParallelRunner`).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: robotframework-parallelrunner
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Run a keyword in parallel inside a single Robot Framework test case, with one clean, ordered log.html.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -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 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.
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
@@ -1,9 +1,9 @@
1
1
  ParallelRunner/__init__.py,sha256=ifsoO1rXf4kL3i75NokrD_KHxnlvE3IuH69Fb-ByfNg,641
2
- ParallelRunner/parallel_library.py,sha256=qagok_v9Kw_OpsXy5yRb2hvwBpwadfgwhfC5bL1boVE,19123
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.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,,
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,,