behave-priority 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,59 @@
1
+ """behave-priority: Priority-based execution for Behave BDD."""
2
+
3
+ import logging
4
+
5
+ from behave_priority.config import PriorityConfig, ReportFormat
6
+ from behave_priority.exceptions import (
7
+ PriorityError,
8
+ PriorityParseError,
9
+ )
10
+ from behave_priority.hooks import (
11
+ after_scenario_hook,
12
+ before_scenario_hook,
13
+ cleanup_parallel_coord,
14
+ get_report,
15
+ priority_report,
16
+ setup_priority,
17
+ )
18
+ from behave_priority.parallel import (
19
+ ParallelCoordinator,
20
+ cleanup_coordinator,
21
+ create_coordinator,
22
+ )
23
+ from behave_priority.parser import (
24
+ is_critical,
25
+ parse_feature_priority,
26
+ parse_priority,
27
+ resolve_priority,
28
+ )
29
+ from behave_priority.report import PriorityReport, ReportEntry, ReportSummary
30
+ from behave_priority.sorter import ScenarioSorter
31
+
32
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
33
+
34
+ __version__ = "1.0.0"
35
+
36
+ __all__ = [
37
+ "PriorityConfig",
38
+ "ReportFormat",
39
+ "parse_priority",
40
+ "parse_feature_priority",
41
+ "resolve_priority",
42
+ "is_critical",
43
+ "ScenarioSorter",
44
+ "setup_priority",
45
+ "before_scenario_hook",
46
+ "after_scenario_hook",
47
+ "get_report",
48
+ "priority_report",
49
+ "cleanup_parallel_coord",
50
+ "ParallelCoordinator",
51
+ "cleanup_coordinator",
52
+ "create_coordinator",
53
+ "PriorityReport",
54
+ "ReportEntry",
55
+ "ReportSummary",
56
+ "PriorityError",
57
+ "PriorityParseError",
58
+ "__version__",
59
+ ]
@@ -0,0 +1,71 @@
1
+ """Immutable configuration for priority execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+ ReportFormat = Literal["text", "json", "csv"]
9
+
10
+ _VALID_FORMATS: frozenset[str] = frozenset({"text", "json", "csv"})
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class PriorityConfig:
15
+ """Immutable configuration for priority execution.
16
+
17
+ All configuration is programmatic — no CLI flags.
18
+
19
+ Attributes:
20
+ order: Sort scenarios by priority (highest first).
21
+ reverse: Reverse sort order (lowest priority first).
22
+ priority_tag: Tag name to run first (e.g. ``"smoke"``).
23
+ stop_after_failures: Stop after N failed scenarios, or None to disable.
24
+ stop_on_critical: Stop if any critical scenario fails.
25
+ critical_tag: Tag name that marks a scenario as critical.
26
+ default_priority: Priority for scenarios without a priority tag.
27
+ report: Print execution report after run.
28
+ report_format: Output format for the report (``"text"``, ``"json"``, or ``"csv"``).
29
+ parallel_coord: Enable cross-process fail-fast coordination via
30
+ ``BEHAVE_PRIORITY_COORD_DIR`` env var. When True and the env
31
+ var is set, workers share failure state via a file-based
32
+ coordinator.
33
+
34
+ Raises:
35
+ ValueError: If ``stop_after_failures`` is not a positive integer
36
+ (or None), if ``critical_tag`` or ``priority_tag`` is an empty
37
+ string, if ``default_priority`` is negative, or if
38
+ ``report_format`` is not one of ``"text"``, ``"json"``, ``"csv"``.
39
+ """
40
+
41
+ order: bool = False
42
+ reverse: bool = False
43
+ priority_tag: str | None = None
44
+ stop_after_failures: int | None = None
45
+ stop_on_critical: bool = False
46
+ critical_tag: str = "critical"
47
+ default_priority: int = 999
48
+ report: bool = False
49
+ report_format: ReportFormat = "text"
50
+ parallel_coord: bool = False
51
+
52
+ def __post_init__(self) -> None:
53
+ """Validate configuration fields after initialization."""
54
+ if self.stop_after_failures is not None and self.stop_after_failures <= 0:
55
+ raise ValueError(
56
+ f"stop_after_failures must be a positive integer or None, "
57
+ f"got {self.stop_after_failures}"
58
+ )
59
+ if not self.critical_tag:
60
+ raise ValueError("critical_tag must not be empty")
61
+ if self.priority_tag is not None and not self.priority_tag:
62
+ raise ValueError("priority_tag must not be empty if provided")
63
+ if self.default_priority < 0:
64
+ raise ValueError(
65
+ f"default_priority must be non-negative, got {self.default_priority}"
66
+ )
67
+ if self.report_format not in _VALID_FORMATS:
68
+ raise ValueError(
69
+ f"report_format must be one of 'text', 'json', 'csv', "
70
+ f"got {self.report_format!r}"
71
+ )
@@ -0,0 +1,15 @@
1
+ """Exception hierarchy for behave-priority."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class PriorityError(Exception):
7
+ """Base exception for all behave-priority errors."""
8
+
9
+
10
+ class PriorityParseError(PriorityError):
11
+ """Raised when a priority tag has invalid syntax.
12
+
13
+ For example, ``priority(abc)`` or ``priority(1.5)`` are invalid because
14
+ the value inside the parentheses is not an integer.
15
+ """
@@ -0,0 +1,390 @@
1
+ """Behave hooks for priority execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ from behave_priority.config import PriorityConfig, ReportFormat
10
+ from behave_priority.parallel import (
11
+ ParallelCoordinator,
12
+ cleanup_coordinator,
13
+ create_coordinator,
14
+ )
15
+ from behave_priority.parser import is_critical, resolve_priority
16
+ from behave_priority.report import PriorityReport
17
+ from behave_priority.sorter import ScenarioSorter
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def _scenario_key(scenario: Any) -> str:
23
+ """Build a deterministic key for a scenario.
24
+
25
+ Uses ``filename`` and ``line`` attributes when available (as behave
26
+ provides), falling back to ``id()`` for objects without them.
27
+
28
+ Args:
29
+ scenario: The scenario object.
30
+
31
+ Returns:
32
+ A string key unique to the scenario.
33
+ """
34
+ filename = getattr(scenario, "filename", None)
35
+ line = getattr(scenario, "line", None)
36
+ if filename is not None and line is not None:
37
+ return f"{filename}:{line}"
38
+ name = getattr(scenario, "name", "")
39
+ return f"id:{id(scenario)}:{name}"
40
+
41
+
42
+ def _map_scenario(
43
+ state: PriorityState,
44
+ scenario: Any,
45
+ feature_tags: list[str],
46
+ feature_name: str,
47
+ config: PriorityConfig,
48
+ rule_tags: list[str] | None = None,
49
+ ) -> None:
50
+ """Map a scenario and its expanded examples into priority state.
51
+
52
+ Handles both plain scenarios and ScenarioOutline objects. For
53
+ ScenarioOutline, each expanded example is mapped individually so
54
+ that ``after_scenario_hook`` can resolve the correct priority.
55
+
56
+ Args:
57
+ state: The priority state to populate.
58
+ scenario: The scenario or scenario outline to map.
59
+ feature_tags: Tags from the parent feature.
60
+ feature_name: Name or filename of the parent feature.
61
+ config: The priority configuration.
62
+ rule_tags: Tags from the parent rule (Gherkin v6), if any.
63
+ """
64
+ effective_rule_tags = rule_tags or []
65
+
66
+ expanded = getattr(scenario, "scenarios", None)
67
+ if expanded:
68
+ for example in expanded:
69
+ key = _scenario_key(example)
70
+ state.priority_map[key] = resolve_priority(
71
+ example.tags, feature_tags, config, effective_rule_tags
72
+ )
73
+ state.feature_map[key] = feature_name
74
+ state.rule_tag_map[key] = effective_rule_tags
75
+ state.feature_tag_map[key] = feature_tags
76
+ else:
77
+ key = _scenario_key(scenario)
78
+ state.priority_map[key] = resolve_priority(
79
+ scenario.tags, feature_tags, config, effective_rule_tags
80
+ )
81
+ state.feature_map[key] = feature_name
82
+ state.rule_tag_map[key] = effective_rule_tags
83
+ state.feature_tag_map[key] = feature_tags
84
+
85
+
86
+ @dataclass
87
+ class PriorityState:
88
+ """Mutable execution state, persisted across hooks via context.
89
+
90
+ .. note::
91
+ This state is **not shared across processes**. When behave runs with
92
+ ``--parallel``, each worker process gets its own isolated
93
+ ``PriorityState``. However, when ``parallel_coord=True`` and
94
+ ``BEHAVE_PRIORITY_COORD_DIR`` is set, a :class:`ParallelCoordinator`
95
+ shares fail-fast state across workers via file-based IPC.
96
+
97
+ Attributes:
98
+ config: The priority configuration.
99
+ report: The execution report collector.
100
+ sorter: The scenario sorter instance.
101
+ coordinator: Optional parallel coordinator for cross-process
102
+ fail-fast. None when ``parallel_coord`` is disabled.
103
+ failed_count: Number of failed scenarios so far.
104
+ critical_failed: Whether any critical scenario has failed.
105
+ should_stop: Whether fail-fast conditions have been triggered.
106
+ executed_count: Number of scenarios actually executed.
107
+ skipped_count: Number of scenarios skipped by fail-fast.
108
+ priority_map: Maps scenario key to resolved priority.
109
+ feature_map: Maps scenario key to parent feature name.
110
+ rule_tag_map: Maps scenario key to parent rule tags (Gherkin v6).
111
+ feature_tag_map: Maps scenario key to parent feature tags.
112
+ """
113
+
114
+ config: PriorityConfig
115
+ report: PriorityReport
116
+ sorter: ScenarioSorter
117
+ coordinator: ParallelCoordinator | None = None
118
+ failed_count: int = 0
119
+ critical_failed: bool = False
120
+ should_stop: bool = False
121
+ executed_count: int = 0
122
+ skipped_count: int = 0
123
+ priority_map: dict[str, int] = field(default_factory=dict)
124
+ feature_map: dict[str, str] = field(default_factory=dict)
125
+ rule_tag_map: dict[str, list[str]] = field(default_factory=dict)
126
+ feature_tag_map: dict[str, list[str]] = field(default_factory=dict)
127
+
128
+ def check_fail_fast(self) -> bool:
129
+ """Check if fail-fast conditions are met.
130
+
131
+ When a parallel coordinator is active, global failure counts
132
+ across all workers are considered in addition to local state.
133
+
134
+ Returns:
135
+ True if execution should stop after the current scenario.
136
+ """
137
+ if self.coordinator is not None:
138
+ return self.coordinator.should_stop(
139
+ stop_after_failures=self.config.stop_after_failures,
140
+ stop_on_critical=self.config.stop_on_critical,
141
+ )
142
+
143
+ if (
144
+ self.config.stop_after_failures is not None
145
+ and self.failed_count >= self.config.stop_after_failures
146
+ ):
147
+ return True
148
+
149
+ return self.config.stop_on_critical and self.critical_failed
150
+
151
+
152
+ def setup_priority(
153
+ context: Any,
154
+ *,
155
+ order: bool = False,
156
+ reverse: bool = False,
157
+ priority_tag: str | None = None,
158
+ stop_after_failures: int | None = None,
159
+ stop_on_critical: bool = False,
160
+ critical_tag: str = "critical",
161
+ default_priority: int = 999,
162
+ report: bool = False,
163
+ report_format: ReportFormat = "text",
164
+ parallel_coord: bool = False,
165
+ ) -> None:
166
+ """Set up priority execution in before_all hook.
167
+
168
+ All configuration is passed explicitly — no CLI flags.
169
+
170
+ Args:
171
+ context: Behave's context object (``context`` in ``before_all``).
172
+ order: Sort scenarios by priority (highest first).
173
+ reverse: Reverse sort order (lowest priority first).
174
+ priority_tag: Tag name to run first (e.g. ``"smoke"``).
175
+ stop_after_failures: Stop after N failed scenarios.
176
+ stop_on_critical: Stop if any critical scenario fails.
177
+ critical_tag: Tag name that marks a scenario as critical.
178
+ default_priority: Priority for scenarios without a priority tag.
179
+ report: Print execution report after run.
180
+ report_format: Output format for the report (``"text"``, ``"json"``, ``"csv"``).
181
+ parallel_coord: Enable cross-process fail-fast coordination.
182
+ Requires ``BEHAVE_PRIORITY_COORD_DIR`` env var to be set.
183
+ """
184
+ config = PriorityConfig(
185
+ order=order,
186
+ reverse=reverse,
187
+ priority_tag=priority_tag,
188
+ stop_after_failures=stop_after_failures,
189
+ stop_on_critical=stop_on_critical,
190
+ critical_tag=critical_tag,
191
+ default_priority=default_priority,
192
+ report=report,
193
+ report_format=report_format,
194
+ parallel_coord=parallel_coord,
195
+ )
196
+
197
+ runner = getattr(context, "_runner", None)
198
+ if runner is None:
199
+ runner = getattr(context, "runner", None)
200
+ if runner is None:
201
+ logger.warning(
202
+ "cannot access behave's runner. "
203
+ "Scenarios will NOT be reordered and priority hooks "
204
+ "will have no effect. Ensure this is called from "
205
+ "before_all() in environment.py."
206
+ )
207
+ return
208
+
209
+ features = getattr(runner, "features", None)
210
+ if features is None:
211
+ features = getattr(runner, "feature_list", None)
212
+ if features is None:
213
+ logger.warning(
214
+ "runner has no 'features' or 'feature_list' "
215
+ "attribute. Scenarios will NOT be reordered."
216
+ )
217
+ return
218
+
219
+ sorter = ScenarioSorter(config)
220
+ sorted_features = sorter.sort(features)
221
+ features[:] = sorted_features
222
+
223
+ priority_report_obj = PriorityReport(config)
224
+ state = PriorityState(
225
+ config=config,
226
+ report=priority_report_obj,
227
+ sorter=sorter,
228
+ )
229
+
230
+ for feature in sorted_features:
231
+ feature_name = feature.name or feature.filename
232
+ items = getattr(feature, "run_items", None) or feature.scenarios
233
+ for item in items:
234
+ if hasattr(item, "run_items"):
235
+ rule_tags: list[str] = getattr(item, "tags", [])
236
+ inner_items: Any = (
237
+ getattr(item, "run_items", None)
238
+ or getattr(item, "scenarios", [])
239
+ )
240
+ for scenario in inner_items:
241
+ _map_scenario(
242
+ state, scenario, feature.tags, feature_name, config, rule_tags
243
+ )
244
+ else:
245
+ _map_scenario(
246
+ state, item, feature.tags, feature_name, config
247
+ )
248
+
249
+ context._priority_state = state
250
+
251
+ if config.parallel_coord:
252
+ coordinator = create_coordinator()
253
+ if coordinator is not None:
254
+ state.coordinator = coordinator
255
+ context._priority_coordinator = coordinator
256
+ else:
257
+ logger.warning(
258
+ "parallel_coord=True but BEHAVE_PRIORITY_COORD_DIR "
259
+ "env var is not set. Cross-process coordination "
260
+ "will not be active."
261
+ )
262
+
263
+
264
+ def before_scenario_hook(context: Any, scenario: Any) -> None:
265
+ """Skip scenario if fail-fast has been triggered.
266
+
267
+ Intended for use as ``before_scenario`` in behave's ``environment.py``.
268
+ Recording of skipped scenarios is handled by ``after_scenario_hook``
269
+ to avoid duplicate entries.
270
+
271
+ Args:
272
+ context: Behave's context object.
273
+ scenario: The scenario about to run.
274
+ """
275
+ state: PriorityState | None = getattr(context, "_priority_state", None)
276
+ if state is None:
277
+ return
278
+
279
+ if state.coordinator is not None and not state.should_stop:
280
+ state.should_stop = state.check_fail_fast()
281
+
282
+ if state.should_stop:
283
+ state.skipped_count += 1
284
+ if hasattr(scenario, "skip"):
285
+ scenario.skip("fail-fast triggered")
286
+
287
+
288
+ def after_scenario_hook(context: Any, scenario: Any) -> None:
289
+ """Record scenario result and update fail-fast state.
290
+
291
+ Intended for use as ``after_scenario`` in behave's ``environment.py``.
292
+ Both executed and skipped scenarios are recorded here to avoid
293
+ duplicate entries.
294
+
295
+ Args:
296
+ context: Behave's context object.
297
+ scenario: The scenario that just finished.
298
+ """
299
+ state: PriorityState | None = getattr(context, "_priority_state", None)
300
+ if state is None:
301
+ return
302
+
303
+ key = _scenario_key(scenario)
304
+ priority = state.priority_map.get(key, state.config.default_priority)
305
+ raw_status = getattr(scenario, "status", "unknown")
306
+ status = raw_status.name if hasattr(raw_status, "name") else str(raw_status)
307
+ duration = getattr(scenario, "duration", 0.0)
308
+ feature_name = state.feature_map.get(key, "")
309
+
310
+ rule_tags = state.rule_tag_map.get(key, [])
311
+ feature_tags = state.feature_tag_map.get(key, [])
312
+ scenario_tags = getattr(scenario, "tags", [])
313
+ combined_tags = list(scenario_tags) + rule_tags + feature_tags
314
+ is_crit = is_critical(combined_tags, state.config.critical_tag)
315
+
316
+ state.report.record(
317
+ scenario_name=getattr(scenario, "name", "unknown"),
318
+ feature_name=feature_name,
319
+ priority=priority,
320
+ status=status,
321
+ duration=duration,
322
+ is_critical=is_crit,
323
+ )
324
+
325
+ if status == "skipped":
326
+ state.should_stop = state.check_fail_fast()
327
+ return
328
+
329
+ state.executed_count += 1
330
+
331
+ if status == "failed":
332
+ state.failed_count += 1
333
+ if is_crit:
334
+ state.critical_failed = True
335
+ if state.coordinator is not None:
336
+ state.coordinator.report_failure(is_critical=is_crit)
337
+
338
+ state.should_stop = state.check_fail_fast()
339
+
340
+
341
+ def get_report(context: Any) -> PriorityReport | None:
342
+ """Retrieve the priority execution report from context.
343
+
344
+ Allows programmatic access to the ``PriorityReport`` object after a run,
345
+ without accessing the private ``context._priority_state``.
346
+
347
+ Args:
348
+ context: Behave's context object.
349
+
350
+ Returns:
351
+ The ``PriorityReport`` if priority state was set up, otherwise None.
352
+ """
353
+ state: PriorityState | None = getattr(context, "_priority_state", None)
354
+ if state is None:
355
+ return None
356
+ return state.report
357
+
358
+
359
+ def priority_report(context: Any) -> None:
360
+ """Print the priority execution report.
361
+
362
+ Intended for use as ``after_all`` in behave's ``environment.py``.
363
+
364
+ Args:
365
+ context: Behave's context object.
366
+ """
367
+ state: PriorityState | None = getattr(context, "_priority_state", None)
368
+ if state is None:
369
+ return
370
+
371
+ if state.config.report:
372
+ fmt = state.config.report_format
373
+ if fmt == "json":
374
+ logger.info("\n%s", state.report.to_json())
375
+ elif fmt == "csv":
376
+ logger.info("\n%s", state.report.to_csv())
377
+ else:
378
+ logger.info("\n%s", state.report.render())
379
+
380
+
381
+ def cleanup_parallel_coord(context: Any) -> None:
382
+ """Clean up the parallel coordinator for this worker.
383
+
384
+ Intended for use in ``after_all`` alongside ``priority_report``.
385
+ Removes this worker's file from the coordination directory.
386
+
387
+ Args:
388
+ context: Behave's context object.
389
+ """
390
+ cleanup_coordinator(context)
@@ -0,0 +1,164 @@
1
+ """File-based coordination for parallel fail-fast across worker processes.
2
+
3
+ When behave runs with ``--parallel=N``, each worker is a separate process
4
+ with its own ``PriorityState``. This module provides a ``ParallelCoordinator``
5
+ that uses a shared directory with one JSON file per worker to track global
6
+ failure counts and critical failures.
7
+
8
+ Each worker writes its own file atomically (temp file + rename). To check
9
+ global state, all worker files in the coordination directory are read and
10
+ aggregated. This avoids the need for cross-process file locking.
11
+
12
+ Usage::
13
+
14
+ # Before running behave with --parallel, set env var:
15
+ # BEHAVE_PRIORITY_COORD_DIR=/tmp/behave_priority_coord
16
+
17
+ # In environment.py:
18
+ setup_priority(context, stop_after_failures=3, parallel_coord=True)
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import contextlib
24
+ import json
25
+ import os
26
+ from pathlib import Path
27
+
28
+ _COORD_DIR_ENV = "BEHAVE_PRIORITY_COORD_DIR"
29
+
30
+
31
+ class ParallelCoordinator:
32
+ """File-based coordination for parallel workers.
33
+
34
+ Each worker creates and maintains its own JSON file in a shared
35
+ coordination directory. The file contains the worker's failure
36
+ count and critical-failure flag. Global state is computed by
37
+ reading all worker files in the directory.
38
+
39
+ Attributes:
40
+ coord_dir: Directory where worker files are stored.
41
+ worker_id: Unique identifier for this worker (defaults to PID).
42
+ """
43
+
44
+ def __init__(self, coord_dir: str | Path, worker_id: str | None = None) -> None:
45
+ """Initialize the coordinator and create the worker file.
46
+
47
+ If a stale file from a previous run exists (e.g. a worker that
48
+ crashed without cleanup), it is overwritten with fresh state.
49
+
50
+ Args:
51
+ coord_dir: Path to the shared coordination directory.
52
+ Will be created if it does not exist.
53
+ worker_id: Unique identifier for this worker. If None,
54
+ uses the process PID.
55
+ """
56
+ self.coord_dir = Path(coord_dir)
57
+ self.coord_dir.mkdir(parents=True, exist_ok=True)
58
+ self.worker_id = worker_id or str(os.getpid())
59
+ self._worker_file = self.coord_dir / f"worker_{self.worker_id}.json"
60
+ self._failed_count = 0
61
+ self._critical_failed = False
62
+ self._write_state()
63
+
64
+ def report_failure(self, *, is_critical: bool = False) -> None:
65
+ """Record a failure for this worker.
66
+
67
+ Atomically updates the worker's file with the new failure count.
68
+
69
+ Args:
70
+ is_critical: Whether the failure was in a critical scenario.
71
+ """
72
+ self._failed_count += 1
73
+ if is_critical:
74
+ self._critical_failed = True
75
+ self._write_state()
76
+
77
+ def should_stop(
78
+ self,
79
+ stop_after_failures: int | None,
80
+ stop_on_critical: bool,
81
+ ) -> bool:
82
+ """Check if global fail-fast conditions have been met.
83
+
84
+ Reads all worker files in the coordination directory and
85
+ aggregates failure counts and critical-failure flags.
86
+
87
+ Args:
88
+ stop_after_failures: Global failure threshold, or None.
89
+ stop_on_critical: Whether to stop on any critical failure.
90
+
91
+ Returns:
92
+ True if global conditions indicate execution should stop.
93
+ """
94
+ global_failed = 0
95
+ global_critical = False
96
+
97
+ for f in self.coord_dir.glob("worker_*.json"):
98
+ try:
99
+ data = json.loads(f.read_text(encoding="utf-8"))
100
+ global_failed += data.get("failed_count", 0)
101
+ if data.get("critical_failed", False):
102
+ global_critical = True
103
+ except (json.JSONDecodeError, OSError):
104
+ continue
105
+
106
+ if stop_after_failures is not None and global_failed >= stop_after_failures:
107
+ return True
108
+ return stop_on_critical and global_critical
109
+
110
+ def cleanup(self) -> None:
111
+ """Remove this worker's file from the coordination directory."""
112
+ with contextlib.suppress(OSError):
113
+ self._worker_file.unlink(missing_ok=True)
114
+
115
+ def _write_state(self) -> None:
116
+ """Atomically write this worker's state to its file."""
117
+ data = {
118
+ "failed_count": self._failed_count,
119
+ "critical_failed": self._critical_failed,
120
+ }
121
+ tmp = self._worker_file.with_suffix(".tmp")
122
+ tmp.write_text(json.dumps(data), encoding="utf-8")
123
+ tmp.replace(self._worker_file)
124
+
125
+
126
+ def get_coord_dir() -> str | None:
127
+ """Get the coordination directory from the environment variable.
128
+
129
+ Returns:
130
+ The path from ``BEHAVE_PRIORITY_COORD_DIR`` env var, or None.
131
+ """
132
+ return os.environ.get(_COORD_DIR_ENV)
133
+
134
+
135
+ def create_coordinator(worker_id: str | None = None) -> ParallelCoordinator | None:
136
+ """Create a ParallelCoordinator from the env var, if set.
137
+
138
+ Args:
139
+ worker_id: Optional worker identifier. Defaults to PID.
140
+
141
+ Returns:
142
+ A ``ParallelCoordinator`` if ``BEHAVE_PRIORITY_COORD_DIR`` is set,
143
+ otherwise None.
144
+ """
145
+ coord_dir = get_coord_dir()
146
+ if coord_dir is None:
147
+ return None
148
+ return ParallelCoordinator(coord_dir, worker_id=worker_id)
149
+
150
+
151
+ def cleanup_coordinator(context: object) -> None:
152
+ """Clean up the parallel coordinator for this worker.
153
+
154
+ Intended for use in ``after_all``. Removes the worker's file from
155
+ the coordination directory.
156
+
157
+ Args:
158
+ context: Behave's context object.
159
+ """
160
+ coordinator: ParallelCoordinator | None = getattr(
161
+ context, "_priority_coordinator", None
162
+ )
163
+ if coordinator is not None:
164
+ coordinator.cleanup()