robotframework-parallelrunner 0.3.0__tar.gz → 0.3.1__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.
- {robotframework_parallelrunner-0.3.0 → robotframework_parallelrunner-0.3.1}/PKG-INFO +8 -6
- {robotframework_parallelrunner-0.3.0 → robotframework_parallelrunner-0.3.1}/README.md +7 -5
- {robotframework_parallelrunner-0.3.0 → robotframework_parallelrunner-0.3.1}/pyproject.toml +1 -1
- {robotframework_parallelrunner-0.3.0 → robotframework_parallelrunner-0.3.1}/src/ParallelRunner/parallel_library.py +222 -43
- {robotframework_parallelrunner-0.3.0 → robotframework_parallelrunner-0.3.1}/LICENSE +0 -0
- {robotframework_parallelrunner-0.3.0 → robotframework_parallelrunner-0.3.1}/src/ParallelRunner/__init__.py +0 -0
- {robotframework_parallelrunner-0.3.0 → robotframework_parallelrunner-0.3.1}/src/ParallelRunner/py.typed +0 -0
- {robotframework_parallelrunner-0.3.0 → robotframework_parallelrunner-0.3.1}/src/ParallelRunner/version.py +0 -0
- {robotframework_parallelrunner-0.3.0 → robotframework_parallelrunner-0.3.1}/src/parallelrunner.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: robotframework-parallelrunner
|
|
3
|
-
Version: 0.3.
|
|
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
|
-
|
|
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
|
|
234
|
-
[ROADMAP.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/ROADMAP.md))
|
|
235
|
-
|
|
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
|
|
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
|
|
@@ -121,7 +121,7 @@ Concretely:
|
|
|
121
121
|
that needs to do the same kind of work many times (loop over a list of IDs,
|
|
122
122
|
or repeat something N times) — how do I parallelize the body of that loop
|
|
123
123
|
without corrupting the log or needing to merge anything?" It uses a
|
|
124
|
-
|
|
124
|
+
pool of threads inside a single process, and produces one
|
|
125
125
|
already-merged, already-ordered log for that test case.
|
|
126
126
|
- Because ParallelRunner uses **threads**, not processes, it shares memory and
|
|
127
127
|
is subject to the GIL — it is a good fit for **I/O-bound** work (network
|
|
@@ -196,9 +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 timeout.** Not implemented yet
|
|
200
|
-
[ROADMAP.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/ROADMAP.md))
|
|
201
|
-
|
|
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.
|
|
202
204
|
- **You need process-level isolation** (a crash in one call shouldn't be
|
|
203
205
|
able to affect another) or cross-machine parallelism — that's pabot's
|
|
204
206
|
domain, not this library's.
|
|
@@ -208,7 +210,7 @@ Things to keep in mind:
|
|
|
208
210
|
```
|
|
209
211
|
robotframework-parallelrunner/
|
|
210
212
|
├── src/ParallelRunner/ # The installable library (do not depend on internals prefixed with `_`)
|
|
211
|
-
├── examples/ # Example libraries used by the tests, plus the pabot
|
|
213
|
+
├── examples/ # Example libraries used by the tests, plus the pabot and log levels demos
|
|
212
214
|
├── atest/ # Robot Framework acceptance suites
|
|
213
215
|
├── utest/ # pytest unit tests
|
|
214
216
|
├── docs/ # INSTALLATION, QUICKSTART, API_REFERENCE, generated keyword docs
|
|
@@ -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.
|
|
9
|
+
version = "0.3.1"
|
|
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"
|
|
@@ -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
|
|
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
|
|
78
|
-
|
|
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 = "
|
|
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
|
-
``
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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,
|
|
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
|
|
347
|
-
"""
|
|
348
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
369
|
-
"""Sequential dump to the real RF Logger
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
|
381
|
-
|
|
382
|
-
|
|
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`).
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{robotframework_parallelrunner-0.3.0 → robotframework_parallelrunner-0.3.1}/src/parallelrunner.py
RENAMED
|
File without changes
|