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.
@@ -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
@@ -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]