robotframework-parallelrunner 0.2.0__py3-none-any.whl → 0.2.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.
- ParallelRunner/parallel_library.py +63 -33
- {robotframework_parallelrunner-0.2.0.dist-info → robotframework_parallelrunner-0.2.1.dist-info}/METADATA +63 -8
- {robotframework_parallelrunner-0.2.0.dist-info → robotframework_parallelrunner-0.2.1.dist-info}/RECORD +5 -5
- {robotframework_parallelrunner-0.2.0.dist-info → robotframework_parallelrunner-0.2.1.dist-info}/WHEEL +0 -0
- {robotframework_parallelrunner-0.2.0.dist-info → robotframework_parallelrunner-0.2.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import concurrent.futures
|
|
3
|
+
import importlib
|
|
3
4
|
from typing import Iterable, Any, List, Dict, Optional, Tuple, Union
|
|
4
5
|
from robot.api import logger
|
|
5
6
|
from robot.libraries.BuiltIn import BuiltIn
|
|
@@ -94,11 +95,31 @@ class ParallelRunner:
|
|
|
94
95
|
or use ``return_values_only=True`` / `Get Result Values`, which fail
|
|
95
96
|
if any call failed.
|
|
96
97
|
|
|
98
|
+
= Custom logger mappers =
|
|
99
|
+
|
|
100
|
+
A logger mapper replaces the default buffering logger: the target keyword
|
|
101
|
+
receives it as ``_logger`` and every message goes straight to your own
|
|
102
|
+
logging system. Use it when a project already has its own log format
|
|
103
|
+
(severity codes, JSON...).
|
|
104
|
+
|
|
105
|
+
- Give it as a callable or as a ``module.function`` path, either with the
|
|
106
|
+
``logger_mapper`` argument or, for a project-wide default, with the
|
|
107
|
+
``ROBOT_LOGGER_MAPPER`` environment variable. The argument wins.
|
|
108
|
+
- Bare names (without a module path) are not supported. A value that
|
|
109
|
+
can't be resolved is ignored with a warning in the log.
|
|
110
|
+
- Mapper messages are *not* buffered and replayed into ``log.html``, so
|
|
111
|
+
``thread_log_level`` and ``remove_passing_logs`` don't apply to them.
|
|
112
|
+
The mapper is called from several threads at once, so it must be
|
|
113
|
+
thread-safe.
|
|
114
|
+
- ``ROBOT_LOGGER_MAPPER`` must be a real environment variable (e.g. set
|
|
115
|
+
with ``Set Environment Variable`` or in the shell); a Robot variable
|
|
116
|
+
given with ``--variable`` is not read.
|
|
117
|
+
|
|
97
118
|
= Environment variables =
|
|
98
119
|
|
|
99
120
|
| =Variable= | =Description= |
|
|
100
121
|
| ROBOT_THREAD_WORKERS | Number of worker threads. Read when the library is imported. Default ``4``. |
|
|
101
|
-
| ROBOT_LOGGER_MAPPER | Default ``logger_mapper`` as a ``module.function`` path, used when the argument is not given. |
|
|
122
|
+
| ROBOT_LOGGER_MAPPER | Default ``logger_mapper`` as a ``module.function`` path, used when the argument is not given. See `Custom logger mappers`. |
|
|
102
123
|
|
|
103
124
|
= When not to use it =
|
|
104
125
|
|
|
@@ -147,10 +168,12 @@ class ParallelRunner:
|
|
|
147
168
|
- ``thread_log_level``: Minimum level replayed from the threads:
|
|
148
169
|
``INFO`` (default), ``WARN`` or ``ERROR``.
|
|
149
170
|
- ``logger_mapper``: Optional callable ``mapper(msg, level)`` - or a
|
|
150
|
-
``module.function`` path to one
|
|
151
|
-
|
|
171
|
+
``module.function`` path to one, such as
|
|
172
|
+
``my_package.my_module.my_mapper`` - injected as ``_logger`` instead
|
|
173
|
+
of the default buffering logger, to route logs to a custom logging
|
|
152
174
|
system. Falls back to the ``ROBOT_LOGGER_MAPPER`` environment
|
|
153
|
-
variable.
|
|
175
|
+
variable. A value that can't be resolved (e.g. a bare name without a
|
|
176
|
+
module) is ignored with a warning. See `Custom logger mappers`.
|
|
154
177
|
- ``return_values_only``: If true, return a plain tuple of each call's
|
|
155
178
|
return value (in call order) instead of the result dictionaries.
|
|
156
179
|
Fails with ``ParallelTaskError`` if any call failed. Same as calling
|
|
@@ -229,52 +252,59 @@ class ParallelRunner:
|
|
|
229
252
|
raise ParallelTaskError(failures, len(results))
|
|
230
253
|
return tuple(entry["result"] for entry in results)
|
|
231
254
|
|
|
232
|
-
def _resolve_mapper(self, mapper: Optional[Any]) -> Optional[Any]:
|
|
255
|
+
def _resolve_mapper(self, mapper: Optional[Any], source: str = "logger_mapper") -> Optional[Any]:
|
|
233
256
|
"""
|
|
234
|
-
Resolve a mapper
|
|
235
|
-
|
|
257
|
+
Resolve a mapper given as a callable or as a ``module.function`` path
|
|
258
|
+
(e.g. "examples.custom_logger.custom_logging_mapper.custom_logger_adapter").
|
|
259
|
+
|
|
260
|
+
Returns the callable, or None when no mapper was given. A mapper that
|
|
261
|
+
was given but can't be resolved (a bare name without a module, a module
|
|
262
|
+
that can't be imported, a missing or non-callable attribute) is ignored
|
|
263
|
+
with a warning in the log - it used to be ignored silently, which hid
|
|
264
|
+
typos and unsupported values such as bare registered names.
|
|
265
|
+
|
|
236
266
|
Args:
|
|
237
|
-
mapper: Callable
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
267
|
+
mapper: Callable, ``module.function`` string, or "not given": None,
|
|
268
|
+
an empty string, or the string "None" (any case - Robot
|
|
269
|
+
Framework's usual spelling of None, e.g. ``logger_mapper=None``).
|
|
270
|
+
source: Where the value came from, used in the warning message.
|
|
241
271
|
"""
|
|
242
|
-
if mapper is None:
|
|
272
|
+
if mapper is None or (isinstance(mapper, str) and mapper.strip().upper() in ("", "NONE")):
|
|
243
273
|
return None
|
|
244
|
-
|
|
245
|
-
# If already callable, return it
|
|
274
|
+
|
|
246
275
|
if callable(mapper):
|
|
247
276
|
return mapper
|
|
248
|
-
|
|
249
|
-
# If it's a string, try to import it
|
|
277
|
+
|
|
250
278
|
if isinstance(mapper, str):
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
279
|
+
module_name, _, func_name = mapper.strip().rpartition(".")
|
|
280
|
+
if not module_name or not func_name:
|
|
281
|
+
reason = "expected a 'module.function' path such as 'my_package.my_module.my_mapper'"
|
|
282
|
+
else:
|
|
283
|
+
try:
|
|
284
|
+
module = importlib.import_module(module_name)
|
|
285
|
+
except ImportError as error:
|
|
286
|
+
reason = f"cannot import module '{module_name}' ({error})"
|
|
287
|
+
else:
|
|
256
288
|
resolved = getattr(module, func_name, None)
|
|
257
289
|
if callable(resolved):
|
|
258
290
|
return resolved
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
291
|
+
reason = f"module '{module_name}' has no callable '{func_name}'"
|
|
292
|
+
else:
|
|
293
|
+
reason = f"expected a callable or a 'module.function' string, got {type(mapper).__name__}"
|
|
294
|
+
|
|
295
|
+
logger.warn(f"Ignoring {source} {mapper!r}: {reason}.")
|
|
262
296
|
return None
|
|
263
297
|
|
|
264
298
|
def _load_mapper_from_environment(self) -> Optional[Any]:
|
|
265
299
|
"""
|
|
266
|
-
Load
|
|
267
|
-
|
|
268
|
-
|
|
300
|
+
Load the logger mapper named by the ROBOT_LOGGER_MAPPER environment
|
|
301
|
+
variable, which must be a ``module.function`` path (bare names are not
|
|
302
|
+
supported and are ignored with a warning).
|
|
303
|
+
|
|
269
304
|
Returns:
|
|
270
305
|
Callable: The mapper function if found, None otherwise.
|
|
271
306
|
"""
|
|
272
|
-
|
|
273
|
-
if not mapper_name:
|
|
274
|
-
return None
|
|
275
|
-
|
|
276
|
-
# Use _resolve_mapper to handle the string path
|
|
277
|
-
return self._resolve_mapper(mapper_name)
|
|
307
|
+
return self._resolve_mapper(os.getenv("ROBOT_LOGGER_MAPPER"), source="ROBOT_LOGGER_MAPPER")
|
|
278
308
|
|
|
279
309
|
def _get_library_instance_owning_keyword(self, keyword_name: str, library: str) -> Any:
|
|
280
310
|
method_name = keyword_name.replace(" ", "_").lower()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: robotframework-parallelrunner
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.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
|
|
@@ -112,8 +112,10 @@ threading, buffering, and replay logic stay out of your `.robot` files.
|
|
|
112
112
|
|
|
113
113
|
## Summary of Benefits
|
|
114
114
|
|
|
115
|
-
- **Speed.**
|
|
116
|
-
|
|
115
|
+
- **Speed.** Waiting time is overlapped instead of added up. In this repo's
|
|
116
|
+
examples, 9 HTTP checks with a 1 s delay each drop from 13 s to 2 s with
|
|
117
|
+
9 workers, and 4 suites × 8 items × 1 s drop from 32.7 s to 4.6 s with
|
|
118
|
+
8 workers (see [Using It Together With pabot](#using-it-together-with-pabot)).
|
|
117
119
|
- **Integrity.** Your `log.html` remains the single source of truth — no
|
|
118
120
|
broken XML tags from concurrent writes.
|
|
119
121
|
- **Flexibility.** Pass `repeat=10` to stress-test a single endpoint, or
|
|
@@ -164,6 +166,57 @@ for pabot. If you need to speed up "this one test case loops over 100 items
|
|
|
164
166
|
and I want a single readable log instead of 100 sequential HTTP round trips
|
|
165
167
|
(or 100 merged `output.xml` files)," that's what ParallelRunner is for.
|
|
166
168
|
|
|
169
|
+
## Using It Together With pabot
|
|
170
|
+
|
|
171
|
+
The two combine without any extra configuration: pabot splits suites (or
|
|
172
|
+
tests) across processes, and inside each process ParallelRunner splits a
|
|
173
|
+
loop across threads. Every pabot process gets its own ParallelRunner
|
|
174
|
+
instance, its own thread pool and its own `output.xml`, so nothing is shared
|
|
175
|
+
between processes, and pabot merges the results as usual.
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
pabot --processes 4 --pythonpath . atest/
|
|
179
|
+
pabot --processes 4 --testlevelsplit --pythonpath . atest/
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Both commands pass the full acceptance suite of this repository (41 tests;
|
|
183
|
+
checked with pabot 5.2.2).
|
|
184
|
+
|
|
185
|
+
How much time each one saves, from the offline
|
|
186
|
+
[pabot timing demo](https://github.com/cristiangarciavd/robotframework-parallelrunner/tree/main/examples/pabot_demo/)
|
|
187
|
+
(4 suites × 8 items × 1 s of simulated I/O wait; `poetry run invoke demo-pabot`):
|
|
188
|
+
|
|
189
|
+
| Run | Wall-clock time | Speedup |
|
|
190
|
+
|---|---|---|
|
|
191
|
+
| robot, sequential loop | 32.7 s | ×1.0 |
|
|
192
|
+
| robot + ParallelRunner (8 workers) | 4.6 s | ×7.1 |
|
|
193
|
+
| pabot (4 processes), sequential loop | 10.6 s | ×3.1 |
|
|
194
|
+
| pabot (4 processes) + ParallelRunner (8 workers) | 3.5 s | ×9.2 |
|
|
195
|
+
|
|
196
|
+
ParallelRunner removes the waiting *inside* each test, and pabot removes it
|
|
197
|
+
*between* suites. With suites this short, pabot's process start-up (about
|
|
198
|
+
2.5 s here) is most of what's left, so the combination pays off more as
|
|
199
|
+
suites get longer.
|
|
200
|
+
|
|
201
|
+
Things to keep in mind:
|
|
202
|
+
|
|
203
|
+
- **Run pabot from the virtual environment where the library is installed**
|
|
204
|
+
(or via `poetry run pabot ...`). pabot starts whichever `robot` command is
|
|
205
|
+
first on the `PATH`. If that belongs to another Python installation, every
|
|
206
|
+
test that uses the library fails with
|
|
207
|
+
`No keyword with name 'Run Parallel Scenarios' found`.
|
|
208
|
+
- **Concurrency multiplies.** The number of simultaneous calls is roughly
|
|
209
|
+
pabot processes × `ROBOT_THREAD_WORKERS`: 4 processes × 4 workers means up
|
|
210
|
+
to 16 concurrent requests against the same API or database. Lower one of
|
|
211
|
+
the two if you hit rate limits, connection-pool limits or lock timeouts.
|
|
212
|
+
- **Shared test resources can collide across processes.** Data, files, user
|
|
213
|
+
accounts, or a table that a `Suite Setup` resets are visible to every
|
|
214
|
+
process. With `--testlevelsplit`, each test of a suite runs its own
|
|
215
|
+
`Suite Setup` in its own process. For example, the `examples/db_seed`
|
|
216
|
+
suite drops and recreates its table in `Suite Setup`, so two of its tests
|
|
217
|
+
could interfere with each other. Keep shared state per process, or split
|
|
218
|
+
by suite.
|
|
219
|
+
|
|
167
220
|
## When Not To Use This
|
|
168
221
|
|
|
169
222
|
- **CPU-bound work.** Threads share Python's GIL — number crunching won't
|
|
@@ -183,11 +236,13 @@ and I want a single readable log instead of 100 sequential HTTP round trips
|
|
|
183
236
|
## Project Structure
|
|
184
237
|
|
|
185
238
|
```
|
|
186
|
-
|
|
187
|
-
├── src/ParallelRunner/ # The installable library (
|
|
188
|
-
├── examples/ # Example
|
|
189
|
-
├── atest/
|
|
190
|
-
├──
|
|
239
|
+
robotframework-parallelrunner/
|
|
240
|
+
├── 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
|
|
242
|
+
├── atest/ # Robot Framework acceptance suites
|
|
243
|
+
├── utest/ # pytest unit tests
|
|
244
|
+
├── docs/ # INSTALLATION, QUICKSTART, API_REFERENCE, generated keyword docs
|
|
245
|
+
├── tasks.py # invoke tasks: tests, libdoc, demo-pabot, build, ...
|
|
191
246
|
└── ARCHITECTURE.md # Technical deep dive
|
|
192
247
|
```
|
|
193
248
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
ParallelRunner/__init__.py,sha256=ifsoO1rXf4kL3i75NokrD_KHxnlvE3IuH69Fb-ByfNg,641
|
|
2
|
-
ParallelRunner/parallel_library.py,sha256=
|
|
2
|
+
ParallelRunner/parallel_library.py,sha256=wOBvPLPLC0GSc9el7O_bcVHfl5NuGKaiWGXdZKTH6do,17380
|
|
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.
|
|
7
|
-
robotframework_parallelrunner-0.2.
|
|
8
|
-
robotframework_parallelrunner-0.2.
|
|
9
|
-
robotframework_parallelrunner-0.2.
|
|
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,,
|
|
File without changes
|
|
File without changes
|