behave-pool 1.0.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,21 @@
1
+ """behave-pool: parallel test execution for Behave BDD via native ITestRunner.
2
+
3
+ Public API:
4
+
5
+ from behave_pool import ParallelRunner
6
+
7
+ # Register in behave.ini:
8
+ # [behave.runners]
9
+ # parallel = behave_pool:ParallelRunner
10
+
11
+ # Then run:
12
+ # behave --runner=parallel --parallel 4 --parallel-scheme feature features/
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ __version__ = "1.0.0"
18
+
19
+ from behave_pool.runner import ParallelRunner
20
+
21
+ __all__ = ["ParallelRunner", "__version__"]
behave_pool/config.py ADDED
@@ -0,0 +1,131 @@
1
+ """Parallel configuration options for behave-pool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from behave.configuration import Configuration
10
+
11
+
12
+ def _register_parallel_options() -> None:
13
+ """Register --parallel-scheme and --parallel-balance in behave's OPTIONS list (once)."""
14
+ from behave.configuration import OPTIONS
15
+
16
+ existing: set[str] = set()
17
+ for fixed, _ in OPTIONS:
18
+ if fixed:
19
+ existing.add(fixed[0])
20
+
21
+ if "--parallel-scheme" not in existing:
22
+ OPTIONS.append(
23
+ (
24
+ ("--parallel-scheme",),
25
+ {
26
+ "dest": "parallel_scheme",
27
+ "default": "feature",
28
+ "help": "Parallelization scheme: feature (default: %(default)s).",
29
+ },
30
+ ),
31
+ )
32
+
33
+ if "--parallel-balance" not in existing:
34
+ OPTIONS.append(
35
+ (
36
+ ("--parallel-balance",),
37
+ {
38
+ "dest": "parallel_balance",
39
+ "default": "lpt",
40
+ "choices": ["lpt", "fifo"],
41
+ "help": (
42
+ "Work unit ordering: lpt (longest first) or fifo (default: %(default)s)."
43
+ ),
44
+ },
45
+ ),
46
+ )
47
+
48
+ if "--parallel-timing-file" not in existing:
49
+ OPTIONS.append(
50
+ (
51
+ ("--parallel-timing-file",),
52
+ {
53
+ "dest": "parallel_timing_file",
54
+ "default": ".behave-pool-timing.json",
55
+ "help": "Path to timing file for LPT balancing (default: %(default)s).",
56
+ },
57
+ ),
58
+ )
59
+
60
+
61
+ _register_parallel_options()
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class ConfigSnapshot:
66
+ """Picklable snapshot of essential Configuration fields for worker processes.
67
+
68
+ The full behave Configuration contains non-picklable objects (file
69
+ handles, reporters). This snapshot captures only the fields needed
70
+ by WorkerRunner to execute features.
71
+ """
72
+
73
+ base_dir: str
74
+ steps_dir: str
75
+ environment_file: str
76
+ lang: str | None
77
+ stop: bool
78
+ paths: list[str] = field(default_factory=list)
79
+ parallel: int = 1
80
+ parallel_scheme: str = "feature"
81
+ parallel_balance: str = "lpt"
82
+ parallel_timing_file: str = ".behave-pool-timing.json"
83
+ dry_run: bool = False
84
+ use_nested_step_modules: bool = False
85
+
86
+
87
+ def snapshot_config(config: Configuration) -> ConfigSnapshot:
88
+ """Create a picklable snapshot from a Configuration instance."""
89
+ return ConfigSnapshot(
90
+ base_dir=str(getattr(config, "base_dir", None) or "features"),
91
+ steps_dir=str(getattr(config, "steps_dir", None) or "steps"),
92
+ environment_file=str(getattr(config, "environment_file", None) or "environment.py"),
93
+ lang=config.lang,
94
+ stop=config.stop,
95
+ paths=[str(p) for p in config.paths] if config.paths else [],
96
+ parallel=getattr(config, "parallel", None) or getattr(config, "jobs", None) or 1,
97
+ parallel_scheme=getattr(config, "parallel_scheme", "feature"),
98
+ parallel_balance=getattr(config, "parallel_balance", "lpt"),
99
+ parallel_timing_file=str(
100
+ getattr(config, "parallel_timing_file", None) or ".behave-pool-timing.json"
101
+ ),
102
+ dry_run=config.dry_run,
103
+ use_nested_step_modules=getattr(config, "use_nested_step_modules", False),
104
+ )
105
+
106
+
107
+ def add_parallel_options(config: Configuration) -> None:
108
+ """Add parallel-related attributes to a Configuration instance.
109
+
110
+ Maps behave's ``config.jobs`` (from ``--parallel``/``--jobs``) to
111
+ ``config.parallel`` and ensures ``config.parallel_scheme`` exists.
112
+
113
+ Args:
114
+ config: Behave Configuration instance to augment.
115
+ """
116
+ jobs = getattr(config, "jobs", 1)
117
+ if jobs is None:
118
+ jobs = 1
119
+ config.parallel = jobs
120
+
121
+ if not hasattr(config, "parallel_scheme") or config.parallel_scheme is None:
122
+ config.parallel_scheme = "feature"
123
+
124
+ if not hasattr(config, "parallel_balance") or config.parallel_balance is None:
125
+ config.parallel_balance = "lpt"
126
+
127
+ if not hasattr(config, "parallel_timing_file") or config.parallel_timing_file is None:
128
+ config.parallel_timing_file = ".behave-pool-timing.json"
129
+
130
+ if not hasattr(config, "use_nested_step_modules"):
131
+ config.use_nested_step_modules = False
@@ -0,0 +1,81 @@
1
+ """Strategy pattern for iterating work units from Behave features."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from collections.abc import Iterator
7
+ from typing import TYPE_CHECKING
8
+
9
+ from behave_pool.config import snapshot_config
10
+ from behave_pool.work_unit import WorkUnit
11
+
12
+ if TYPE_CHECKING:
13
+ from behave.configuration import Configuration
14
+ from behave.model import Feature
15
+
16
+
17
+ class WorkUnitIterator(ABC):
18
+ """Abstract strategy for generating WorkUnits from parsed features."""
19
+
20
+ @abstractmethod
21
+ def iterate(self) -> Iterator[WorkUnit]:
22
+ """Yield WorkUnit instances one at a time."""
23
+ ...
24
+
25
+ @staticmethod
26
+ def for_scheme(
27
+ scheme: str,
28
+ features: list[Feature],
29
+ config: Configuration,
30
+ ) -> WorkUnitIterator:
31
+ """Factory: return the iterator for the given parallel scheme.
32
+
33
+ Args:
34
+ scheme: "feature" or "scenario".
35
+ features: Parsed Behave Feature objects.
36
+ config: Coordinator's Configuration (will be deep-copied per unit).
37
+
38
+ Returns:
39
+ A WorkUnitIterator instance for the requested scheme.
40
+
41
+ Raises:
42
+ ValueError: If scheme is not recognised.
43
+ NotImplementedError: If scheme is "scenario" (not yet implemented).
44
+ """
45
+ if scheme == "feature":
46
+ return FeatureIterator(features, config)
47
+ if scheme == "scenario":
48
+ raise NotImplementedError("ScenarioIterator is not yet implemented")
49
+ msg = f"Unknown parallel scheme: {scheme!r}. Use 'feature' or 'scenario'."
50
+ raise ValueError(msg)
51
+
52
+
53
+ class FeatureIterator(WorkUnitIterator):
54
+ """Generate one WorkUnit per feature file.
55
+
56
+ Each WorkUnit contains an isolated deep copy of the Configuration
57
+ so that workers can execute independently without shared mutable state.
58
+ """
59
+
60
+ def __init__(self, features: list[Feature], config: Configuration) -> None:
61
+ self._features = features
62
+ self._config = config
63
+
64
+ def iterate(self) -> Iterator[WorkUnit]:
65
+ """Yield one WorkUnit per feature.
66
+
67
+ Tags are collected from both the feature and its scenarios.
68
+ If any scenario has the ``serial`` tag, the work unit is
69
+ marked serial so it runs in the serial phase.
70
+ """
71
+ for feature in self._features:
72
+ tags = set(str(t) for t in (getattr(feature, "tags", None) or []))
73
+ for scenario in getattr(feature, "scenarios", None) or []:
74
+ tags.update(str(t) for t in (getattr(scenario, "tags", None) or []))
75
+ yield WorkUnit(
76
+ id=f"feature:{feature.filename}",
77
+ config=snapshot_config(self._config),
78
+ feature_path=feature.filename,
79
+ scenario_line=None,
80
+ tags=list(tags),
81
+ )
behave_pool/py.typed ADDED
File without changes
behave_pool/result.py ADDED
@@ -0,0 +1,32 @@
1
+ """WorkerResult: the outcome of executing a WorkUnit in a worker process."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class WorkerResult:
10
+ """Result of a worker executing a single WorkUnit.
11
+
12
+ Produced by WorkerRunner.run_work_unit() and sent back to the
13
+ coordinator via the result queue for aggregation.
14
+
15
+ Attributes:
16
+ worker_id: Identifier of the worker process that produced this result.
17
+ work_unit_id: ID of the WorkUnit that was executed.
18
+ failed: True if any scenario or step in the work unit failed.
19
+ duration: Wall-clock execution time in seconds.
20
+ report_path: Path to the temporary JSON report file, or None if
21
+ no report was written.
22
+ undefined_steps: List of undefined step text patterns encountered.
23
+ error: Error message if the worker process crashed, None otherwise.
24
+ """
25
+
26
+ worker_id: int
27
+ work_unit_id: str
28
+ failed: bool
29
+ duration: float
30
+ report_path: str | None = None
31
+ undefined_steps: list[str] = field(default_factory=list)
32
+ error: str | None = None
behave_pool/runner.py ADDED
@@ -0,0 +1,343 @@
1
+ """ParallelRunner: coordinator that orchestrates parallel feature execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import multiprocessing
7
+ import os
8
+ import queue
9
+ import shutil
10
+ import time
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING
13
+
14
+ from behave.runner import Runner, make_formatters, parse_features
15
+
16
+ from behave_pool.config import add_parallel_options, snapshot_config
17
+ from behave_pool.iterator import WorkUnitIterator
18
+ from behave_pool.result import WorkerResult
19
+ from behave_pool.timing import TimingStore
20
+ from behave_pool.worker import WorkerProcess
21
+
22
+ if TYPE_CHECKING:
23
+ from typing import Any
24
+
25
+ from behave.configuration import Configuration
26
+
27
+ from behave_pool.work_unit import WorkUnit
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ class ParallelRunner(Runner): # type: ignore[misc]
33
+ """Coordinator that dispatches work units to worker processes.
34
+
35
+ When ``config.parallel <= 1`` it falls back to the standard Behave
36
+ sequential runner. Otherwise it plans, dispatches, and collects
37
+ results from N worker processes.
38
+ """
39
+
40
+ def __init__(self, config: Configuration) -> None:
41
+ super().__init__(config)
42
+ add_parallel_options(config)
43
+
44
+ def run(self) -> bool:
45
+ """Run the test suite — parallel or sequential depending on config."""
46
+ with self.path_manager:
47
+ self.setup_paths()
48
+ return self.run_with_paths()
49
+
50
+ def run_with_paths(self) -> bool:
51
+ """Run tests with configured paths.
52
+
53
+ If ``config.parallel <= 1`` delegates to the standard sequential
54
+ runner. Otherwise enters the parallel pipeline.
55
+ """
56
+ if self.config.parallel <= 1:
57
+ return self._run_sequential()
58
+
59
+ return self._run_parallel()
60
+
61
+ def _run_sequential(self) -> bool:
62
+ """Standard Behave sequential execution."""
63
+ from behave.runner import Context
64
+
65
+ self.context = Context(self)
66
+ self.load_hooks()
67
+ self.load_step_definitions()
68
+
69
+ feature_locations = [
70
+ filename for filename in self.feature_locations() if not self.config.exclude(filename)
71
+ ]
72
+ features = parse_features(feature_locations, language=self.config.lang)
73
+ self.features.extend(features)
74
+
75
+ stream_openers = self.config.outputs
76
+ self.formatters = make_formatters(self.config, stream_openers)
77
+ failed: bool = self.run_model()
78
+ return failed
79
+
80
+ def _run_parallel(self) -> bool:
81
+ """Execute the parallel pipeline: plan -> split -> dispatch -> collect."""
82
+ ctx = multiprocessing.get_context("spawn")
83
+ task_queue: Any = ctx.JoinableQueue()
84
+ result_queue: Any = ctx.Queue()
85
+ stop_event: Any = ctx.Event()
86
+
87
+ try:
88
+ work_units = self._plan()
89
+ parallel_batch, serial_batch = self._split_by_serial_tag(work_units)
90
+ dispatched = self._dispatch(
91
+ task_queue, result_queue, stop_event, parallel_batch, serial_batch
92
+ )
93
+ return self._collect(result_queue, dispatched)
94
+ finally:
95
+ stop_event.set()
96
+ task_queue.close()
97
+ result_queue.close()
98
+
99
+ def _plan(self) -> list[WorkUnit]:
100
+ """Parse features and create work units.
101
+
102
+ Returns:
103
+ List of work units to execute.
104
+ """
105
+ from behave.runner import Context
106
+
107
+ self.context = Context(self)
108
+ self.load_hooks()
109
+
110
+ feature_locations = [
111
+ filename for filename in self.feature_locations() if not self.config.exclude(filename)
112
+ ]
113
+ features = parse_features(feature_locations, language=self.config.lang)
114
+ self.features.extend(features)
115
+
116
+ iterator = WorkUnitIterator.for_scheme(
117
+ scheme=self.config.parallel_scheme,
118
+ features=features,
119
+ config=self.config,
120
+ )
121
+ work_units = list(iterator.iterate())
122
+ work_units = self._sort_by_duration(work_units)
123
+
124
+ return work_units
125
+
126
+ def _sort_by_duration(self, units: list[WorkUnit]) -> list[WorkUnit]:
127
+ """Sort work units by historical duration (LPT) or keep FIFO order.
128
+
129
+ When ``config.parallel_balance`` is ``"lpt"``, units are sorted
130
+ descending by their stored duration in the TimingStore so that
131
+ the longest jobs start first, improving overall wall-clock time.
132
+
133
+ When ``config.parallel_balance`` is ``"fifo"``, the original
134
+ order is preserved.
135
+
136
+ Args:
137
+ units: Work units to sort.
138
+
139
+ Returns:
140
+ Sorted list of work units.
141
+ """
142
+ balance = getattr(self.config, "parallel_balance", "lpt")
143
+ if balance == "fifo":
144
+ return units
145
+
146
+ timing_file = (
147
+ getattr(self.config, "parallel_timing_file", None) or ".behave-pool-timing.json"
148
+ )
149
+ store = TimingStore(path=Path(timing_file))
150
+ store.load()
151
+ return sorted(units, key=lambda u: store.get_duration(u.id), reverse=True)
152
+
153
+ @staticmethod
154
+ def _split_by_serial_tag(
155
+ units: list[WorkUnit],
156
+ ) -> tuple[list[WorkUnit], list[WorkUnit]]:
157
+ """Split work units into parallel and serial batches.
158
+
159
+ Args:
160
+ units: All work units to split.
161
+
162
+ Returns:
163
+ Tuple of (parallel_batch, serial_batch).
164
+ """
165
+ parallel_batch = [u for u in units if not u.is_serial]
166
+ serial_batch = [u for u in units if u.is_serial]
167
+ return parallel_batch, serial_batch
168
+
169
+ def _dispatch(
170
+ self,
171
+ task_queue: Any,
172
+ result_queue: Any,
173
+ stop_event: Any,
174
+ parallel_batch: list[WorkUnit],
175
+ serial_batch: list[WorkUnit],
176
+ ) -> list[WorkUnit]:
177
+ """Two-phase dispatch: parallel first, then serial.
178
+
179
+ Phase 1: enqueue parallel_batch, launch N workers, wait for completion.
180
+ Phase 2: enqueue serial_batch one at a time, launch 1 worker, wait.
181
+
182
+ Returns:
183
+ List of work units that were actually enqueued (dispatched).
184
+ """
185
+ n_workers = self.config.parallel
186
+ config_snapshot = snapshot_config(self.config)
187
+ dispatched: list[WorkUnit] = []
188
+ ctx = multiprocessing.get_context("spawn")
189
+
190
+ # -- Phase 1: parallel batch with N workers.
191
+ if parallel_batch:
192
+ for unit in parallel_batch:
193
+ task_queue.put(unit)
194
+ for _ in range(n_workers):
195
+ task_queue.put(None)
196
+ dispatched.extend(parallel_batch)
197
+
198
+ workers: list[WorkerProcess] = []
199
+ for worker_id in range(n_workers):
200
+ worker = WorkerProcess(
201
+ worker_id=worker_id,
202
+ task_queue=task_queue,
203
+ result_queue=result_queue,
204
+ stop_event=stop_event,
205
+ config_snapshot=config_snapshot,
206
+ ctx=ctx,
207
+ )
208
+ worker.start()
209
+ workers.append(worker)
210
+
211
+ for worker in workers:
212
+ worker.join(timeout=300)
213
+ if worker.is_alive():
214
+ logger.warning(
215
+ "Worker %d did not terminate within 300s; "
216
+ "setting stop event and terminating.",
217
+ worker.worker_id,
218
+ )
219
+ stop_event.set()
220
+ worker.terminate()
221
+
222
+ # Drain any unconsumed items so the queue is empty for Phase 2.
223
+ while not task_queue.empty():
224
+ try:
225
+ task_queue.get_nowait()
226
+ task_queue.task_done()
227
+ except queue.Empty:
228
+ break
229
+
230
+ # -- Phase 2: serial batch with 1 worker.
231
+ if serial_batch and not stop_event.is_set():
232
+ for unit in serial_batch:
233
+ task_queue.put(unit)
234
+ task_queue.put(None)
235
+ dispatched.extend(serial_batch)
236
+
237
+ serial_worker = WorkerProcess(
238
+ worker_id=0,
239
+ task_queue=task_queue,
240
+ result_queue=result_queue,
241
+ stop_event=stop_event,
242
+ config_snapshot=config_snapshot,
243
+ ctx=ctx,
244
+ )
245
+ serial_worker.start()
246
+ serial_worker.join(timeout=300)
247
+ if serial_worker.is_alive():
248
+ logger.warning(
249
+ "Serial worker did not terminate within 300s; "
250
+ "setting stop event and terminating."
251
+ )
252
+ stop_event.set()
253
+ serial_worker.terminate()
254
+
255
+ # Drain any unconsumed items.
256
+ while not task_queue.empty():
257
+ try:
258
+ task_queue.get_nowait()
259
+ task_queue.task_done()
260
+ except queue.Empty:
261
+ break
262
+
263
+ return dispatched
264
+
265
+ def _collect(
266
+ self,
267
+ result_queue: Any,
268
+ work_units: list[WorkUnit],
269
+ deadline_seconds: float = 30,
270
+ ) -> bool:
271
+ """Drain result queue, merge results, and compute exit code.
272
+
273
+ Returns:
274
+ True if any test failed (Behave convention).
275
+ """
276
+ expected = len(work_units)
277
+ results: list[WorkerResult] = []
278
+ received_ids: set[str] = set()
279
+
280
+ # Drain all available results, waiting up to deadline_seconds for late arrivals.
281
+ deadline = time.monotonic() + deadline_seconds
282
+ while len(results) < expected and time.monotonic() < deadline:
283
+ try:
284
+ result = result_queue.get(timeout=1)
285
+ except queue.Empty:
286
+ continue
287
+ except (EOFError, OSError):
288
+ break
289
+ results.append(result)
290
+ received_ids.add(result.work_unit_id)
291
+
292
+ # Detect missing results from crashed or timed-out workers.
293
+ missing = [u.id for u in work_units if u.id not in received_ids]
294
+ if missing:
295
+ logger.warning(
296
+ "Missing %d result(s) from worker(s): %s",
297
+ len(missing),
298
+ ", ".join(missing),
299
+ )
300
+
301
+ any_failed = any(r.failed for r in results)
302
+
303
+ # Missing results indicate worker crashes — treat as failures.
304
+ if missing:
305
+ any_failed = True
306
+
307
+ self._update_timings(results)
308
+
309
+ tmp_dir = os.path.join(os.getcwd(), "tmp")
310
+ if os.path.isdir(tmp_dir):
311
+ shutil.rmtree(tmp_dir, ignore_errors=True)
312
+
313
+ logger.info(
314
+ "Parallel run complete: %d work units, %d results, failed=%s",
315
+ len(work_units),
316
+ len(results),
317
+ any_failed,
318
+ )
319
+
320
+ return any_failed
321
+
322
+ def _update_timings(self, results: list[WorkerResult]) -> None:
323
+ """Update the TimingStore with observed durations from results.
324
+
325
+ Timing persistence is best-effort: any failure is logged and
326
+ does not affect the test run outcome.
327
+
328
+ Args:
329
+ results: Worker results containing durations to persist.
330
+ """
331
+ timing_file = (
332
+ getattr(self.config, "parallel_timing_file", None) or ".behave-pool-timing.json"
333
+ )
334
+ try:
335
+ store = TimingStore(path=Path(timing_file))
336
+ store.load()
337
+ for result in results:
338
+ store.update(result.work_unit_id, result.duration)
339
+ store.save_if_changed()
340
+ except Exception:
341
+ logger.warning(
342
+ "Failed to update timing file %s; timings will not persist.", timing_file
343
+ )