Source code for behave_priority.parallel

"""File-based coordination for parallel fail-fast across worker processes.

When behave runs with ``--parallel=N``, each worker is a separate process
with its own ``PriorityState``. This module provides a ``ParallelCoordinator``
that uses a shared directory with one JSON file per worker to track global
failure counts and critical failures.

Each worker writes its own file atomically (temp file + rename). To check
global state, all worker files in the coordination directory are read and
aggregated. This avoids the need for cross-process file locking.

Usage::

    # Before running behave with --parallel, set env var:
    #   BEHAVE_PRIORITY_COORD_DIR=/tmp/behave_priority_coord

    # In environment.py:
    setup_priority(context, stop_after_failures=3, parallel_coord=True)
"""

from __future__ import annotations

import contextlib
import json
import os
from pathlib import Path

_COORD_DIR_ENV = "BEHAVE_PRIORITY_COORD_DIR"


[docs] class ParallelCoordinator: """File-based coordination for parallel workers. Each worker creates and maintains its own JSON file in a shared coordination directory. The file contains the worker's failure count and critical-failure flag. Global state is computed by reading all worker files in the directory. Attributes: coord_dir: Directory where worker files are stored. worker_id: Unique identifier for this worker (defaults to PID). """ def __init__(self, coord_dir: str | Path, worker_id: str | None = None) -> None: """Initialize the coordinator and create the worker file. If a stale file from a previous run exists (e.g. a worker that crashed without cleanup), it is overwritten with fresh state. Args: coord_dir: Path to the shared coordination directory. Will be created if it does not exist. worker_id: Unique identifier for this worker. If None, uses the process PID. """ self.coord_dir = Path(coord_dir) self.coord_dir.mkdir(parents=True, exist_ok=True) self.worker_id = worker_id or str(os.getpid()) self._worker_file = self.coord_dir / f"worker_{self.worker_id}.json" self._failed_count = 0 self._critical_failed = False self._write_state()
[docs] def report_failure(self, *, is_critical: bool = False) -> None: """Record a failure for this worker. Atomically updates the worker's file with the new failure count. Args: is_critical: Whether the failure was in a critical scenario. """ self._failed_count += 1 if is_critical: self._critical_failed = True self._write_state()
[docs] def should_stop( self, stop_after_failures: int | None, stop_on_critical: bool, ) -> bool: """Check if global fail-fast conditions have been met. Reads all worker files in the coordination directory and aggregates failure counts and critical-failure flags. Args: stop_after_failures: Global failure threshold, or None. stop_on_critical: Whether to stop on any critical failure. Returns: True if global conditions indicate execution should stop. """ global_failed = 0 global_critical = False for f in self.coord_dir.glob("worker_*.json"): try: data = json.loads(f.read_text(encoding="utf-8")) global_failed += data.get("failed_count", 0) if data.get("critical_failed", False): global_critical = True except (json.JSONDecodeError, OSError): continue if stop_after_failures is not None and global_failed >= stop_after_failures: return True return stop_on_critical and global_critical
[docs] def cleanup(self) -> None: """Remove this worker's file from the coordination directory.""" with contextlib.suppress(OSError): self._worker_file.unlink(missing_ok=True)
def _write_state(self) -> None: """Atomically write this worker's state to its file.""" data = { "failed_count": self._failed_count, "critical_failed": self._critical_failed, } tmp = self._worker_file.with_suffix(".tmp") tmp.write_text(json.dumps(data), encoding="utf-8") tmp.replace(self._worker_file)
[docs] def get_coord_dir() -> str | None: """Get the coordination directory from the environment variable. Returns: The path from ``BEHAVE_PRIORITY_COORD_DIR`` env var, or None. """ return os.environ.get(_COORD_DIR_ENV)
[docs] def create_coordinator(worker_id: str | None = None) -> ParallelCoordinator | None: """Create a ParallelCoordinator from the env var, if set. Args: worker_id: Optional worker identifier. Defaults to PID. Returns: A ``ParallelCoordinator`` if ``BEHAVE_PRIORITY_COORD_DIR`` is set, otherwise None. """ coord_dir = get_coord_dir() if coord_dir is None: return None return ParallelCoordinator(coord_dir, worker_id=worker_id)
[docs] def cleanup_coordinator(context: object) -> None: """Clean up the parallel coordinator for this worker. Intended for use in ``after_all``. Removes the worker's file from the coordination directory. Args: context: Behave's context object. """ coordinator: ParallelCoordinator | None = getattr( context, "_priority_coordinator", None ) if coordinator is not None: coordinator.cleanup()