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.
- ParallelRunner/__init__.py +2 -2
- ParallelRunner/parallel_library.py +522 -55
- {robotframework_parallelrunner-0.3.0.dist-info → robotframework_parallelrunner-0.4.0.dist-info}/METADATA +8 -6
- robotframework_parallelrunner-0.4.0.dist-info/RECORD +9 -0
- robotframework_parallelrunner-0.3.0.dist-info/RECORD +0 -9
- {robotframework_parallelrunner-0.3.0.dist-info → robotframework_parallelrunner-0.4.0.dist-info}/WHEEL +0 -0
- {robotframework_parallelrunner-0.3.0.dist-info → robotframework_parallelrunner-0.4.0.dist-info}/licenses/LICENSE +0 -0
ParallelRunner/__init__.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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 = "
|
|
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
|
-
``
|
|
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
|
-
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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,
|
|
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
|
|
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
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
369
|
-
"""Sequential dump to the real RF Logger
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
|
381
|
-
|
|
382
|
-
|
|
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
|
+
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
|
-
|
|
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
|
|
234
|
-
[ROADMAP.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/ROADMAP.md))
|
|
235
|
-
|
|
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
|
|
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,,
|
|
File without changes
|
|
File without changes
|