robotframework-parallelrunner 0.2.0__tar.gz → 0.3.0__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.2.0 → robotframework_parallelrunner-0.3.0}/PKG-INFO +67 -8
- {robotframework_parallelrunner-0.2.0 → robotframework_parallelrunner-0.3.0}/README.md +66 -7
- {robotframework_parallelrunner-0.2.0 → robotframework_parallelrunner-0.3.0}/pyproject.toml +3 -1
- {robotframework_parallelrunner-0.2.0 → robotframework_parallelrunner-0.3.0}/src/ParallelRunner/parallel_library.py +105 -45
- {robotframework_parallelrunner-0.2.0 → robotframework_parallelrunner-0.3.0}/LICENSE +0 -0
- {robotframework_parallelrunner-0.2.0 → robotframework_parallelrunner-0.3.0}/src/ParallelRunner/__init__.py +0 -0
- {robotframework_parallelrunner-0.2.0 → robotframework_parallelrunner-0.3.0}/src/ParallelRunner/py.typed +0 -0
- {robotframework_parallelrunner-0.2.0 → robotframework_parallelrunner-0.3.0}/src/ParallelRunner/version.py +0 -0
- {robotframework_parallelrunner-0.2.0 → robotframework_parallelrunner-0.3.0}/src/parallelrunner.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: robotframework-parallelrunner
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -112,10 +112,16 @@ 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.
|
|
121
|
+
- **No hidden failures.** If any call fails, the keyword fails too (after all
|
|
122
|
+
calls finish and their logs are replayed), with a message listing every
|
|
123
|
+
failed item. Pass `fail_on_error=False` to get a `PASS`/`FAIL` status per
|
|
124
|
+
item and check them yourself.
|
|
119
125
|
- **Flexibility.** Pass `repeat=10` to stress-test a single endpoint, or
|
|
120
126
|
`for_loop_iterable=${items}` for batch validation of a whole list.
|
|
121
127
|
- **Ergonomics.** Results come back in call order (not completion order), so
|
|
@@ -164,6 +170,57 @@ for pabot. If you need to speed up "this one test case loops over 100 items
|
|
|
164
170
|
and I want a single readable log instead of 100 sequential HTTP round trips
|
|
165
171
|
(or 100 merged `output.xml` files)," that's what ParallelRunner is for.
|
|
166
172
|
|
|
173
|
+
## Using It Together With pabot
|
|
174
|
+
|
|
175
|
+
The two combine without any extra configuration: pabot splits suites (or
|
|
176
|
+
tests) across processes, and inside each process ParallelRunner splits a
|
|
177
|
+
loop across threads. Every pabot process gets its own ParallelRunner
|
|
178
|
+
instance, its own thread pool and its own `output.xml`, so nothing is shared
|
|
179
|
+
between processes, and pabot merges the results as usual.
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
pabot --processes 4 --pythonpath . atest/
|
|
183
|
+
pabot --processes 4 --testlevelsplit --pythonpath . atest/
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Both commands pass the full acceptance suite of this repository (52 tests;
|
|
187
|
+
checked with pabot 5.2.2).
|
|
188
|
+
|
|
189
|
+
How much time each one saves, from the offline
|
|
190
|
+
[pabot timing demo](https://github.com/cristiangarciavd/robotframework-parallelrunner/tree/main/examples/pabot_demo/)
|
|
191
|
+
(4 suites × 8 items × 1 s of simulated I/O wait; `poetry run invoke demo-pabot`):
|
|
192
|
+
|
|
193
|
+
| Run | Wall-clock time | Speedup |
|
|
194
|
+
|---|---|---|
|
|
195
|
+
| robot, sequential loop | 32.7 s | ×1.0 |
|
|
196
|
+
| robot + ParallelRunner (8 workers) | 4.6 s | ×7.1 |
|
|
197
|
+
| pabot (4 processes), sequential loop | 10.6 s | ×3.1 |
|
|
198
|
+
| pabot (4 processes) + ParallelRunner (8 workers) | 3.5 s | ×9.2 |
|
|
199
|
+
|
|
200
|
+
ParallelRunner removes the waiting *inside* each test, and pabot removes it
|
|
201
|
+
*between* suites. With suites this short, pabot's process start-up (about
|
|
202
|
+
2.5 s here) is most of what's left, so the combination pays off more as
|
|
203
|
+
suites get longer.
|
|
204
|
+
|
|
205
|
+
Things to keep in mind:
|
|
206
|
+
|
|
207
|
+
- **Run pabot from the virtual environment where the library is installed**
|
|
208
|
+
(or via `poetry run pabot ...`). pabot starts whichever `robot` command is
|
|
209
|
+
first on the `PATH`. If that belongs to another Python installation, every
|
|
210
|
+
test that uses the library fails with
|
|
211
|
+
`No keyword with name 'Run Parallel Scenarios' found`.
|
|
212
|
+
- **Concurrency multiplies.** The number of simultaneous calls is roughly
|
|
213
|
+
pabot processes × `ROBOT_THREAD_WORKERS`: 4 processes × 4 workers means up
|
|
214
|
+
to 16 concurrent requests against the same API or database. Lower one of
|
|
215
|
+
the two if you hit rate limits, connection-pool limits or lock timeouts.
|
|
216
|
+
- **Shared test resources can collide across processes.** Data, files, user
|
|
217
|
+
accounts, or a table that a `Suite Setup` resets are visible to every
|
|
218
|
+
process. With `--testlevelsplit`, each test of a suite runs its own
|
|
219
|
+
`Suite Setup` in its own process. For example, the `examples/db_seed`
|
|
220
|
+
suite drops and recreates its table in `Suite Setup`, so two of its tests
|
|
221
|
+
could interfere with each other. Keep shared state per process, or split
|
|
222
|
+
by suite.
|
|
223
|
+
|
|
167
224
|
## When Not To Use This
|
|
168
225
|
|
|
169
226
|
- **CPU-bound work.** Threads share Python's GIL — number crunching won't
|
|
@@ -183,11 +240,13 @@ and I want a single readable log instead of 100 sequential HTTP round trips
|
|
|
183
240
|
## Project Structure
|
|
184
241
|
|
|
185
242
|
```
|
|
186
|
-
|
|
187
|
-
├── src/ParallelRunner/ # The installable library (
|
|
188
|
-
├── examples/ # Example
|
|
189
|
-
├── atest/
|
|
190
|
-
├──
|
|
243
|
+
robotframework-parallelrunner/
|
|
244
|
+
├── 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
|
|
246
|
+
├── atest/ # Robot Framework acceptance suites
|
|
247
|
+
├── utest/ # pytest unit tests
|
|
248
|
+
├── docs/ # INSTALLATION, QUICKSTART, API_REFERENCE, generated keyword docs
|
|
249
|
+
├── tasks.py # invoke tasks: tests, libdoc, demo-pabot, build, ...
|
|
191
250
|
└── ARCHITECTURE.md # Technical deep dive
|
|
192
251
|
```
|
|
193
252
|
|
|
@@ -78,10 +78,16 @@ threading, buffering, and replay logic stay out of your `.robot` files.
|
|
|
78
78
|
|
|
79
79
|
## Summary of Benefits
|
|
80
80
|
|
|
81
|
-
- **Speed.**
|
|
82
|
-
|
|
81
|
+
- **Speed.** Waiting time is overlapped instead of added up. In this repo's
|
|
82
|
+
examples, 9 HTTP checks with a 1 s delay each drop from 13 s to 2 s with
|
|
83
|
+
9 workers, and 4 suites × 8 items × 1 s drop from 32.7 s to 4.6 s with
|
|
84
|
+
8 workers (see [Using It Together With pabot](#using-it-together-with-pabot)).
|
|
83
85
|
- **Integrity.** Your `log.html` remains the single source of truth — no
|
|
84
86
|
broken XML tags from concurrent writes.
|
|
87
|
+
- **No hidden failures.** If any call fails, the keyword fails too (after all
|
|
88
|
+
calls finish and their logs are replayed), with a message listing every
|
|
89
|
+
failed item. Pass `fail_on_error=False` to get a `PASS`/`FAIL` status per
|
|
90
|
+
item and check them yourself.
|
|
85
91
|
- **Flexibility.** Pass `repeat=10` to stress-test a single endpoint, or
|
|
86
92
|
`for_loop_iterable=${items}` for batch validation of a whole list.
|
|
87
93
|
- **Ergonomics.** Results come back in call order (not completion order), so
|
|
@@ -130,6 +136,57 @@ for pabot. If you need to speed up "this one test case loops over 100 items
|
|
|
130
136
|
and I want a single readable log instead of 100 sequential HTTP round trips
|
|
131
137
|
(or 100 merged `output.xml` files)," that's what ParallelRunner is for.
|
|
132
138
|
|
|
139
|
+
## Using It Together With pabot
|
|
140
|
+
|
|
141
|
+
The two combine without any extra configuration: pabot splits suites (or
|
|
142
|
+
tests) across processes, and inside each process ParallelRunner splits a
|
|
143
|
+
loop across threads. Every pabot process gets its own ParallelRunner
|
|
144
|
+
instance, its own thread pool and its own `output.xml`, so nothing is shared
|
|
145
|
+
between processes, and pabot merges the results as usual.
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
pabot --processes 4 --pythonpath . atest/
|
|
149
|
+
pabot --processes 4 --testlevelsplit --pythonpath . atest/
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Both commands pass the full acceptance suite of this repository (52 tests;
|
|
153
|
+
checked with pabot 5.2.2).
|
|
154
|
+
|
|
155
|
+
How much time each one saves, from the offline
|
|
156
|
+
[pabot timing demo](https://github.com/cristiangarciavd/robotframework-parallelrunner/tree/main/examples/pabot_demo/)
|
|
157
|
+
(4 suites × 8 items × 1 s of simulated I/O wait; `poetry run invoke demo-pabot`):
|
|
158
|
+
|
|
159
|
+
| Run | Wall-clock time | Speedup |
|
|
160
|
+
|---|---|---|
|
|
161
|
+
| robot, sequential loop | 32.7 s | ×1.0 |
|
|
162
|
+
| robot + ParallelRunner (8 workers) | 4.6 s | ×7.1 |
|
|
163
|
+
| pabot (4 processes), sequential loop | 10.6 s | ×3.1 |
|
|
164
|
+
| pabot (4 processes) + ParallelRunner (8 workers) | 3.5 s | ×9.2 |
|
|
165
|
+
|
|
166
|
+
ParallelRunner removes the waiting *inside* each test, and pabot removes it
|
|
167
|
+
*between* suites. With suites this short, pabot's process start-up (about
|
|
168
|
+
2.5 s here) is most of what's left, so the combination pays off more as
|
|
169
|
+
suites get longer.
|
|
170
|
+
|
|
171
|
+
Things to keep in mind:
|
|
172
|
+
|
|
173
|
+
- **Run pabot from the virtual environment where the library is installed**
|
|
174
|
+
(or via `poetry run pabot ...`). pabot starts whichever `robot` command is
|
|
175
|
+
first on the `PATH`. If that belongs to another Python installation, every
|
|
176
|
+
test that uses the library fails with
|
|
177
|
+
`No keyword with name 'Run Parallel Scenarios' found`.
|
|
178
|
+
- **Concurrency multiplies.** The number of simultaneous calls is roughly
|
|
179
|
+
pabot processes × `ROBOT_THREAD_WORKERS`: 4 processes × 4 workers means up
|
|
180
|
+
to 16 concurrent requests against the same API or database. Lower one of
|
|
181
|
+
the two if you hit rate limits, connection-pool limits or lock timeouts.
|
|
182
|
+
- **Shared test resources can collide across processes.** Data, files, user
|
|
183
|
+
accounts, or a table that a `Suite Setup` resets are visible to every
|
|
184
|
+
process. With `--testlevelsplit`, each test of a suite runs its own
|
|
185
|
+
`Suite Setup` in its own process. For example, the `examples/db_seed`
|
|
186
|
+
suite drops and recreates its table in `Suite Setup`, so two of its tests
|
|
187
|
+
could interfere with each other. Keep shared state per process, or split
|
|
188
|
+
by suite.
|
|
189
|
+
|
|
133
190
|
## When Not To Use This
|
|
134
191
|
|
|
135
192
|
- **CPU-bound work.** Threads share Python's GIL — number crunching won't
|
|
@@ -149,11 +206,13 @@ and I want a single readable log instead of 100 sequential HTTP round trips
|
|
|
149
206
|
## Project Structure
|
|
150
207
|
|
|
151
208
|
```
|
|
152
|
-
|
|
153
|
-
├── src/ParallelRunner/ # The installable library (
|
|
154
|
-
├── examples/ # Example
|
|
155
|
-
├── atest/
|
|
156
|
-
├──
|
|
209
|
+
robotframework-parallelrunner/
|
|
210
|
+
├── src/ParallelRunner/ # The installable library (do not depend on internals prefixed with `_`)
|
|
211
|
+
├── examples/ # Example libraries used by the tests, plus the pabot timing demo
|
|
212
|
+
├── atest/ # Robot Framework acceptance suites
|
|
213
|
+
├── utest/ # pytest unit tests
|
|
214
|
+
├── docs/ # INSTALLATION, QUICKSTART, API_REFERENCE, generated keyword docs
|
|
215
|
+
├── tasks.py # invoke tasks: tests, libdoc, demo-pabot, build, ...
|
|
157
216
|
└── ARCHITECTURE.md # Technical deep dive
|
|
158
217
|
```
|
|
159
218
|
|
|
@@ -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.
|
|
9
|
+
version = "0.3.0"
|
|
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"
|
|
@@ -72,6 +72,8 @@ coverage = ">=7.0"
|
|
|
72
72
|
invoke = ">=2.0"
|
|
73
73
|
# Used by the example libraries the acceptance tests run against (examples/).
|
|
74
74
|
requests = ">=2.25"
|
|
75
|
+
# Used by `invoke demo-pabot` (examples/pabot_demo/); not needed by the library itself.
|
|
76
|
+
robotframework-pabot = ">=4.0"
|
|
75
77
|
|
|
76
78
|
[tool.pytest.ini_options]
|
|
77
79
|
testpaths = ["utest"]
|
|
@@ -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
|
|
@@ -9,13 +10,14 @@ from .version import __version__
|
|
|
9
10
|
|
|
10
11
|
class ParallelTaskError(RuntimeError):
|
|
11
12
|
"""
|
|
12
|
-
Raised when at least one parallel task failed
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
Raised when at least one parallel task failed: by ``run_parallel_scenarios``
|
|
14
|
+
(unless called with ``fail_on_error=False``), and by ``get_result_values``,
|
|
15
|
+
which has no meaningful return value to hand back for a failed task.
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
this is raised instead
|
|
17
|
+
Returning normally after a failed call would let a test keep going (and
|
|
18
|
+
pass) with bad or missing data instead of failing for the right reason,
|
|
19
|
+
so this is raised instead - after every task has finished and all logs
|
|
20
|
+
have been replayed.
|
|
19
21
|
|
|
20
22
|
Attributes:
|
|
21
23
|
failures: the subset of result dicts with ``status == "FAIL"``,
|
|
@@ -90,15 +92,50 @@ class ParallelRunner:
|
|
|
90
92
|
| result | The return value of the call (only when ``status`` is ``PASS``). |
|
|
91
93
|
| error | The error message (only when ``status`` is ``FAIL``). |
|
|
92
94
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
95
|
+
= Failures =
|
|
96
|
+
|
|
97
|
+
If any call raises, `Run Parallel Scenarios` still waits for every other
|
|
98
|
+
call, replays all logs, and then *fails* with a ``ParallelTaskError``
|
|
99
|
+
that lists each failed item and its error, e.g.
|
|
100
|
+
``2 of 5 parallel task(s) failed: item='4': ...; item='5': ...``.
|
|
101
|
+
One failed call is never hidden behind a passing keyword.
|
|
102
|
+
|
|
103
|
+
To inspect failures yourself instead, pass ``fail_on_error=False``: the
|
|
104
|
+
keyword then always returns the result list, failed calls get
|
|
105
|
+
``status`` ``FAIL`` (their error is also logged at ``ERROR`` level), and
|
|
106
|
+
it is up to you to assert on them:
|
|
107
|
+
|
|
108
|
+
| ${results}= | Run Parallel Scenarios | keyword=Verify Agent Data | library=MyLibrary | for_loop_iterable=${agents} | fail_on_error=False |
|
|
109
|
+
| Should Be Equal | ${results}[1][status] | PASS |
|
|
110
|
+
|
|
111
|
+
``return_values_only=True`` and `Get Result Values` always fail if any
|
|
112
|
+
call failed, because a failed call has no return value.
|
|
113
|
+
|
|
114
|
+
= Custom logger mappers =
|
|
115
|
+
|
|
116
|
+
A logger mapper replaces the default buffering logger: the target keyword
|
|
117
|
+
receives it as ``_logger`` and every message goes straight to your own
|
|
118
|
+
logging system. Use it when a project already has its own log format
|
|
119
|
+
(severity codes, JSON...).
|
|
120
|
+
|
|
121
|
+
- Give it as a callable or as a ``module.function`` path, either with the
|
|
122
|
+
``logger_mapper`` argument or, for a project-wide default, with the
|
|
123
|
+
``ROBOT_LOGGER_MAPPER`` environment variable. The argument wins.
|
|
124
|
+
- Bare names (without a module path) are not supported. A value that
|
|
125
|
+
can't be resolved is ignored with a warning in the log.
|
|
126
|
+
- Mapper messages are *not* buffered and replayed into ``log.html``, so
|
|
127
|
+
``thread_log_level`` and ``remove_passing_logs`` don't apply to them.
|
|
128
|
+
The mapper is called from several threads at once, so it must be
|
|
129
|
+
thread-safe.
|
|
130
|
+
- ``ROBOT_LOGGER_MAPPER`` must be a real environment variable (e.g. set
|
|
131
|
+
with ``Set Environment Variable`` or in the shell); a Robot variable
|
|
132
|
+
given with ``--variable`` is not read.
|
|
96
133
|
|
|
97
134
|
= Environment variables =
|
|
98
135
|
|
|
99
136
|
| =Variable= | =Description= |
|
|
100
137
|
| 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. |
|
|
138
|
+
| ROBOT_LOGGER_MAPPER | Default ``logger_mapper`` as a ``module.function`` path, used when the argument is not given. See `Custom logger mappers`. |
|
|
102
139
|
|
|
103
140
|
= When not to use it =
|
|
104
141
|
|
|
@@ -125,6 +162,7 @@ class ParallelRunner:
|
|
|
125
162
|
thread_log_level: str = "INFO",
|
|
126
163
|
logger_mapper: Optional[Any] = None,
|
|
127
164
|
return_values_only: bool = False,
|
|
165
|
+
fail_on_error: bool = True,
|
|
128
166
|
**kwargs
|
|
129
167
|
) -> Union[List[Dict[str, Any]], Tuple[Any, ...]]:
|
|
130
168
|
"""Runs ``keyword`` from ``library`` concurrently, once per item or N times.
|
|
@@ -147,25 +185,33 @@ class ParallelRunner:
|
|
|
147
185
|
- ``thread_log_level``: Minimum level replayed from the threads:
|
|
148
186
|
``INFO`` (default), ``WARN`` or ``ERROR``.
|
|
149
187
|
- ``logger_mapper``: Optional callable ``mapper(msg, level)`` - or a
|
|
150
|
-
``module.function`` path to one
|
|
151
|
-
|
|
188
|
+
``module.function`` path to one, such as
|
|
189
|
+
``my_package.my_module.my_mapper`` - injected as ``_logger`` instead
|
|
190
|
+
of the default buffering logger, to route logs to a custom logging
|
|
152
191
|
system. Falls back to the ``ROBOT_LOGGER_MAPPER`` environment
|
|
153
|
-
variable.
|
|
192
|
+
variable. A value that can't be resolved (e.g. a bare name without a
|
|
193
|
+
module) is ignored with a warning. See `Custom logger mappers`.
|
|
154
194
|
- ``return_values_only``: If true, return a plain tuple of each call's
|
|
155
195
|
return value (in call order) instead of the result dictionaries.
|
|
156
196
|
Fails with ``ParallelTaskError`` if any call failed. Same as calling
|
|
157
197
|
`Get Result Values` on the default return value.
|
|
198
|
+
- ``fail_on_error``: If true (default), fail with ``ParallelTaskError``
|
|
199
|
+
when any call failed, after all calls have finished and their logs
|
|
200
|
+
have been replayed. If false, return the results anyway and leave
|
|
201
|
+
checking ``status`` to the caller. See `Failures`.
|
|
158
202
|
- ``**kwargs``: Any other named argument is passed to every call.
|
|
159
203
|
|
|
160
204
|
Returns a list of result dictionaries (see `Result format`), in call
|
|
161
205
|
order: entry ``i`` belongs to ``for_loop_iterable[i]`` / repeat
|
|
162
|
-
index ``i``, regardless of which thread finished first.
|
|
206
|
+
index ``i``, regardless of which thread finished first. With the
|
|
207
|
+
default ``fail_on_error=True`` it only returns when every call passed.
|
|
163
208
|
|
|
164
209
|
Examples:
|
|
165
210
|
| ${agents}= | Create List | 1 | 2 | 3 |
|
|
166
211
|
| ${results}= | Run Parallel Scenarios | keyword=Verify Agent Data | library=MyLibrary | for_loop_iterable=${agents} |
|
|
167
212
|
| ${results}= | Run Parallel Scenarios | keyword=Check Health | library=MyLibrary | repeat=8 | agent_id=1 |
|
|
168
213
|
| ${id1} ${id2} ${id3}= | Run Parallel Scenarios | keyword=Seed Record | library=MyLibrary | repeat=3 | return_values_only=True |
|
|
214
|
+
| ${results}= | Run Parallel Scenarios | keyword=Verify Agent Data | library=MyLibrary | for_loop_iterable=${agents} | fail_on_error=False |
|
|
169
215
|
"""
|
|
170
216
|
# Determine items to process. NOTE: `repeat or 1` would be wrong here -
|
|
171
217
|
# 0 is falsy in Python, so an explicit repeat=0 would silently fall
|
|
@@ -205,6 +251,8 @@ class ParallelRunner:
|
|
|
205
251
|
|
|
206
252
|
if return_values_only:
|
|
207
253
|
return self.get_result_values(results)
|
|
254
|
+
if fail_on_error:
|
|
255
|
+
self._raise_if_any_failed(results)
|
|
208
256
|
return results
|
|
209
257
|
|
|
210
258
|
def get_result_values(self, results: List[Dict[str, Any]]) -> Tuple[Any, ...]:
|
|
@@ -215,7 +263,9 @@ class ParallelRunner:
|
|
|
215
263
|
|
|
216
264
|
Passing ``return_values_only=True`` to `Run Parallel Scenarios` does
|
|
217
265
|
the same in one step; use this keyword when you want to inspect the
|
|
218
|
-
full results first (e.g. assert on ``status`` or ``logs``).
|
|
266
|
+
full results first (e.g. assert on ``status`` or ``logs``). Such a
|
|
267
|
+
result list may only contain failed calls if it came from a run with
|
|
268
|
+
``fail_on_error=False``.
|
|
219
269
|
|
|
220
270
|
Fails with ``ParallelTaskError`` if any entry has status ``FAIL`` -
|
|
221
271
|
a failed call has no return value to put in its slot.
|
|
@@ -224,57 +274,67 @@ class ParallelRunner:
|
|
|
224
274
|
| ${results}= | Run Parallel Scenarios | keyword=Seed Record | library=MyLibrary | repeat=3 |
|
|
225
275
|
| ${id1} ${id2} ${id3}= | Get Result Values | ${results} |
|
|
226
276
|
"""
|
|
277
|
+
self._raise_if_any_failed(results)
|
|
278
|
+
return tuple(entry["result"] for entry in results)
|
|
279
|
+
|
|
280
|
+
def _raise_if_any_failed(self, results: List[Dict[str, Any]]) -> None:
|
|
227
281
|
failures = [entry for entry in results if entry.get("status") == "FAIL"]
|
|
228
282
|
if failures:
|
|
229
283
|
raise ParallelTaskError(failures, len(results))
|
|
230
|
-
return tuple(entry["result"] for entry in results)
|
|
231
284
|
|
|
232
|
-
def _resolve_mapper(self, mapper: Optional[Any]) -> Optional[Any]:
|
|
285
|
+
def _resolve_mapper(self, mapper: Optional[Any], source: str = "logger_mapper") -> Optional[Any]:
|
|
233
286
|
"""
|
|
234
|
-
Resolve a mapper
|
|
235
|
-
|
|
287
|
+
Resolve a mapper given as a callable or as a ``module.function`` path
|
|
288
|
+
(e.g. "examples.custom_logger.custom_logging_mapper.custom_logger_adapter").
|
|
289
|
+
|
|
290
|
+
Returns the callable, or None when no mapper was given. A mapper that
|
|
291
|
+
was given but can't be resolved (a bare name without a module, a module
|
|
292
|
+
that can't be imported, a missing or non-callable attribute) is ignored
|
|
293
|
+
with a warning in the log - it used to be ignored silently, which hid
|
|
294
|
+
typos and unsupported values such as bare registered names.
|
|
295
|
+
|
|
236
296
|
Args:
|
|
237
|
-
mapper: Callable
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
297
|
+
mapper: Callable, ``module.function`` string, or "not given": None,
|
|
298
|
+
an empty string, or the string "None" (any case - Robot
|
|
299
|
+
Framework's usual spelling of None, e.g. ``logger_mapper=None``).
|
|
300
|
+
source: Where the value came from, used in the warning message.
|
|
241
301
|
"""
|
|
242
|
-
if mapper is None:
|
|
302
|
+
if mapper is None or (isinstance(mapper, str) and mapper.strip().upper() in ("", "NONE")):
|
|
243
303
|
return None
|
|
244
|
-
|
|
245
|
-
# If already callable, return it
|
|
304
|
+
|
|
246
305
|
if callable(mapper):
|
|
247
306
|
return mapper
|
|
248
|
-
|
|
249
|
-
# If it's a string, try to import it
|
|
307
|
+
|
|
250
308
|
if isinstance(mapper, str):
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
309
|
+
module_name, _, func_name = mapper.strip().rpartition(".")
|
|
310
|
+
if not module_name or not func_name:
|
|
311
|
+
reason = "expected a 'module.function' path such as 'my_package.my_module.my_mapper'"
|
|
312
|
+
else:
|
|
313
|
+
try:
|
|
314
|
+
module = importlib.import_module(module_name)
|
|
315
|
+
except ImportError as error:
|
|
316
|
+
reason = f"cannot import module '{module_name}' ({error})"
|
|
317
|
+
else:
|
|
256
318
|
resolved = getattr(module, func_name, None)
|
|
257
319
|
if callable(resolved):
|
|
258
320
|
return resolved
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
321
|
+
reason = f"module '{module_name}' has no callable '{func_name}'"
|
|
322
|
+
else:
|
|
323
|
+
reason = f"expected a callable or a 'module.function' string, got {type(mapper).__name__}"
|
|
324
|
+
|
|
325
|
+
logger.warn(f"Ignoring {source} {mapper!r}: {reason}.")
|
|
262
326
|
return None
|
|
263
327
|
|
|
264
328
|
def _load_mapper_from_environment(self) -> Optional[Any]:
|
|
265
329
|
"""
|
|
266
|
-
Load
|
|
267
|
-
|
|
268
|
-
|
|
330
|
+
Load the logger mapper named by the ROBOT_LOGGER_MAPPER environment
|
|
331
|
+
variable, which must be a ``module.function`` path (bare names are not
|
|
332
|
+
supported and are ignored with a warning).
|
|
333
|
+
|
|
269
334
|
Returns:
|
|
270
335
|
Callable: The mapper function if found, None otherwise.
|
|
271
336
|
"""
|
|
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)
|
|
337
|
+
return self._resolve_mapper(os.getenv("ROBOT_LOGGER_MAPPER"), source="ROBOT_LOGGER_MAPPER")
|
|
278
338
|
|
|
279
339
|
def _get_library_instance_owning_keyword(self, keyword_name: str, library: str) -> Any:
|
|
280
340
|
method_name = keyword_name.replace(" ", "_").lower()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{robotframework_parallelrunner-0.2.0 → robotframework_parallelrunner-0.3.0}/src/parallelrunner.py
RENAMED
|
File without changes
|