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.
- behave_priority/__init__.py +59 -0
- behave_priority/config.py +71 -0
- behave_priority/exceptions.py +15 -0
- behave_priority/hooks.py +390 -0
- behave_priority/parallel.py +164 -0
- behave_priority/parser.py +132 -0
- behave_priority/report.py +398 -0
- behave_priority/sorter.py +249 -0
- behave_priority-1.0.0.dist-info/METADATA +325 -0
- behave_priority-1.0.0.dist-info/RECORD +12 -0
- behave_priority-1.0.0.dist-info/WHEEL +4 -0
- behave_priority-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
"""
|
behave_priority/hooks.py
ADDED
|
@@ -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()
|