robotframework-parallelrunner 0.2.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/LICENSE +21 -0
- robotframework_parallelrunner-0.2.0/PKG-INFO +214 -0
- robotframework_parallelrunner-0.2.0/README.md +179 -0
- robotframework_parallelrunner-0.2.0/pyproject.toml +78 -0
- robotframework_parallelrunner-0.2.0/src/ParallelRunner/__init__.py +19 -0
- robotframework_parallelrunner-0.2.0/src/ParallelRunner/parallel_library.py +327 -0
- robotframework_parallelrunner-0.2.0/src/ParallelRunner/py.typed +0 -0
- robotframework_parallelrunner-0.2.0/src/ParallelRunner/version.py +16 -0
- robotframework_parallelrunner-0.2.0/src/parallelrunner.py +34 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cristian Garcia
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: robotframework-parallelrunner
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Run a keyword in parallel inside a single Robot Framework test case, with one clean, ordered log.html.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: robotframework,robot-framework,parallel,threading,testing,automation
|
|
8
|
+
Author: Cristian Garcia
|
|
9
|
+
Author-email: cristian.garcia.vd@gmail.com
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Framework :: Robot Framework
|
|
13
|
+
Classifier: Framework :: Robot Framework :: Library
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Testing
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Provides-Extra: playwright-example
|
|
26
|
+
Requires-Dist: playwright (>=1.40) ; extra == "playwright-example"
|
|
27
|
+
Requires-Dist: robotframework (>=5.0)
|
|
28
|
+
Project-URL: Changelog, https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/CHANGELOG.md
|
|
29
|
+
Project-URL: Documentation, https://cristiangarciavd.github.io/robotframework-parallelrunner/ParallelRunner.html
|
|
30
|
+
Project-URL: Homepage, https://github.com/cristiangarciavd/robotframework-parallelrunner
|
|
31
|
+
Project-URL: Issues, https://github.com/cristiangarciavd/robotframework-parallelrunner/issues
|
|
32
|
+
Project-URL: Repository, https://github.com/cristiangarciavd/robotframework-parallelrunner
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# ParallelRunner
|
|
36
|
+
|
|
37
|
+
[](https://pypi.org/project/robotframework-parallelrunner/)
|
|
38
|
+
[](https://pypi.org/project/robotframework-parallelrunner/)
|
|
39
|
+
[](https://github.com/cristiangarciavd/robotframework-parallelrunner/actions/workflows/tests.yml)
|
|
40
|
+
|
|
41
|
+
**Run a loop inside a single Robot Framework test case in parallel — with one clean `log.html`, not a merge of many.**
|
|
42
|
+
|
|
43
|
+
ParallelRunner is a small Robot Framework library that lets you fan a
|
|
44
|
+
keyword out across a thread pool from *within* a test case — e.g. hit 100
|
|
45
|
+
API endpoints, or repeat one check N times — and get back a single,
|
|
46
|
+
correctly ordered `log.html` plus a structured list of per-item results.
|
|
47
|
+
|
|
48
|
+
**Keyword documentation:** <https://cristiangarciavd.github.io/robotframework-parallelrunner/ParallelRunner.html>
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install robotframework-parallelrunner
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Requires Python 3.9+ and Robot Framework 5.0+ (installed automatically).
|
|
57
|
+
See [docs/INSTALLATION.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/docs/INSTALLATION.md) for development installs
|
|
58
|
+
and upgrading from the pre-release import path.
|
|
59
|
+
|
|
60
|
+
## Quickstart
|
|
61
|
+
|
|
62
|
+
The keyword you want to parallelize is a method of a Python library. It
|
|
63
|
+
receives the current item first, and an injected `_logger` it should log
|
|
64
|
+
through (so logs from different threads never interleave):
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
# my_library.py
|
|
68
|
+
class MyLibrary:
|
|
69
|
+
def verify_agent_data(self, agent_id, _logger=None, **kwargs):
|
|
70
|
+
_logger(f"Checking agent {agent_id}")
|
|
71
|
+
...
|
|
72
|
+
return result
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```robot
|
|
76
|
+
*** Settings ***
|
|
77
|
+
Library ParallelRunner
|
|
78
|
+
Library my_library.MyLibrary
|
|
79
|
+
|
|
80
|
+
*** Test Cases ***
|
|
81
|
+
Verify Agents In Parallel
|
|
82
|
+
${agents}= Create List 1 2 3 4 5
|
|
83
|
+
${results}= Run Parallel Scenarios
|
|
84
|
+
... keyword=Verify Agent Data
|
|
85
|
+
... library=my_library.MyLibrary
|
|
86
|
+
... for_loop_iterable=${agents}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
robot --pythonpath . my_suite.robot
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
See [docs/QUICKSTART.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/docs/QUICKSTART.md) for a full walkthrough and
|
|
94
|
+
[docs/API_REFERENCE.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/docs/API_REFERENCE.md) for every parameter of
|
|
95
|
+
`Run Parallel Scenarios`. For the internal design (log buffering, thread
|
|
96
|
+
safety), see [ARCHITECTURE.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/ARCHITECTURE.md).
|
|
97
|
+
|
|
98
|
+
## Why This Works
|
|
99
|
+
|
|
100
|
+
**Thread safety.** We avoid `BuiltIn().run_keyword()` inside threads, which is
|
|
101
|
+
the main cause of crashes in multi-threaded Robot Framework. We call the
|
|
102
|
+
underlying Python methods directly instead.
|
|
103
|
+
|
|
104
|
+
**No log interleaving.** Each worker thread buffers its own log messages in
|
|
105
|
+
memory. Once every task finishes, the main thread replays all buffered logs
|
|
106
|
+
sequentially into Robot Framework's real logger (`_replay_logs`). The result:
|
|
107
|
+
`log.html` shows logs grouped cleanly by item, even though the work happened
|
|
108
|
+
concurrently.
|
|
109
|
+
|
|
110
|
+
**Encapsulation.** Callers only ever see `Run Parallel Scenarios` — the
|
|
111
|
+
threading, buffering, and replay logic stay out of your `.robot` files.
|
|
112
|
+
|
|
113
|
+
## Summary of Benefits
|
|
114
|
+
|
|
115
|
+
- **Speed.** Validating 100 APIs that take 1s each takes roughly 10s (with 10
|
|
116
|
+
workers) instead of 100s.
|
|
117
|
+
- **Integrity.** Your `log.html` remains the single source of truth — no
|
|
118
|
+
broken XML tags from concurrent writes.
|
|
119
|
+
- **Flexibility.** Pass `repeat=10` to stress-test a single endpoint, or
|
|
120
|
+
`for_loop_iterable=${items}` for batch validation of a whole list.
|
|
121
|
+
- **Ergonomics.** Results come back in call order (not completion order), so
|
|
122
|
+
`${results}[0]` is always the first call. Add `return_values_only=True` to
|
|
123
|
+
skip the status/logs envelope entirely and unpack each call's return value
|
|
124
|
+
straight into its own variable — e.g. `${id1} ${id2} ${id3}= Run Parallel Scenarios ... repeat=3 return_values_only=True`
|
|
125
|
+
when seeding N independent fixture rows.
|
|
126
|
+
|
|
127
|
+
## How Is This Different From pabot?
|
|
128
|
+
|
|
129
|
+
[pabot](https://github.com/mkorpela/pabot) is the standard tool for parallel
|
|
130
|
+
execution in the Robot Framework ecosystem, and this project is not a
|
|
131
|
+
replacement for it — the two solve different problems and compose well
|
|
132
|
+
together.
|
|
133
|
+
|
|
134
|
+
| | **pabot** | **ParallelRunner** |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| Parallelizes at the level of | Suites / test cases | A loop (or repeated call) **inside one test case** |
|
|
137
|
+
| Execution model | Separate **processes** | **Threads** in the same process |
|
|
138
|
+
| Result merging | Runs each suite separately, then merges multiple `output.xml` files with `rebot` | Nothing to merge — logs are buffered per item and replayed into the *same* `log.html` in order |
|
|
139
|
+
| Best suited for | Running many independent suites/tests concurrently, across cores or machines | Fanning out inside a single test case (e.g. one test that validates 100 endpoints) |
|
|
140
|
+
| Workload type | Any (process isolation means CPU-bound work scales too) | I/O-bound work (HTTP calls, DB/network waits) — Python's GIL limits benefit for CPU-bound work |
|
|
141
|
+
|
|
142
|
+
Concretely:
|
|
143
|
+
|
|
144
|
+
- **pabot** takes your existing suites/tests, runs several of them at once as
|
|
145
|
+
separate OS processes (so they don't share memory or a GIL), and then stitches
|
|
146
|
+
the independent `output.xml` results back into one report. It answers: "I
|
|
147
|
+
have many independent tests, how do I run them all faster?"
|
|
148
|
+
- **ParallelRunner** answers a different question: "I have *one* test case
|
|
149
|
+
that needs to do the same kind of work many times (loop over a list of IDs,
|
|
150
|
+
or repeat something N times) — how do I parallelize the body of that loop
|
|
151
|
+
without corrupting the log or needing to merge anything?" It uses a
|
|
152
|
+
`ThreadPoolExecutor` inside a single process, and produces one
|
|
153
|
+
already-merged, already-ordered log for that test case.
|
|
154
|
+
- Because ParallelRunner uses **threads**, not processes, it shares memory and
|
|
155
|
+
is subject to the GIL — it is a good fit for **I/O-bound** work (network
|
|
156
|
+
calls, waiting on APIs/databases) where threads spend most of their time
|
|
157
|
+
blocked on I/O, not a good fit for CPU-bound number crunching.
|
|
158
|
+
- The two are **complementary**: you can use pabot to run many suites in
|
|
159
|
+
parallel across processes, where individual test cases *within* those suites
|
|
160
|
+
use ParallelRunner to parallelize their own inner loops across threads.
|
|
161
|
+
|
|
162
|
+
If you need to speed up "run these 50 independent test files faster," reach
|
|
163
|
+
for pabot. If you need to speed up "this one test case loops over 100 items
|
|
164
|
+
and I want a single readable log instead of 100 sequential HTTP round trips
|
|
165
|
+
(or 100 merged `output.xml` files)," that's what ParallelRunner is for.
|
|
166
|
+
|
|
167
|
+
## When Not To Use This
|
|
168
|
+
|
|
169
|
+
- **CPU-bound work.** Threads share Python's GIL — number crunching won't
|
|
170
|
+
get faster this way. Use `multiprocessing`, or pabot (separate processes),
|
|
171
|
+
instead.
|
|
172
|
+
- **Your keyword mutates shared state without synchronization.** Logging is
|
|
173
|
+
made thread-safe for you; your own keyword's side effects are not. If it
|
|
174
|
+
writes to a shared variable, file, or object without a lock, running it
|
|
175
|
+
concurrently can race the same way any multi-threaded code can.
|
|
176
|
+
- **You need per-item retries or a timeout.** Not implemented yet (see
|
|
177
|
+
[ROADMAP.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/ROADMAP.md)) — one hung call currently blocks the whole batch
|
|
178
|
+
from returning.
|
|
179
|
+
- **You need process-level isolation** (a crash in one call shouldn't be
|
|
180
|
+
able to affect another) or cross-machine parallelism — that's pabot's
|
|
181
|
+
domain, not this library's.
|
|
182
|
+
|
|
183
|
+
## Project Structure
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
ParallelRunner/
|
|
187
|
+
├── src/ParallelRunner/ # The installable library (core, do not depend on internals prefixed with `_`)
|
|
188
|
+
├── examples/ # Example "business logic" libraries used by the test suites
|
|
189
|
+
├── atest/ # Robot Framework acceptance suites
|
|
190
|
+
├── docs/ # INSTALLATION, QUICKSTART, API_REFERENCE
|
|
191
|
+
└── ARCHITECTURE.md # Technical deep dive
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
See [ARCHITECTURE.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/ARCHITECTURE.md) for the full breakdown and design
|
|
195
|
+
principles.
|
|
196
|
+
|
|
197
|
+
### Optional: UI automation with Playwright
|
|
198
|
+
|
|
199
|
+
The same thread-pool approach applies to browser automation, not just HTTP
|
|
200
|
+
calls — see [examples/playwright_ui/](https://github.com/cristiangarciavd/robotframework-parallelrunner/tree/main/examples/playwright_ui/) for a
|
|
201
|
+
worked example using Playwright's official `sync_api`. It's kept out of
|
|
202
|
+
the default install and CI (heavy dependency, real browser download), so
|
|
203
|
+
it's opt-in: read that folder's README before installing anything.
|
|
204
|
+
|
|
205
|
+
## Contributing
|
|
206
|
+
|
|
207
|
+
See [CONTRIBUTING.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/CONTRIBUTING.md) and [CODE_OF_CONDUCT.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/CODE_OF_CONDUCT.md).
|
|
208
|
+
For what's done and what's planned, see [ROADMAP.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/ROADMAP.md) and
|
|
209
|
+
[CHANGELOG.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/CHANGELOG.md).
|
|
210
|
+
|
|
211
|
+
## License
|
|
212
|
+
|
|
213
|
+
MIT — see [LICENSE](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/LICENSE).
|
|
214
|
+
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# ParallelRunner
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/robotframework-parallelrunner/)
|
|
4
|
+
[](https://pypi.org/project/robotframework-parallelrunner/)
|
|
5
|
+
[](https://github.com/cristiangarciavd/robotframework-parallelrunner/actions/workflows/tests.yml)
|
|
6
|
+
|
|
7
|
+
**Run a loop inside a single Robot Framework test case in parallel — with one clean `log.html`, not a merge of many.**
|
|
8
|
+
|
|
9
|
+
ParallelRunner is a small Robot Framework library that lets you fan a
|
|
10
|
+
keyword out across a thread pool from *within* a test case — e.g. hit 100
|
|
11
|
+
API endpoints, or repeat one check N times — and get back a single,
|
|
12
|
+
correctly ordered `log.html` plus a structured list of per-item results.
|
|
13
|
+
|
|
14
|
+
**Keyword documentation:** <https://cristiangarciavd.github.io/robotframework-parallelrunner/ParallelRunner.html>
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install robotframework-parallelrunner
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Requires Python 3.9+ and Robot Framework 5.0+ (installed automatically).
|
|
23
|
+
See [docs/INSTALLATION.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/docs/INSTALLATION.md) for development installs
|
|
24
|
+
and upgrading from the pre-release import path.
|
|
25
|
+
|
|
26
|
+
## Quickstart
|
|
27
|
+
|
|
28
|
+
The keyword you want to parallelize is a method of a Python library. It
|
|
29
|
+
receives the current item first, and an injected `_logger` it should log
|
|
30
|
+
through (so logs from different threads never interleave):
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
# my_library.py
|
|
34
|
+
class MyLibrary:
|
|
35
|
+
def verify_agent_data(self, agent_id, _logger=None, **kwargs):
|
|
36
|
+
_logger(f"Checking agent {agent_id}")
|
|
37
|
+
...
|
|
38
|
+
return result
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```robot
|
|
42
|
+
*** Settings ***
|
|
43
|
+
Library ParallelRunner
|
|
44
|
+
Library my_library.MyLibrary
|
|
45
|
+
|
|
46
|
+
*** Test Cases ***
|
|
47
|
+
Verify Agents In Parallel
|
|
48
|
+
${agents}= Create List 1 2 3 4 5
|
|
49
|
+
${results}= Run Parallel Scenarios
|
|
50
|
+
... keyword=Verify Agent Data
|
|
51
|
+
... library=my_library.MyLibrary
|
|
52
|
+
... for_loop_iterable=${agents}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
robot --pythonpath . my_suite.robot
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
See [docs/QUICKSTART.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/docs/QUICKSTART.md) for a full walkthrough and
|
|
60
|
+
[docs/API_REFERENCE.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/docs/API_REFERENCE.md) for every parameter of
|
|
61
|
+
`Run Parallel Scenarios`. For the internal design (log buffering, thread
|
|
62
|
+
safety), see [ARCHITECTURE.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/ARCHITECTURE.md).
|
|
63
|
+
|
|
64
|
+
## Why This Works
|
|
65
|
+
|
|
66
|
+
**Thread safety.** We avoid `BuiltIn().run_keyword()` inside threads, which is
|
|
67
|
+
the main cause of crashes in multi-threaded Robot Framework. We call the
|
|
68
|
+
underlying Python methods directly instead.
|
|
69
|
+
|
|
70
|
+
**No log interleaving.** Each worker thread buffers its own log messages in
|
|
71
|
+
memory. Once every task finishes, the main thread replays all buffered logs
|
|
72
|
+
sequentially into Robot Framework's real logger (`_replay_logs`). The result:
|
|
73
|
+
`log.html` shows logs grouped cleanly by item, even though the work happened
|
|
74
|
+
concurrently.
|
|
75
|
+
|
|
76
|
+
**Encapsulation.** Callers only ever see `Run Parallel Scenarios` — the
|
|
77
|
+
threading, buffering, and replay logic stay out of your `.robot` files.
|
|
78
|
+
|
|
79
|
+
## Summary of Benefits
|
|
80
|
+
|
|
81
|
+
- **Speed.** Validating 100 APIs that take 1s each takes roughly 10s (with 10
|
|
82
|
+
workers) instead of 100s.
|
|
83
|
+
- **Integrity.** Your `log.html` remains the single source of truth — no
|
|
84
|
+
broken XML tags from concurrent writes.
|
|
85
|
+
- **Flexibility.** Pass `repeat=10` to stress-test a single endpoint, or
|
|
86
|
+
`for_loop_iterable=${items}` for batch validation of a whole list.
|
|
87
|
+
- **Ergonomics.** Results come back in call order (not completion order), so
|
|
88
|
+
`${results}[0]` is always the first call. Add `return_values_only=True` to
|
|
89
|
+
skip the status/logs envelope entirely and unpack each call's return value
|
|
90
|
+
straight into its own variable — e.g. `${id1} ${id2} ${id3}= Run Parallel Scenarios ... repeat=3 return_values_only=True`
|
|
91
|
+
when seeding N independent fixture rows.
|
|
92
|
+
|
|
93
|
+
## How Is This Different From pabot?
|
|
94
|
+
|
|
95
|
+
[pabot](https://github.com/mkorpela/pabot) is the standard tool for parallel
|
|
96
|
+
execution in the Robot Framework ecosystem, and this project is not a
|
|
97
|
+
replacement for it — the two solve different problems and compose well
|
|
98
|
+
together.
|
|
99
|
+
|
|
100
|
+
| | **pabot** | **ParallelRunner** |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| Parallelizes at the level of | Suites / test cases | A loop (or repeated call) **inside one test case** |
|
|
103
|
+
| Execution model | Separate **processes** | **Threads** in the same process |
|
|
104
|
+
| Result merging | Runs each suite separately, then merges multiple `output.xml` files with `rebot` | Nothing to merge — logs are buffered per item and replayed into the *same* `log.html` in order |
|
|
105
|
+
| Best suited for | Running many independent suites/tests concurrently, across cores or machines | Fanning out inside a single test case (e.g. one test that validates 100 endpoints) |
|
|
106
|
+
| Workload type | Any (process isolation means CPU-bound work scales too) | I/O-bound work (HTTP calls, DB/network waits) — Python's GIL limits benefit for CPU-bound work |
|
|
107
|
+
|
|
108
|
+
Concretely:
|
|
109
|
+
|
|
110
|
+
- **pabot** takes your existing suites/tests, runs several of them at once as
|
|
111
|
+
separate OS processes (so they don't share memory or a GIL), and then stitches
|
|
112
|
+
the independent `output.xml` results back into one report. It answers: "I
|
|
113
|
+
have many independent tests, how do I run them all faster?"
|
|
114
|
+
- **ParallelRunner** answers a different question: "I have *one* test case
|
|
115
|
+
that needs to do the same kind of work many times (loop over a list of IDs,
|
|
116
|
+
or repeat something N times) — how do I parallelize the body of that loop
|
|
117
|
+
without corrupting the log or needing to merge anything?" It uses a
|
|
118
|
+
`ThreadPoolExecutor` inside a single process, and produces one
|
|
119
|
+
already-merged, already-ordered log for that test case.
|
|
120
|
+
- Because ParallelRunner uses **threads**, not processes, it shares memory and
|
|
121
|
+
is subject to the GIL — it is a good fit for **I/O-bound** work (network
|
|
122
|
+
calls, waiting on APIs/databases) where threads spend most of their time
|
|
123
|
+
blocked on I/O, not a good fit for CPU-bound number crunching.
|
|
124
|
+
- The two are **complementary**: you can use pabot to run many suites in
|
|
125
|
+
parallel across processes, where individual test cases *within* those suites
|
|
126
|
+
use ParallelRunner to parallelize their own inner loops across threads.
|
|
127
|
+
|
|
128
|
+
If you need to speed up "run these 50 independent test files faster," reach
|
|
129
|
+
for pabot. If you need to speed up "this one test case loops over 100 items
|
|
130
|
+
and I want a single readable log instead of 100 sequential HTTP round trips
|
|
131
|
+
(or 100 merged `output.xml` files)," that's what ParallelRunner is for.
|
|
132
|
+
|
|
133
|
+
## When Not To Use This
|
|
134
|
+
|
|
135
|
+
- **CPU-bound work.** Threads share Python's GIL — number crunching won't
|
|
136
|
+
get faster this way. Use `multiprocessing`, or pabot (separate processes),
|
|
137
|
+
instead.
|
|
138
|
+
- **Your keyword mutates shared state without synchronization.** Logging is
|
|
139
|
+
made thread-safe for you; your own keyword's side effects are not. If it
|
|
140
|
+
writes to a shared variable, file, or object without a lock, running it
|
|
141
|
+
concurrently can race the same way any multi-threaded code can.
|
|
142
|
+
- **You need per-item retries or a timeout.** Not implemented yet (see
|
|
143
|
+
[ROADMAP.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/ROADMAP.md)) — one hung call currently blocks the whole batch
|
|
144
|
+
from returning.
|
|
145
|
+
- **You need process-level isolation** (a crash in one call shouldn't be
|
|
146
|
+
able to affect another) or cross-machine parallelism — that's pabot's
|
|
147
|
+
domain, not this library's.
|
|
148
|
+
|
|
149
|
+
## Project Structure
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
ParallelRunner/
|
|
153
|
+
├── src/ParallelRunner/ # The installable library (core, do not depend on internals prefixed with `_`)
|
|
154
|
+
├── examples/ # Example "business logic" libraries used by the test suites
|
|
155
|
+
├── atest/ # Robot Framework acceptance suites
|
|
156
|
+
├── docs/ # INSTALLATION, QUICKSTART, API_REFERENCE
|
|
157
|
+
└── ARCHITECTURE.md # Technical deep dive
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
See [ARCHITECTURE.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/ARCHITECTURE.md) for the full breakdown and design
|
|
161
|
+
principles.
|
|
162
|
+
|
|
163
|
+
### Optional: UI automation with Playwright
|
|
164
|
+
|
|
165
|
+
The same thread-pool approach applies to browser automation, not just HTTP
|
|
166
|
+
calls — see [examples/playwright_ui/](https://github.com/cristiangarciavd/robotframework-parallelrunner/tree/main/examples/playwright_ui/) for a
|
|
167
|
+
worked example using Playwright's official `sync_api`. It's kept out of
|
|
168
|
+
the default install and CI (heavy dependency, real browser download), so
|
|
169
|
+
it's opt-in: read that folder's README before installing anything.
|
|
170
|
+
|
|
171
|
+
## Contributing
|
|
172
|
+
|
|
173
|
+
See [CONTRIBUTING.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/CONTRIBUTING.md) and [CODE_OF_CONDUCT.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/CODE_OF_CONDUCT.md).
|
|
174
|
+
For what's done and what's planned, see [ROADMAP.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/ROADMAP.md) and
|
|
175
|
+
[CHANGELOG.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/CHANGELOG.md).
|
|
176
|
+
|
|
177
|
+
## License
|
|
178
|
+
|
|
179
|
+
MIT — see [LICENSE](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/LICENSE).
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
3
|
+
build-backend = "poetry.core.masonry.api"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "robotframework-parallelrunner"
|
|
7
|
+
# Single source of truth for the version: bump with `poetry version patch|minor|major`.
|
|
8
|
+
# ParallelRunner.__version__ / ROBOT_LIBRARY_VERSION read it back from the installed metadata.
|
|
9
|
+
version = "0.2.0"
|
|
10
|
+
description = "Run a keyword in parallel inside a single Robot Framework test case, with one clean, ordered log.html."
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
license = "MIT"
|
|
13
|
+
license-files = ["LICENSE"]
|
|
14
|
+
requires-python = ">=3.9"
|
|
15
|
+
authors = [
|
|
16
|
+
{ name = "Cristian Garcia", email = "cristian.garcia.vd@gmail.com" },
|
|
17
|
+
]
|
|
18
|
+
keywords = [
|
|
19
|
+
"robotframework",
|
|
20
|
+
"robot-framework",
|
|
21
|
+
"parallel",
|
|
22
|
+
"threading",
|
|
23
|
+
"testing",
|
|
24
|
+
"automation",
|
|
25
|
+
]
|
|
26
|
+
classifiers = [
|
|
27
|
+
"Development Status :: 4 - Beta",
|
|
28
|
+
"Framework :: Robot Framework",
|
|
29
|
+
"Framework :: Robot Framework :: Library",
|
|
30
|
+
"Intended Audience :: Developers",
|
|
31
|
+
"Operating System :: OS Independent",
|
|
32
|
+
"Programming Language :: Python :: 3",
|
|
33
|
+
"Programming Language :: Python :: 3.9",
|
|
34
|
+
"Programming Language :: Python :: 3.10",
|
|
35
|
+
"Programming Language :: Python :: 3.11",
|
|
36
|
+
"Programming Language :: Python :: 3.12",
|
|
37
|
+
"Programming Language :: Python :: 3.13",
|
|
38
|
+
"Topic :: Software Development :: Testing",
|
|
39
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
40
|
+
"Typing :: Typed",
|
|
41
|
+
]
|
|
42
|
+
dependencies = [
|
|
43
|
+
"robotframework>=5.0",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[project.optional-dependencies]
|
|
47
|
+
# Optional, opt-in only: powers examples/playwright_ui/, not part of CI.
|
|
48
|
+
# See examples/playwright_ui/README.md - also run `playwright install chromium` after installing.
|
|
49
|
+
playwright-example = [
|
|
50
|
+
"playwright>=1.40",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[project.urls]
|
|
54
|
+
Homepage = "https://github.com/cristiangarciavd/robotframework-parallelrunner"
|
|
55
|
+
Documentation = "https://cristiangarciavd.github.io/robotframework-parallelrunner/ParallelRunner.html"
|
|
56
|
+
Repository = "https://github.com/cristiangarciavd/robotframework-parallelrunner"
|
|
57
|
+
Issues = "https://github.com/cristiangarciavd/robotframework-parallelrunner/issues"
|
|
58
|
+
Changelog = "https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/CHANGELOG.md"
|
|
59
|
+
|
|
60
|
+
[tool.poetry]
|
|
61
|
+
# Distribution name (pip install robotframework-parallelrunner) differs from
|
|
62
|
+
# the import name (Library ParallelRunner), so the packages are listed explicitly.
|
|
63
|
+
# `parallelrunner` is a deprecated single-file shim for the old import path.
|
|
64
|
+
packages = [
|
|
65
|
+
{ include = "ParallelRunner", from = "src" },
|
|
66
|
+
{ include = "parallelrunner.py", from = "src" },
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
[tool.poetry.group.dev.dependencies]
|
|
70
|
+
pytest = ">=7.0"
|
|
71
|
+
coverage = ">=7.0"
|
|
72
|
+
invoke = ">=2.0"
|
|
73
|
+
# Used by the example libraries the acceptance tests run against (examples/).
|
|
74
|
+
requests = ">=2.25"
|
|
75
|
+
|
|
76
|
+
[tool.pytest.ini_options]
|
|
77
|
+
testpaths = ["utest"]
|
|
78
|
+
pythonpath = ["src", "."]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ParallelRunner - Thread-based parallel execution for Robot Framework test cases.
|
|
3
|
+
|
|
4
|
+
Lets a single test case fan out a keyword (or Python method) across a thread
|
|
5
|
+
pool while keeping log.html clean via a buffer-and-replay mechanism.
|
|
6
|
+
|
|
7
|
+
Typical usage in a Robot Framework suite::
|
|
8
|
+
|
|
9
|
+
*** Settings ***
|
|
10
|
+
Library ParallelRunner
|
|
11
|
+
|
|
12
|
+
Keyword documentation:
|
|
13
|
+
https://cristiangarciavd.github.io/robotframework-parallelrunner/ParallelRunner.html
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .parallel_library import ParallelLibrary, ParallelRunner, ParallelTaskError
|
|
17
|
+
from .version import __version__
|
|
18
|
+
|
|
19
|
+
__all__ = ["ParallelRunner", "ParallelLibrary", "ParallelTaskError", "__version__"]
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import concurrent.futures
|
|
3
|
+
from typing import Iterable, Any, List, Dict, Optional, Tuple, Union
|
|
4
|
+
from robot.api import logger
|
|
5
|
+
from robot.libraries.BuiltIn import BuiltIn
|
|
6
|
+
|
|
7
|
+
from .version import __version__
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ParallelTaskError(RuntimeError):
|
|
11
|
+
"""
|
|
12
|
+
Raised when at least one parallel task failed and there is no meaningful
|
|
13
|
+
return value to hand back for it - by ``get_result_values``, and by
|
|
14
|
+
``run_parallel_scenarios`` when called with ``return_values_only=True``.
|
|
15
|
+
|
|
16
|
+
Silently substituting `None` for a failed task's value would let a test
|
|
17
|
+
keep going with bad data instead of failing for the right reason, so
|
|
18
|
+
this is raised instead.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
failures: the subset of result dicts with ``status == "FAIL"``,
|
|
22
|
+
in the same order they appear in the full result list.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, failures: List[Dict[str, Any]], total: int):
|
|
26
|
+
self.failures = failures
|
|
27
|
+
detail = "; ".join(f"item={entry['item']!r}: {entry['error']}" for entry in failures)
|
|
28
|
+
super().__init__(f"{len(failures)} of {total} parallel task(s) failed: {detail}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ParallelRunner:
|
|
32
|
+
"""
|
|
33
|
+
ParallelRunner runs a keyword many times *inside a single test case*,
|
|
34
|
+
concurrently on a thread pool, and still produces one clean, ordered
|
|
35
|
+
``log.html``.
|
|
36
|
+
|
|
37
|
+
Typical uses: validate 100 API endpoints in one test, or repeat one
|
|
38
|
+
call N times (seed N fixture rows, a light concurrent load check) -
|
|
39
|
+
I/O-bound work where most of the time is spent waiting on the network
|
|
40
|
+
or a database.
|
|
41
|
+
|
|
42
|
+
= Table of contents =
|
|
43
|
+
|
|
44
|
+
%TOC%
|
|
45
|
+
|
|
46
|
+
= How it works =
|
|
47
|
+
|
|
48
|
+
- Each call runs on a worker thread of a ``ThreadPoolExecutor``.
|
|
49
|
+
``BuiltIn().run_keyword`` is *not* used inside threads (it is not
|
|
50
|
+
thread-safe); the underlying Python method of the target library is
|
|
51
|
+
called directly instead.
|
|
52
|
+
- Every thread buffers its log messages in memory. When all tasks have
|
|
53
|
+
finished, the logs are replayed sequentially into the real Robot
|
|
54
|
+
Framework logger, grouped per item and in call order - so concurrent
|
|
55
|
+
work never interleaves or corrupts ``log.html``.
|
|
56
|
+
- Results come back in *call order*, not completion order: entry ``i``
|
|
57
|
+
always belongs to the ``i``-th item / repetition.
|
|
58
|
+
|
|
59
|
+
= Writing a parallel-ready keyword =
|
|
60
|
+
|
|
61
|
+
The target keyword must be a method of a Python library that is already
|
|
62
|
+
imported in the suite. It receives the current item (or the repeat
|
|
63
|
+
index) as its first argument and an injected ``_logger`` callable that
|
|
64
|
+
it should use instead of ``robot.api.logger``:
|
|
65
|
+
|
|
66
|
+
| from robot.api import logger
|
|
67
|
+
|
|
|
68
|
+
| class MyLibrary:
|
|
69
|
+
| def verify_agent(self, agent_id, _logger=None, **kwargs):
|
|
70
|
+
| log = _logger or (lambda msg, level="INFO": logger.write(msg, level))
|
|
71
|
+
| log(f"Checking agent {agent_id}")
|
|
72
|
+
| ...
|
|
73
|
+
| return result
|
|
74
|
+
|
|
75
|
+
``_logger(msg, level)`` accepts the levels ``INFO``, ``WARN``, ``ERROR``
|
|
76
|
+
and ``IGNORE`` (dropped). Any extra named argument given to
|
|
77
|
+
`Run Parallel Scenarios` is forwarded to every call as ``**kwargs``.
|
|
78
|
+
|
|
79
|
+
Logging is made thread-safe for you; your keyword's own side effects are
|
|
80
|
+
not. Protect shared state (files, shared objects) with a lock.
|
|
81
|
+
|
|
82
|
+
= Result format =
|
|
83
|
+
|
|
84
|
+
`Run Parallel Scenarios` returns a list with one dictionary per call:
|
|
85
|
+
|
|
86
|
+
| =Key= | =Description= |
|
|
87
|
+
| status | ``PASS`` or ``FAIL``. |
|
|
88
|
+
| item | The item (or repeat index) the call received. |
|
|
89
|
+
| logs | List of ``(level, message)`` tuples captured during the call. |
|
|
90
|
+
| result | The return value of the call (only when ``status`` is ``PASS``). |
|
|
91
|
+
| error | The error message (only when ``status`` is ``FAIL``). |
|
|
92
|
+
|
|
93
|
+
A failing call does *not* fail the keyword; check ``status`` yourself,
|
|
94
|
+
or use ``return_values_only=True`` / `Get Result Values`, which fail
|
|
95
|
+
if any call failed.
|
|
96
|
+
|
|
97
|
+
= Environment variables =
|
|
98
|
+
|
|
99
|
+
| =Variable= | =Description= |
|
|
100
|
+
| 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. |
|
|
102
|
+
|
|
103
|
+
= When not to use it =
|
|
104
|
+
|
|
105
|
+
- CPU-bound work: threads share Python's GIL. Use
|
|
106
|
+
[https://github.com/mkorpela/pabot|pabot] or ``multiprocessing``.
|
|
107
|
+
- Running many independent suites/tests faster: that is what pabot is
|
|
108
|
+
for. Both tools compose well together.
|
|
109
|
+
"""
|
|
110
|
+
ROBOT_LIBRARY_SCOPE = 'GLOBAL'
|
|
111
|
+
ROBOT_LIBRARY_VERSION = __version__
|
|
112
|
+
ROBOT_LIBRARY_DOC_FORMAT = 'ROBOT'
|
|
113
|
+
|
|
114
|
+
def __init__(self):
|
|
115
|
+
# Workers count from Env Var or default to 4
|
|
116
|
+
self.max_workers = int(os.getenv("ROBOT_THREAD_WORKERS", "4"))
|
|
117
|
+
|
|
118
|
+
def run_parallel_scenarios(
|
|
119
|
+
self,
|
|
120
|
+
keyword: str,
|
|
121
|
+
library: str,
|
|
122
|
+
for_loop_iterable: Optional[Iterable[Any]] = None,
|
|
123
|
+
repeat: Optional[int] = None,
|
|
124
|
+
remove_passing_logs: bool = False,
|
|
125
|
+
thread_log_level: str = "INFO",
|
|
126
|
+
logger_mapper: Optional[Any] = None,
|
|
127
|
+
return_values_only: bool = False,
|
|
128
|
+
**kwargs
|
|
129
|
+
) -> Union[List[Dict[str, Any]], Tuple[Any, ...]]:
|
|
130
|
+
"""Runs ``keyword`` from ``library`` concurrently, once per item or N times.
|
|
131
|
+
|
|
132
|
+
Arguments:
|
|
133
|
+
- ``keyword``: Name of the keyword to run, e.g. ``Verify Agent Data``.
|
|
134
|
+
It must be implemented as a Python method of ``library`` (see
|
|
135
|
+
`Writing a parallel-ready keyword`).
|
|
136
|
+
- ``library``: Name of the library that owns the keyword, exactly as
|
|
137
|
+
it was imported in the suite (e.g. ``my_package.MyLibrary``).
|
|
138
|
+
- ``for_loop_iterable``: Items to iterate over, like a parallel FOR
|
|
139
|
+
loop. Each call receives one item as its first argument. Takes
|
|
140
|
+
precedence over ``repeat``.
|
|
141
|
+
- ``repeat``: Run the keyword this many times; each call receives its
|
|
142
|
+
repeat index (``0`` .. ``N-1``). ``repeat=0`` runs it zero times.
|
|
143
|
+
If neither ``for_loop_iterable`` nor ``repeat`` is given, the
|
|
144
|
+
keyword runs once.
|
|
145
|
+
- ``remove_passing_logs``: If true, logs of passing calls are not
|
|
146
|
+
replayed into ``log.html``; only failed calls are shown.
|
|
147
|
+
- ``thread_log_level``: Minimum level replayed from the threads:
|
|
148
|
+
``INFO`` (default), ``WARN`` or ``ERROR``.
|
|
149
|
+
- ``logger_mapper``: Optional callable ``mapper(msg, level)`` - or a
|
|
150
|
+
``module.function`` path to one - injected as ``_logger`` instead of
|
|
151
|
+
the default buffering logger, to route logs to a custom logging
|
|
152
|
+
system. Falls back to the ``ROBOT_LOGGER_MAPPER`` environment
|
|
153
|
+
variable.
|
|
154
|
+
- ``return_values_only``: If true, return a plain tuple of each call's
|
|
155
|
+
return value (in call order) instead of the result dictionaries.
|
|
156
|
+
Fails with ``ParallelTaskError`` if any call failed. Same as calling
|
|
157
|
+
`Get Result Values` on the default return value.
|
|
158
|
+
- ``**kwargs``: Any other named argument is passed to every call.
|
|
159
|
+
|
|
160
|
+
Returns a list of result dictionaries (see `Result format`), in call
|
|
161
|
+
order: entry ``i`` belongs to ``for_loop_iterable[i]`` / repeat
|
|
162
|
+
index ``i``, regardless of which thread finished first.
|
|
163
|
+
|
|
164
|
+
Examples:
|
|
165
|
+
| ${agents}= | Create List | 1 | 2 | 3 |
|
|
166
|
+
| ${results}= | Run Parallel Scenarios | keyword=Verify Agent Data | library=MyLibrary | for_loop_iterable=${agents} |
|
|
167
|
+
| ${results}= | Run Parallel Scenarios | keyword=Check Health | library=MyLibrary | repeat=8 | agent_id=1 |
|
|
168
|
+
| ${id1} ${id2} ${id3}= | Run Parallel Scenarios | keyword=Seed Record | library=MyLibrary | repeat=3 | return_values_only=True |
|
|
169
|
+
"""
|
|
170
|
+
# Determine items to process. NOTE: `repeat or 1` would be wrong here -
|
|
171
|
+
# 0 is falsy in Python, so an explicit repeat=0 would silently fall
|
|
172
|
+
# back to running once instead of zero times. Only a missing (None)
|
|
173
|
+
# repeat should default to 1.
|
|
174
|
+
if for_loop_iterable is not None:
|
|
175
|
+
items = for_loop_iterable
|
|
176
|
+
else:
|
|
177
|
+
items = range(1 if repeat is None else repeat)
|
|
178
|
+
|
|
179
|
+
# Resolve mapper: it might be a callable or a string path
|
|
180
|
+
effective_mapper = self._resolve_mapper(logger_mapper)
|
|
181
|
+
|
|
182
|
+
# If not resolved from parameter, try environment
|
|
183
|
+
if effective_mapper is None:
|
|
184
|
+
effective_mapper = self._load_mapper_from_environment()
|
|
185
|
+
|
|
186
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
|
187
|
+
# We map the execution.
|
|
188
|
+
# Note: We must call the underlying Python method, not BuiltIn().run_keyword
|
|
189
|
+
# because run_keyword is not thread-safe.
|
|
190
|
+
lib_instance = self._get_library_instance_owning_keyword(keyword, library)
|
|
191
|
+
method = getattr(lib_instance, keyword.replace(" ", "_").lower())
|
|
192
|
+
|
|
193
|
+
# Submit every task up front so they all start running concurrently,
|
|
194
|
+
# then collect results in submission order (NOT as_completed order) -
|
|
195
|
+
# tasks[i].result() blocks only until the i-th task finishes, it
|
|
196
|
+
# doesn't force tasks to run one at a time.
|
|
197
|
+
tasks = [
|
|
198
|
+
executor.submit(self._execute_and_capture, method, item, effective_mapper, **kwargs)
|
|
199
|
+
for item in items
|
|
200
|
+
]
|
|
201
|
+
results = [task.result() for task in tasks]
|
|
202
|
+
|
|
203
|
+
# Step: Re-play logs into Robot Framework sequentially
|
|
204
|
+
self._replay_logs(results, remove_passing_logs, thread_log_level)
|
|
205
|
+
|
|
206
|
+
if return_values_only:
|
|
207
|
+
return self.get_result_values(results)
|
|
208
|
+
return results
|
|
209
|
+
|
|
210
|
+
def get_result_values(self, results: List[Dict[str, Any]]) -> Tuple[Any, ...]:
|
|
211
|
+
"""Returns the return value of every call in ``results``, as a tuple in call order.
|
|
212
|
+
|
|
213
|
+
``results`` is the list returned by `Run Parallel Scenarios`.
|
|
214
|
+
``results[i]`` becomes tuple index ``i``.
|
|
215
|
+
|
|
216
|
+
Passing ``return_values_only=True`` to `Run Parallel Scenarios` does
|
|
217
|
+
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``).
|
|
219
|
+
|
|
220
|
+
Fails with ``ParallelTaskError`` if any entry has status ``FAIL`` -
|
|
221
|
+
a failed call has no return value to put in its slot.
|
|
222
|
+
|
|
223
|
+
Example:
|
|
224
|
+
| ${results}= | Run Parallel Scenarios | keyword=Seed Record | library=MyLibrary | repeat=3 |
|
|
225
|
+
| ${id1} ${id2} ${id3}= | Get Result Values | ${results} |
|
|
226
|
+
"""
|
|
227
|
+
failures = [entry for entry in results if entry.get("status") == "FAIL"]
|
|
228
|
+
if failures:
|
|
229
|
+
raise ParallelTaskError(failures, len(results))
|
|
230
|
+
return tuple(entry["result"] for entry in results)
|
|
231
|
+
|
|
232
|
+
def _resolve_mapper(self, mapper: Optional[Any]) -> Optional[Any]:
|
|
233
|
+
"""
|
|
234
|
+
Resolve a mapper which might be a callable or a string path to a module function.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
mapper: Callable or string path (e.g., "examples.custom_logger.custom_logging_mapper.custom_logger_adapter")
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
Callable: The mapper function if successfully resolved, None otherwise.
|
|
241
|
+
"""
|
|
242
|
+
if mapper is None:
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
# If already callable, return it
|
|
246
|
+
if callable(mapper):
|
|
247
|
+
return mapper
|
|
248
|
+
|
|
249
|
+
# If it's a string, try to import it
|
|
250
|
+
if isinstance(mapper, str):
|
|
251
|
+
try:
|
|
252
|
+
parts = mapper.rsplit(".", 1)
|
|
253
|
+
if len(parts) == 2:
|
|
254
|
+
module_name, func_name = parts
|
|
255
|
+
module = __import__(module_name, fromlist=[func_name])
|
|
256
|
+
resolved = getattr(module, func_name, None)
|
|
257
|
+
if callable(resolved):
|
|
258
|
+
return resolved
|
|
259
|
+
except (ImportError, AttributeError):
|
|
260
|
+
pass
|
|
261
|
+
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
def _load_mapper_from_environment(self) -> Optional[Any]:
|
|
265
|
+
"""
|
|
266
|
+
Load custom logger mapper from ROBOT_LOGGER_MAPPER environment variable.
|
|
267
|
+
Supports both registered mapper names and module.function paths.
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
Callable: The mapper function if found, None otherwise.
|
|
271
|
+
"""
|
|
272
|
+
mapper_name = os.getenv("ROBOT_LOGGER_MAPPER")
|
|
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)
|
|
278
|
+
|
|
279
|
+
def _get_library_instance_owning_keyword(self, keyword_name: str, library: str) -> Any:
|
|
280
|
+
method_name = keyword_name.replace(" ", "_").lower()
|
|
281
|
+
lib_instance = BuiltIn().get_library_instance(library)
|
|
282
|
+
if hasattr(lib_instance, method_name):
|
|
283
|
+
return lib_instance
|
|
284
|
+
raise ValueError(f"Keyword '{keyword_name}' not found in library '{library}'")
|
|
285
|
+
|
|
286
|
+
def _execute_and_capture(self, method, item, logger_mapper: Optional[Any] = None, **kwargs) -> Dict[str, Any]:
|
|
287
|
+
"""Worker wrapper to capture logs and results."""
|
|
288
|
+
log_buffer = []
|
|
289
|
+
|
|
290
|
+
# Injection of a custom logger into the thread
|
|
291
|
+
def thread_log(msg, level="INFO"):
|
|
292
|
+
# Filter out IGNORE (sv=0) level logs
|
|
293
|
+
if level != "IGNORE":
|
|
294
|
+
log_buffer.append((level, msg))
|
|
295
|
+
|
|
296
|
+
# Use mapper if provided, otherwise use thread_log
|
|
297
|
+
effective_logger = logger_mapper if logger_mapper else thread_log
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
# We pass the custom logger as an extra kwarg if the method supports it
|
|
301
|
+
# or rely on the method returning its own logs.
|
|
302
|
+
result = method(item, _logger=effective_logger, **kwargs)
|
|
303
|
+
return {"status": "PASS", "item": item, "logs": log_buffer, "result": result}
|
|
304
|
+
except Exception as e:
|
|
305
|
+
thread_log(f"Thread failed for item {item}: {str(e)}", "ERROR")
|
|
306
|
+
return {"status": "FAIL", "item": item, "logs": log_buffer, "error": str(e)}
|
|
307
|
+
|
|
308
|
+
def _replay_logs(self, results: List[Dict[str, Any]], remove_passing_logs: bool = False, thread_log_level: str = "INFO"):
|
|
309
|
+
"""Sequential dump to the real RF Logger. Filters out IGNORE level logs."""
|
|
310
|
+
level_order = {"INFO": 1, "WARN": 2, "ERROR": 3}
|
|
311
|
+
min_level = level_order.get(thread_log_level, 1)
|
|
312
|
+
for entry in results:
|
|
313
|
+
if remove_passing_logs and entry['status'] == 'PASS':
|
|
314
|
+
continue
|
|
315
|
+
logger.info(f"--- Logs for Item: {entry['item']} ---")
|
|
316
|
+
for level, msg in entry['logs']:
|
|
317
|
+
# Skip IGNORE level and logs below minimum level
|
|
318
|
+
if level == "IGNORE" or level_order.get(level, 1) < min_level:
|
|
319
|
+
continue
|
|
320
|
+
if level == "INFO": logger.info(msg)
|
|
321
|
+
elif level == "WARN": logger.warn(msg)
|
|
322
|
+
elif level == "ERROR": logger.error(msg)
|
|
323
|
+
logger.info(f"Status: {entry['status']}")
|
|
324
|
+
|
|
325
|
+
# Backwards-compatible name: the class was called ParallelLibrary before the
|
|
326
|
+
# package was renamed to ParallelRunner (imported as `Library ParallelRunner`).
|
|
327
|
+
ParallelLibrary = ParallelRunner
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Single source of truth for the library version.
|
|
2
|
+
|
|
3
|
+
The version lives only in ``pyproject.toml`` (bump it with ``poetry version
|
|
4
|
+
patch|minor|major``). At runtime it is read back from the installed
|
|
5
|
+
distribution's metadata, so ``__version__`` / ``ROBOT_LIBRARY_VERSION`` can
|
|
6
|
+
never drift from what was published to PyPI.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version as _dist_version
|
|
10
|
+
|
|
11
|
+
DISTRIBUTION_NAME = "robotframework-parallelrunner"
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
__version__ = _dist_version(DISTRIBUTION_NAME)
|
|
15
|
+
except PackageNotFoundError: # running from a source checkout that was never installed
|
|
16
|
+
__version__ = "0.0.0+unknown"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Deprecated compatibility shim for the pre-0.2.0 import paths.
|
|
3
|
+
|
|
4
|
+
Old style (still works, emits a DeprecationWarning)::
|
|
5
|
+
|
|
6
|
+
Library parallelrunner.parallel_library.ParallelLibrary
|
|
7
|
+
|
|
8
|
+
New style::
|
|
9
|
+
|
|
10
|
+
Library ParallelRunner
|
|
11
|
+
|
|
12
|
+
Why a single-file module instead of a ``parallelrunner/`` package: Windows and
|
|
13
|
+
macOS filesystems are case-insensitive by default, so a ``parallelrunner/``
|
|
14
|
+
directory cannot live next to ``ParallelRunner/``. Registering the submodule
|
|
15
|
+
in ``sys.modules`` makes ``parallelrunner.parallel_library`` importable anyway.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import sys
|
|
19
|
+
import warnings
|
|
20
|
+
|
|
21
|
+
from ParallelRunner import ParallelLibrary, ParallelRunner, ParallelTaskError, __version__
|
|
22
|
+
from ParallelRunner import parallel_library
|
|
23
|
+
|
|
24
|
+
warnings.warn(
|
|
25
|
+
"Importing 'parallelrunner' is deprecated and will be removed in a future "
|
|
26
|
+
"release; use 'Library ParallelRunner' (or 'from ParallelRunner import "
|
|
27
|
+
"ParallelRunner') instead.",
|
|
28
|
+
DeprecationWarning,
|
|
29
|
+
stacklevel=2,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
sys.modules[__name__ + ".parallel_library"] = parallel_library
|
|
33
|
+
|
|
34
|
+
__all__ = ["ParallelRunner", "ParallelLibrary", "ParallelTaskError", "__version__", "parallel_library"]
|