robotframework-parallelrunner 0.2.0__py3-none-any.whl → 0.3.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.
@@ -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 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``.
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
- 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.
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
- 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.
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 - injected as ``_logger`` instead of
151
- the default buffering logger, to route logs to a custom logging
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 which might be a callable or a string path to a module function.
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 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.
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
- 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])
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
- except (ImportError, AttributeError):
260
- pass
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 custom logger mapper from ROBOT_LOGGER_MAPPER environment variable.
267
- Supports both registered mapper names and module.function paths.
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
- 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)
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()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: robotframework-parallelrunner
3
- Version: 0.2.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.** Validating 100 APIs that take 1s each takes roughly 10s (with 10
116
- workers) instead of 100s.
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
- 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
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
 
@@ -1,9 +1,9 @@
1
1
  ParallelRunner/__init__.py,sha256=ifsoO1rXf4kL3i75NokrD_KHxnlvE3IuH69Fb-ByfNg,641
2
- ParallelRunner/parallel_library.py,sha256=FL7UkGwDJ8deN0F87bZr-PwuWqx3HEX8hF1kDKR8JUc,15173
2
+ ParallelRunner/parallel_library.py,sha256=qagok_v9Kw_OpsXy5yRb2hvwBpwadfgwhfC5bL1boVE,19123
3
3
  ParallelRunner/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  ParallelRunner/version.py,sha256=W8E8ymdTLqfKl_1qBIOrjGpO8kSbqPw30aYZpqYUNMk,632
5
5
  parallelrunner.py,sha256=2oyKOVeKlOaZUZ3f2i_l5xCAYFKVm11bSQZOtzSSJO8,1132
6
- robotframework_parallelrunner-0.2.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,,
6
+ robotframework_parallelrunner-0.3.0.dist-info/METADATA,sha256=6Ph0tfBiO_I6mZ4BP606SDGfJYwCUzOICu5LHMoq1kY,14313
7
+ robotframework_parallelrunner-0.3.0.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
8
+ robotframework_parallelrunner-0.3.0.dist-info/licenses/LICENSE,sha256=xxQh3uvlk5jC1i0eoH5Nyo9cDz2Fzu0DqOzAZw8XxFs,1072
9
+ robotframework_parallelrunner-0.3.0.dist-info/RECORD,,