robotframework-parallelrunner 0.2.0__py3-none-any.whl → 0.2.1__py3-none-any.whl

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