robotframework-parallelrunner 0.2.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.
@@ -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"
parallelrunner.py ADDED
@@ -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"]
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/robotframework-parallelrunner)](https://pypi.org/project/robotframework-parallelrunner/)
38
+ [![Python versions](https://img.shields.io/pypi/pyversions/robotframework-parallelrunner)](https://pypi.org/project/robotframework-parallelrunner/)
39
+ [![Tests](https://github.com/cristiangarciavd/robotframework-parallelrunner/actions/workflows/tests.yml/badge.svg)](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,9 @@
1
+ ParallelRunner/__init__.py,sha256=ifsoO1rXf4kL3i75NokrD_KHxnlvE3IuH69Fb-ByfNg,641
2
+ ParallelRunner/parallel_library.py,sha256=FL7UkGwDJ8deN0F87bZr-PwuWqx3HEX8hF1kDKR8JUc,15173
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.2.0.dist-info/METADATA,sha256=rpxLRJ4t-d8VKyrLfKSVxVNUL4Gd7tPsUoa7FI9xS4w,11187
7
+ robotframework_parallelrunner-0.2.0.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
8
+ robotframework_parallelrunner-0.2.0.dist-info/licenses/LICENSE,sha256=xxQh3uvlk5jC1i0eoH5Nyo9cDz2Fzu0DqOzAZw8XxFs,1072
9
+ robotframework_parallelrunner-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.5.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.