proofrun 0.1.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.
- proofrun/__init__.py +10 -0
- proofrun/__main__.py +9 -0
- proofrun/cli.py +325 -0
- proofrun/core/__init__.py +40 -0
- proofrun/core/compiler.py +136 -0
- proofrun/core/config.py +150 -0
- proofrun/core/context.py +185 -0
- proofrun/core/engine.py +231 -0
- proofrun/core/notifier.py +75 -0
- proofrun/core/provider.py +55 -0
- proofrun/core/state.py +81 -0
- proofrun-0.1.1.dist-info/METADATA +268 -0
- proofrun-0.1.1.dist-info/RECORD +16 -0
- proofrun-0.1.1.dist-info/WHEEL +4 -0
- proofrun-0.1.1.dist-info/entry_points.txt +2 -0
- proofrun-0.1.1.dist-info/licenses/LICENSE +21 -0
proofrun/core/context.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Execution context and dynamic variable interpolation module for Proofrun.
|
|
2
|
+
|
|
3
|
+
This module manages runtime state persistence across scenario steps, JSONPath
|
|
4
|
+
variable extraction from API responses, nested payload interpolation, and
|
|
5
|
+
built-in dynamic generators.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import random
|
|
9
|
+
import re
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from jsonpath_ng import parse
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ContextError(Exception):
|
|
18
|
+
"""Raised when variable extraction or interpolation fails."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ExecutionContext:
|
|
22
|
+
"""Manages runtime state and dynamic variable interpolation."""
|
|
23
|
+
|
|
24
|
+
VAR_PATTERN = re.compile(r"\$\{([a-zA-Z0-9_.$]+)\}")
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
initial_vars: dict[str, Any] | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Initialize the execution context.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
initial_vars: Optional initial variables. The dictionary is copied.
|
|
34
|
+
"""
|
|
35
|
+
self.variables: dict[str, Any] = (
|
|
36
|
+
dict(initial_vars) if initial_vars is not None else {}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def set(self, key: str, value: Any) -> None:
|
|
40
|
+
"""Set a runtime variable."""
|
|
41
|
+
self.variables[key] = value
|
|
42
|
+
|
|
43
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
44
|
+
"""Get a runtime variable."""
|
|
45
|
+
return self.variables.get(key, default)
|
|
46
|
+
|
|
47
|
+
def _get_dynamic_variable(self, var_name: str) -> Any | None:
|
|
48
|
+
"""Resolve a built-in dynamic variable.
|
|
49
|
+
|
|
50
|
+
Note: These generate a new value every time they are called. Multiple
|
|
51
|
+
references to ${$uuid} in a single payload will yield different UUIDs.
|
|
52
|
+
"""
|
|
53
|
+
if var_name == "$uuid":
|
|
54
|
+
return str(uuid.uuid4())
|
|
55
|
+
|
|
56
|
+
if var_name == "$timestamp":
|
|
57
|
+
return int(time.time())
|
|
58
|
+
|
|
59
|
+
if var_name == "$timestamp_ms":
|
|
60
|
+
return int(time.time() * 1000)
|
|
61
|
+
|
|
62
|
+
if var_name == "$iso_timestamp":
|
|
63
|
+
return time.strftime(
|
|
64
|
+
"%Y-%m-%dT%H:%M:%SZ",
|
|
65
|
+
time.gmtime(),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if var_name == "$random_int":
|
|
69
|
+
return random.randint(1000, 9999)
|
|
70
|
+
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
def extract_variables(
|
|
74
|
+
self,
|
|
75
|
+
payload: dict[str, Any] | list[Any],
|
|
76
|
+
rules: dict[str, str],
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Extract variables from a JSON payload using JSONPath.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
payload: JSON response body.
|
|
82
|
+
rules: Mapping of variable names to JSONPath expressions.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
ContextError: If a JSONPath expression is invalid or produces
|
|
86
|
+
no matches.
|
|
87
|
+
"""
|
|
88
|
+
for var_name, jsonpath_expr in rules.items():
|
|
89
|
+
try:
|
|
90
|
+
expression = parse(jsonpath_expr)
|
|
91
|
+
matches = [match.value for match in expression.find(payload)]
|
|
92
|
+
except Exception as err:
|
|
93
|
+
raise ContextError(
|
|
94
|
+
f"Invalid JSONPath expression "
|
|
95
|
+
f"'{jsonpath_expr}' for variable "
|
|
96
|
+
f"'{var_name}': {err}"
|
|
97
|
+
) from err
|
|
98
|
+
|
|
99
|
+
if not matches:
|
|
100
|
+
raise ContextError(
|
|
101
|
+
f"JSONPath extraction failed: Path "
|
|
102
|
+
f"'{jsonpath_expr}' did not match any value "
|
|
103
|
+
"in response."
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
self.variables[var_name] = matches[0]
|
|
107
|
+
|
|
108
|
+
def interpolate_string(self, text: str) -> Any:
|
|
109
|
+
"""Interpolate variables inside a string.
|
|
110
|
+
|
|
111
|
+
An exact variable reference preserves its original type.
|
|
112
|
+
|
|
113
|
+
For example:
|
|
114
|
+
|
|
115
|
+
"${user_id}"
|
|
116
|
+
|
|
117
|
+
can return:
|
|
118
|
+
|
|
119
|
+
123
|
|
120
|
+
|
|
121
|
+
while:
|
|
122
|
+
|
|
123
|
+
"User ${user_id}"
|
|
124
|
+
|
|
125
|
+
returns:
|
|
126
|
+
|
|
127
|
+
"User 123"
|
|
128
|
+
"""
|
|
129
|
+
exact_match = self.VAR_PATTERN.fullmatch(text.strip())
|
|
130
|
+
|
|
131
|
+
if exact_match:
|
|
132
|
+
var_name = exact_match.group(1)
|
|
133
|
+
|
|
134
|
+
if var_name.startswith("$"):
|
|
135
|
+
dynamic_value = self._get_dynamic_variable(var_name)
|
|
136
|
+
|
|
137
|
+
if dynamic_value is not None:
|
|
138
|
+
return dynamic_value
|
|
139
|
+
|
|
140
|
+
if var_name not in self.variables:
|
|
141
|
+
raise ContextError(
|
|
142
|
+
f"Missing required context variable: '${{{var_name}}}'"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
return self.variables[var_name]
|
|
146
|
+
|
|
147
|
+
def replace_var(match: re.Match[str]) -> str:
|
|
148
|
+
var_name = match.group(1)
|
|
149
|
+
|
|
150
|
+
if var_name.startswith("$"):
|
|
151
|
+
dynamic_value = self._get_dynamic_variable(var_name)
|
|
152
|
+
|
|
153
|
+
if dynamic_value is not None:
|
|
154
|
+
return str(dynamic_value)
|
|
155
|
+
|
|
156
|
+
if var_name not in self.variables:
|
|
157
|
+
raise ContextError(
|
|
158
|
+
f"Missing required context variable: '${{{var_name}}}'"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return str(self.variables[var_name])
|
|
162
|
+
|
|
163
|
+
return self.VAR_PATTERN.sub(replace_var, text)
|
|
164
|
+
|
|
165
|
+
def interpolate_data(self, data: Any) -> Any:
|
|
166
|
+
"""Recursively interpolate strings inside nested request data.
|
|
167
|
+
|
|
168
|
+
This will interpolate strings found in dictionary values,
|
|
169
|
+
dictionary keys, and list items.
|
|
170
|
+
"""
|
|
171
|
+
if isinstance(data, str):
|
|
172
|
+
return self.interpolate_string(data)
|
|
173
|
+
|
|
174
|
+
if isinstance(data, dict):
|
|
175
|
+
return {
|
|
176
|
+
(
|
|
177
|
+
self.interpolate_string(key) if isinstance(key, str) else key
|
|
178
|
+
): self.interpolate_data(value)
|
|
179
|
+
for key, value in data.items()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if isinstance(data, list):
|
|
183
|
+
return [self.interpolate_data(item) for item in data]
|
|
184
|
+
|
|
185
|
+
return data
|
proofrun/core/engine.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Asynchronous HTTP execution engine for Proofrun synthetic monitoring.
|
|
2
|
+
|
|
3
|
+
This module executes multi-step HTTP monitoring scenarios using httpx,
|
|
4
|
+
evaluates response assertions, extracts runtime variables into
|
|
5
|
+
ExecutionContext, and records high-resolution latency metrics.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from time import perf_counter
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from proofrun.core.config import ScenarioConfig, StepConfig
|
|
15
|
+
from proofrun.core.context import ExecutionContext
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EngineError(Exception):
|
|
19
|
+
"""Raised when HTTP step execution or assertion evaluation fails."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class StepResult:
|
|
24
|
+
"""Execution outcome and performance metrics for one scenario step."""
|
|
25
|
+
|
|
26
|
+
step_name: str
|
|
27
|
+
passed: bool
|
|
28
|
+
status_code: int
|
|
29
|
+
latency_ms: float
|
|
30
|
+
error: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ScenarioResult:
|
|
35
|
+
"""Aggregated execution outcome for a complete scenario."""
|
|
36
|
+
|
|
37
|
+
scenario_name: str
|
|
38
|
+
passed: bool
|
|
39
|
+
total_latency_ms: float
|
|
40
|
+
step_results: list[StepResult] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ScenarioRunner:
|
|
44
|
+
"""Execute multi-step HTTP monitoring scenarios asynchronously."""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
scenario: ScenarioConfig,
|
|
49
|
+
context: ExecutionContext | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Initialize a scenario runner.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
scenario: Compiled scenario configuration.
|
|
55
|
+
context: Optional execution context shared across steps.
|
|
56
|
+
"""
|
|
57
|
+
self.scenario = scenario
|
|
58
|
+
self.context = context if context is not None else ExecutionContext()
|
|
59
|
+
|
|
60
|
+
async def execute_step(
|
|
61
|
+
self,
|
|
62
|
+
step: StepConfig,
|
|
63
|
+
client: httpx.AsyncClient,
|
|
64
|
+
) -> StepResult:
|
|
65
|
+
"""Execute and validate a single scenario step.
|
|
66
|
+
|
|
67
|
+
The execution pipeline is:
|
|
68
|
+
|
|
69
|
+
1. Validate the scenario base URL.
|
|
70
|
+
2. Validate the resolved request timeout.
|
|
71
|
+
3. Interpolate path, headers, query parameters, and JSON payload.
|
|
72
|
+
4. Execute the HTTP request.
|
|
73
|
+
5. Validate the HTTP status code(s).
|
|
74
|
+
6. Validate the latency ceiling.
|
|
75
|
+
7. Validate expected response headers.
|
|
76
|
+
8. Parse JSON when schema validation or extraction is required.
|
|
77
|
+
9. Validate the pre-compiled JSON schema.
|
|
78
|
+
10. Extract runtime variables for subsequent steps.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
step: Individual scenario step configuration.
|
|
82
|
+
client: Shared asynchronous HTTP client.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
A StepResult describing success/failure and timing.
|
|
86
|
+
"""
|
|
87
|
+
start_time = perf_counter()
|
|
88
|
+
response: httpx.Response | None = None
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
if not self.scenario.base_url:
|
|
92
|
+
raise EngineError(
|
|
93
|
+
f"Scenario '{self.scenario.name}' is missing a valid base_url."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if step.request.timeout is None:
|
|
97
|
+
raise EngineError(
|
|
98
|
+
f"Step '{step.name}' is missing a resolved request timeout."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
interpolated_path = self.context.interpolate_string(step.request.path)
|
|
102
|
+
|
|
103
|
+
base_url = str(self.scenario.base_url).rstrip("/")
|
|
104
|
+
full_url = f"{base_url}/{str(interpolated_path).lstrip('/')}"
|
|
105
|
+
|
|
106
|
+
headers = self.context.interpolate_data(step.request.headers)
|
|
107
|
+
params = self.context.interpolate_data(step.request.params)
|
|
108
|
+
json_payload = self.context.interpolate_data(step.request.json_payload)
|
|
109
|
+
|
|
110
|
+
response = await client.request(
|
|
111
|
+
method=step.request.method,
|
|
112
|
+
url=full_url,
|
|
113
|
+
headers=headers,
|
|
114
|
+
params=params,
|
|
115
|
+
json=json_payload,
|
|
116
|
+
timeout=step.request.timeout,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
latency_ms = (perf_counter() - start_time) * 1000
|
|
120
|
+
|
|
121
|
+
expected_statuses = (
|
|
122
|
+
[step.expect.status]
|
|
123
|
+
if isinstance(step.expect.status, int)
|
|
124
|
+
else step.expect.status
|
|
125
|
+
)
|
|
126
|
+
if response.status_code not in expected_statuses:
|
|
127
|
+
raise EngineError(
|
|
128
|
+
f"Status code mismatch: expected one of "
|
|
129
|
+
f"{expected_statuses}, got {response.status_code}"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if (
|
|
133
|
+
step.expect.max_latency_ms is not None
|
|
134
|
+
and latency_ms > step.expect.max_latency_ms
|
|
135
|
+
):
|
|
136
|
+
raise EngineError(
|
|
137
|
+
f"Latency limit exceeded: response took "
|
|
138
|
+
f"{latency_ms:.2f} ms "
|
|
139
|
+
f"(max allowed: "
|
|
140
|
+
f"{step.expect.max_latency_ms} ms)"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
for expected_header, expected_value in step.expect.headers.items():
|
|
144
|
+
actual_value = response.headers.get(expected_header)
|
|
145
|
+
|
|
146
|
+
if actual_value != expected_value:
|
|
147
|
+
raise EngineError(
|
|
148
|
+
f"Header '{expected_header}' mismatch: expected "
|
|
149
|
+
f"'{expected_value}', got '{actual_value}'"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
response_data: Any = None
|
|
153
|
+
|
|
154
|
+
if step.expect._compiled_schema is not None or step.extract:
|
|
155
|
+
try:
|
|
156
|
+
response_data = response.json()
|
|
157
|
+
except Exception as err:
|
|
158
|
+
raise EngineError(
|
|
159
|
+
f"Response body is not valid JSON: {err}"
|
|
160
|
+
) from err
|
|
161
|
+
|
|
162
|
+
if step.expect._compiled_schema is not None:
|
|
163
|
+
try:
|
|
164
|
+
step.expect._compiled_schema(response_data)
|
|
165
|
+
except Exception as err:
|
|
166
|
+
raise EngineError(f"Schema assertion failed: {err}") from err
|
|
167
|
+
|
|
168
|
+
if step.extract:
|
|
169
|
+
self.context.extract_variables(
|
|
170
|
+
response_data,
|
|
171
|
+
step.extract,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return StepResult(
|
|
175
|
+
step_name=step.name,
|
|
176
|
+
passed=True,
|
|
177
|
+
status_code=response.status_code,
|
|
178
|
+
latency_ms=round(latency_ms, 2),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
except Exception as err: # noqa: BLE001
|
|
182
|
+
latency_ms = (perf_counter() - start_time) * 1000
|
|
183
|
+
|
|
184
|
+
status_code = response.status_code if response is not None else 0
|
|
185
|
+
|
|
186
|
+
return StepResult(
|
|
187
|
+
step_name=step.name,
|
|
188
|
+
passed=False,
|
|
189
|
+
status_code=status_code,
|
|
190
|
+
latency_ms=round(latency_ms, 2),
|
|
191
|
+
error=str(err),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
async def run(self, client: httpx.AsyncClient) -> ScenarioResult:
|
|
195
|
+
"""Execute all scenario steps sequentially.
|
|
196
|
+
|
|
197
|
+
All steps share the same ExecutionContext, allowing values extracted
|
|
198
|
+
from earlier responses to be used by later requests.
|
|
199
|
+
|
|
200
|
+
Execution stops at the first failed step.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
client: Shared asynchronous HTTP client for connection pooling.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
ScenarioResult containing the aggregate scenario outcome,
|
|
207
|
+
total execution latency, and individual step results.
|
|
208
|
+
"""
|
|
209
|
+
step_results: list[StepResult] = []
|
|
210
|
+
scenario_passed = True
|
|
211
|
+
|
|
212
|
+
total_start = perf_counter()
|
|
213
|
+
|
|
214
|
+
client.cookies.clear()
|
|
215
|
+
|
|
216
|
+
for step in self.scenario.steps:
|
|
217
|
+
result = await self.execute_step(step, client)
|
|
218
|
+
step_results.append(result)
|
|
219
|
+
|
|
220
|
+
if not result.passed:
|
|
221
|
+
scenario_passed = False
|
|
222
|
+
break
|
|
223
|
+
|
|
224
|
+
total_latency_ms = (perf_counter() - total_start) * 1000
|
|
225
|
+
|
|
226
|
+
return ScenarioResult(
|
|
227
|
+
scenario_name=self.scenario.name,
|
|
228
|
+
passed=scenario_passed,
|
|
229
|
+
total_latency_ms=round(total_latency_ms, 2),
|
|
230
|
+
step_results=step_results,
|
|
231
|
+
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Notification abstractions and generic webhook notification delivery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from proofrun.core.state import Status
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class NotificationEvent:
|
|
18
|
+
"""Data payload for a scenario health-state transition notification."""
|
|
19
|
+
|
|
20
|
+
scenario_name: str
|
|
21
|
+
old_status: Status
|
|
22
|
+
new_status: Status
|
|
23
|
+
details: str = ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Notifier(Protocol):
|
|
27
|
+
"""Interface for delivering notification events."""
|
|
28
|
+
|
|
29
|
+
async def notify(self, event: NotificationEvent) -> None:
|
|
30
|
+
"""Deliver a notification event."""
|
|
31
|
+
... # pragma: no cover
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class WebhookNotifier:
|
|
35
|
+
"""Deliver notification events to a generic HTTP webhook."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
client: httpx.AsyncClient,
|
|
40
|
+
webhook_url: str,
|
|
41
|
+
) -> None:
|
|
42
|
+
self.client = client
|
|
43
|
+
self.webhook_url = webhook_url
|
|
44
|
+
|
|
45
|
+
async def notify(self, event: NotificationEvent) -> None:
|
|
46
|
+
"""Send a notification event as provider-neutral JSON."""
|
|
47
|
+
if event.new_status == Status.HEALTHY:
|
|
48
|
+
event_type = "scenario_recovered"
|
|
49
|
+
elif event.new_status == Status.FAILING:
|
|
50
|
+
event_type = "scenario_failed"
|
|
51
|
+
else:
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
payload = {
|
|
55
|
+
"event": event_type,
|
|
56
|
+
"scenario": event.scenario_name,
|
|
57
|
+
"previous_status": event.old_status.value,
|
|
58
|
+
"status": event.new_status.value,
|
|
59
|
+
"details": event.details,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
response = await self.client.post(
|
|
64
|
+
self.webhook_url,
|
|
65
|
+
json=payload,
|
|
66
|
+
timeout=5.0,
|
|
67
|
+
)
|
|
68
|
+
response.raise_for_status()
|
|
69
|
+
|
|
70
|
+
console.print(
|
|
71
|
+
f"[dim]Dispatched webhook alert for: {event.scenario_name}[/dim]"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
except httpx.HTTPError as err:
|
|
75
|
+
console.print(f"[bold red]Failed to send webhook:[/bold red] {err}")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Configuration loading and caching for Proofrun."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from proofrun.core.compiler import (
|
|
9
|
+
ConfigCompilerError,
|
|
10
|
+
load_and_compile_config,
|
|
11
|
+
)
|
|
12
|
+
from proofrun.core.config import ProofrunConfig
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ConfigProvider:
|
|
17
|
+
"""Manage a cached, compiled Proofrun configuration."""
|
|
18
|
+
|
|
19
|
+
config_path: Path
|
|
20
|
+
_config: ProofrunConfig | None = None
|
|
21
|
+
_last_modified_ns: int | None = None
|
|
22
|
+
|
|
23
|
+
def load(self) -> tuple[ProofrunConfig | None, ConfigCompilerError | None]:
|
|
24
|
+
"""Load the configuration when needed.
|
|
25
|
+
|
|
26
|
+
The configuration is compiled on the first call and whenever the
|
|
27
|
+
configuration file modification time changes.
|
|
28
|
+
|
|
29
|
+
If a changed configuration is invalid, the last known-good
|
|
30
|
+
configuration is retained and the compiler error is returned.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
A tuple containing the current configuration and an optional
|
|
34
|
+
compilation error.
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
modified_ns = self.config_path.stat().st_mtime_ns
|
|
38
|
+
except OSError as err:
|
|
39
|
+
error = ConfigCompilerError(
|
|
40
|
+
f"Unable to access configuration file '{self.config_path}': {err}"
|
|
41
|
+
)
|
|
42
|
+
return self._config, error
|
|
43
|
+
|
|
44
|
+
if self._config is not None and self._last_modified_ns == modified_ns:
|
|
45
|
+
return self._config, None
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
config = load_and_compile_config(self.config_path)
|
|
49
|
+
except ConfigCompilerError as err:
|
|
50
|
+
return self._config, err
|
|
51
|
+
|
|
52
|
+
self._config = config
|
|
53
|
+
self._last_modified_ns = modified_ns
|
|
54
|
+
|
|
55
|
+
return self._config, None
|
proofrun/core/state.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""State management for tracking scenario health transitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Status(Enum):
|
|
10
|
+
"""The health status of a scenario."""
|
|
11
|
+
|
|
12
|
+
UNKNOWN = "UNKNOWN"
|
|
13
|
+
HEALTHY = "HEALTHY"
|
|
14
|
+
FAILING = "FAILING"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ScenarioState:
|
|
19
|
+
"""Tracks the current state of a single scenario."""
|
|
20
|
+
|
|
21
|
+
status: Status = Status.UNKNOWN
|
|
22
|
+
fail_count: int = 0
|
|
23
|
+
pass_count: int = 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class StateTransition:
|
|
28
|
+
"""Describes a scenario health-state transition."""
|
|
29
|
+
|
|
30
|
+
scenario_name: str
|
|
31
|
+
old_status: Status
|
|
32
|
+
new_status: Status
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def changed(self) -> bool:
|
|
36
|
+
"""Return whether the scenario changed health state."""
|
|
37
|
+
return self.old_status != self.new_status
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class StateManager:
|
|
41
|
+
"""Manages state across all scenarios for alerting purposes."""
|
|
42
|
+
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
self.states: dict[str, ScenarioState] = {}
|
|
45
|
+
|
|
46
|
+
def update(
|
|
47
|
+
self,
|
|
48
|
+
scenario_name: str,
|
|
49
|
+
passed: bool,
|
|
50
|
+
) -> StateTransition:
|
|
51
|
+
"""Update scenario state and return the resulting transition."""
|
|
52
|
+
if scenario_name not in self.states:
|
|
53
|
+
self.states[scenario_name] = ScenarioState()
|
|
54
|
+
|
|
55
|
+
state = self.states[scenario_name]
|
|
56
|
+
old_status = state.status
|
|
57
|
+
|
|
58
|
+
if passed:
|
|
59
|
+
state.status = Status.HEALTHY
|
|
60
|
+
state.pass_count += 1
|
|
61
|
+
state.fail_count = 0
|
|
62
|
+
else:
|
|
63
|
+
state.status = Status.FAILING
|
|
64
|
+
state.fail_count += 1
|
|
65
|
+
state.pass_count = 0
|
|
66
|
+
|
|
67
|
+
return StateTransition(
|
|
68
|
+
scenario_name=scenario_name,
|
|
69
|
+
old_status=old_status,
|
|
70
|
+
new_status=state.status,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def sync(self, active_scenario_names: set[str]) -> None:
|
|
74
|
+
"""Remove state for scenarios that no longer exist in the configuration.
|
|
75
|
+
|
|
76
|
+
Call this periodically or after a configuration hot-reload to prevent
|
|
77
|
+
memory leaks from deleted scenarios.
|
|
78
|
+
"""
|
|
79
|
+
stale_keys = set(self.states.keys()) - active_scenario_names
|
|
80
|
+
for key in stale_keys:
|
|
81
|
+
del self.states[key]
|