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 ADDED
@@ -0,0 +1,10 @@
1
+ """Proofrun synthetic API monitoring package."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("proofrun")
7
+ except PackageNotFoundError: # pragma: no cover
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = ["__version__"]
proofrun/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Entry point for the Proofrun CLI.
2
+
3
+ Allows the package to be executed via `python -m proofrun`.
4
+ """
5
+
6
+ from proofrun.cli import main
7
+
8
+ if __name__ == "__main__":
9
+ main()
proofrun/cli.py ADDED
@@ -0,0 +1,325 @@
1
+ """Command Line Interface for Proofrun synthetic API monitoring daemon.
2
+
3
+ Provides terminal rendering and workflow orchestration for running synthetic
4
+ monitoring scenarios using Typer and Rich.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import random
11
+ from pathlib import Path
12
+ from typing import Annotated
13
+
14
+ import httpx
15
+ import typer
16
+ import uvloop
17
+ from rich.console import Console
18
+ from rich.panel import Panel
19
+ from rich.table import Table
20
+ from rich.text import Text
21
+
22
+ from proofrun import __version__
23
+ from proofrun.core.engine import ScenarioResult, ScenarioRunner
24
+ from proofrun.core.notifier import (
25
+ NotificationEvent,
26
+ Notifier,
27
+ WebhookNotifier,
28
+ )
29
+ from proofrun.core.provider import ConfigProvider
30
+ from proofrun.core.state import StateManager, Status
31
+
32
+ app = typer.Typer(
33
+ name="proofrun",
34
+ help="Synthetic API monitoring as code daemon.",
35
+ add_completion=False,
36
+ )
37
+
38
+ console = Console()
39
+
40
+
41
+ def version_callback(value: bool) -> None:
42
+ """Print the Proofrun version and exit."""
43
+ if value:
44
+ console.print(
45
+ f"[bold cyan]Proofrun[/bold cyan] "
46
+ f"version [bold green]{__version__}[/bold green]"
47
+ )
48
+ raise typer.Exit(code=0)
49
+
50
+
51
+ def render_scenario_result(result: ScenarioResult) -> None:
52
+ """Render a scenario result using Rich."""
53
+ status_style = "bold green" if result.passed else "bold red"
54
+ status_text = "PASSED" if result.passed else "FAILED"
55
+
56
+ table = Table(
57
+ title=f"Scenario: [bold white]{result.scenario_name}[/bold white]",
58
+ title_style="bold cyan",
59
+ show_header=True,
60
+ header_style="bold magenta",
61
+ expand=True,
62
+ )
63
+
64
+ table.add_column("Step Name", style="dim", min_width=25)
65
+ table.add_column("Status", justify="center", width=10)
66
+ table.add_column("HTTP Code", justify="right", width=10)
67
+ table.add_column("Latency", justify="right", width=12)
68
+ table.add_column("Error Details", style="red", min_width=20)
69
+
70
+ for step in result.step_results:
71
+ step_status = (
72
+ "[bold green]PASS[/bold green]"
73
+ if step.passed
74
+ else "[bold red]FAIL[/bold red]"
75
+ )
76
+
77
+ code_style = "green" if step.passed else "red"
78
+ code_str = f"[{code_style}]{step.status_code}[/{code_style}]"
79
+ latency_str = f"{step.latency_ms:.2f} ms"
80
+ error_str = step.error or "-"
81
+
82
+ table.add_row(
83
+ step.step_name,
84
+ step_status,
85
+ code_str,
86
+ latency_str,
87
+ error_str,
88
+ )
89
+
90
+ console.print(table)
91
+
92
+ summary_text = Text()
93
+ summary_text.append("Status: ", style="bold")
94
+ summary_text.append(status_text, style=status_style)
95
+ summary_text.append(" Total Latency: ", style="bold")
96
+ summary_text.append(
97
+ f"{result.total_latency_ms:.2f} ms",
98
+ style="cyan",
99
+ )
100
+
101
+ panel = Panel(
102
+ summary_text,
103
+ title="[bold]Summary[/bold]",
104
+ border_style="green" if result.passed else "red",
105
+ expand=False,
106
+ )
107
+
108
+ console.print(panel)
109
+ console.print()
110
+
111
+
112
+ async def execute_config(
113
+ config_provider: ConfigProvider,
114
+ client: httpx.AsyncClient,
115
+ state_manager: StateManager | None = None,
116
+ notifier: Notifier | None = None,
117
+ ) -> tuple[bool, int]:
118
+ """Load and execute all scenarios concurrently.
119
+
120
+ Args:
121
+ config_provider: Cached configuration provider.
122
+ client: Reusable HTTP client for connection pooling.
123
+ state_manager: Tracks historical health across execution cycles.
124
+ notifier: Optional notification destination for state transitions.
125
+
126
+ Returns:
127
+ A tuple containing (all_passed, smallest_configured_interval).
128
+ """
129
+ proofrun_config, config_error = config_provider.load()
130
+
131
+ if config_error is not None:
132
+ console.print(f"[bold red]Configuration Error:[/bold red] {config_error}")
133
+
134
+ if proofrun_config is None:
135
+ return False, 60
136
+
137
+ console.print("[yellow]Using last known-good configuration.[/yellow]")
138
+
139
+ if proofrun_config is None:
140
+ console.print("[bold red]No valid configuration is available.[/bold red]")
141
+ return False, 60
142
+
143
+ if state_manager is not None:
144
+ active_scenario_names = {s.name for s in proofrun_config.scenarios}
145
+ state_manager.sync(active_scenario_names)
146
+
147
+ runners = [ScenarioRunner(scenario) for scenario in proofrun_config.scenarios]
148
+ tasks = [runner.run(client) for runner in runners]
149
+
150
+ results = await asyncio.gather(*tasks)
151
+
152
+ all_passed = True
153
+ scenario_intervals: list[int] = []
154
+
155
+ for result, scenario in zip(
156
+ results,
157
+ proofrun_config.scenarios,
158
+ strict=True,
159
+ ):
160
+ scenario_intervals.append(scenario.interval_seconds)
161
+
162
+ render_scenario_result(result)
163
+
164
+ if not result.passed:
165
+ all_passed = False
166
+
167
+ if state_manager is None:
168
+ continue
169
+
170
+ transition = state_manager.update(
171
+ scenario.name,
172
+ result.passed,
173
+ )
174
+
175
+ if notifier is None or not transition.changed:
176
+ continue
177
+
178
+ if transition.new_status == Status.FAILING:
179
+ errors = [
180
+ f"- {step.step_name}: {step.error}"
181
+ for step in result.step_results
182
+ if not step.passed
183
+ ]
184
+
185
+ event = NotificationEvent(
186
+ scenario_name=transition.scenario_name,
187
+ old_status=transition.old_status,
188
+ new_status=transition.new_status,
189
+ details="\n".join(errors),
190
+ )
191
+
192
+ await notifier.notify(event)
193
+
194
+ elif (
195
+ transition.old_status == Status.FAILING
196
+ and transition.new_status == Status.HEALTHY
197
+ ):
198
+ event = NotificationEvent(
199
+ scenario_name=transition.scenario_name,
200
+ old_status=transition.old_status,
201
+ new_status=transition.new_status,
202
+ details="All steps passing.",
203
+ )
204
+
205
+ await notifier.notify(event)
206
+
207
+ interval = min(scenario_intervals) if scenario_intervals else 60
208
+
209
+ return all_passed, interval
210
+
211
+
212
+ @app.command()
213
+ def run_command(
214
+ config_path: Annotated[
215
+ Path,
216
+ typer.Argument(
217
+ exists=True,
218
+ file_okay=True,
219
+ dir_okay=False,
220
+ readable=True,
221
+ help="Path to YAML scenario configuration file.",
222
+ ),
223
+ ],
224
+ once: Annotated[
225
+ bool,
226
+ typer.Option(
227
+ "--once/--daemon",
228
+ help="Run scenarios once and exit, or loop continuously in daemon mode.",
229
+ ),
230
+ ] = True,
231
+ interval: Annotated[
232
+ int | None,
233
+ typer.Option(
234
+ "--interval",
235
+ "-i",
236
+ min=1,
237
+ help="Override configured scenario interval in seconds for daemon mode.",
238
+ ),
239
+ ] = None,
240
+ webhook_url: Annotated[
241
+ str | None,
242
+ typer.Option(
243
+ "--webhook-url",
244
+ envvar="PROOF_WEBHOOK_URL",
245
+ help="HTTP webhook URL for state transition alerts.",
246
+ ),
247
+ ] = None,
248
+ _show_version: Annotated[
249
+ bool | None,
250
+ typer.Option(
251
+ "--version",
252
+ "-v",
253
+ help="Show Proofrun version and exit.",
254
+ callback=version_callback,
255
+ is_eager=True,
256
+ ),
257
+ ] = None,
258
+ ) -> None:
259
+ """Execute synthetic monitoring scenarios defined in YAML."""
260
+ console.print(
261
+ f"[bold cyan]Proofrun[/bold cyan] executing config: "
262
+ f"[bold yellow]{config_path}[/bold yellow]\n"
263
+ )
264
+
265
+ async def _run_loop() -> int:
266
+ config_provider = ConfigProvider(config_path)
267
+ state_manager = StateManager()
268
+
269
+ async with httpx.AsyncClient() as client:
270
+ notifier: Notifier | None = None
271
+
272
+ if webhook_url is not None:
273
+ notifier = WebhookNotifier(
274
+ client=client,
275
+ webhook_url=webhook_url,
276
+ )
277
+
278
+ if once:
279
+ passed, _ = await execute_config(
280
+ config_provider,
281
+ client,
282
+ )
283
+
284
+ return 0 if passed else 1
285
+
286
+ console.print(
287
+ "[bold green]Starting daemon mode... "
288
+ "Press Ctrl+C to stop.[/bold green]\n"
289
+ )
290
+
291
+ while True:
292
+ _, config_interval = await execute_config(
293
+ config_provider,
294
+ client,
295
+ state_manager,
296
+ notifier,
297
+ )
298
+
299
+ base_sleep = interval if interval is not None else config_interval
300
+
301
+ sleep_time = base_sleep * random.uniform(
302
+ 0.85,
303
+ 1.15,
304
+ )
305
+
306
+ console.print(
307
+ f"[dim]Sleeping for {sleep_time:.1f}s "
308
+ "(incl. jitter) before next execution cycle..."
309
+ "[/dim]\n"
310
+ )
311
+
312
+ await asyncio.sleep(sleep_time)
313
+
314
+ try:
315
+ exit_code = uvloop.run(_run_loop())
316
+ except KeyboardInterrupt:
317
+ console.print("\n[yellow]Daemon execution stopped by user.[/yellow]")
318
+ raise typer.Exit(code=0) from None
319
+
320
+ raise typer.Exit(code=exit_code) # pragma: no cover
321
+
322
+
323
+ def main() -> None:
324
+ """Run the Proofrun CLI."""
325
+ app()
@@ -0,0 +1,40 @@
1
+ """Proofrun core package initialization."""
2
+
3
+ from proofrun.core.compiler import ConfigCompilerError, load_and_compile_config
4
+ from proofrun.core.config import (
5
+ ExpectConfig,
6
+ GlobalConfig,
7
+ ProofrunConfig,
8
+ RequestConfig,
9
+ ScenarioConfig,
10
+ StepConfig,
11
+ )
12
+ from proofrun.core.context import ExecutionContext
13
+ from proofrun.core.engine import EngineError, ScenarioResult, ScenarioRunner, StepResult
14
+ from proofrun.core.notifier import NotificationEvent, Notifier, WebhookNotifier
15
+ from proofrun.core.provider import ConfigProvider
16
+ from proofrun.core.state import ScenarioState, StateManager, StateTransition, Status
17
+
18
+ __all__ = [
19
+ "ExpectConfig",
20
+ "GlobalConfig",
21
+ "ProofrunConfig",
22
+ "RequestConfig",
23
+ "ScenarioConfig",
24
+ "StepConfig",
25
+ "ConfigCompilerError",
26
+ "ConfigProvider",
27
+ "load_and_compile_config",
28
+ "ExecutionContext",
29
+ "EngineError",
30
+ "ScenarioResult",
31
+ "ScenarioRunner",
32
+ "StepResult",
33
+ "ScenarioState",
34
+ "StateManager",
35
+ "StateTransition",
36
+ "Status",
37
+ "NotificationEvent",
38
+ "Notifier",
39
+ "WebhookNotifier",
40
+ ]
@@ -0,0 +1,136 @@
1
+ """AOT Schema Compiler for Proofrun scenario configurations.
2
+
3
+ This module loads YAML configuration, validates it using Pydantic, resolves
4
+ global defaults, and pre-compiles JSON schemas into executable validators.
5
+ """
6
+
7
+ import json
8
+ from collections.abc import Callable
9
+ from pathlib import Path
10
+ from typing import Any, cast
11
+
12
+ import fastjsonschema
13
+ import yaml
14
+ from pydantic import ValidationError
15
+
16
+ from proofrun.core.config import GlobalConfig, ProofrunConfig
17
+
18
+
19
+ class ConfigCompilerError(Exception):
20
+ """Raised when configuration loading or compilation fails."""
21
+
22
+
23
+ def load_and_compile_config(config_path: str | Path) -> ProofrunConfig:
24
+ """Load, validate, resolve, and compile a Proofrun configuration.
25
+
26
+ Args:
27
+ config_path: Path to the YAML configuration file.
28
+
29
+ Returns:
30
+ A validated and executable ProofrunConfig.
31
+
32
+ Raises:
33
+ ConfigCompilerError: If loading, validation, inheritance, or schema
34
+ compilation fails.
35
+ """
36
+ path = Path(config_path).resolve()
37
+
38
+ if not path.exists():
39
+ raise ConfigCompilerError(f"Configuration file not found: {path}")
40
+
41
+ try:
42
+ with path.open("r", encoding="utf-8") as file:
43
+ raw_data = yaml.safe_load(file)
44
+ except yaml.YAMLError as err:
45
+ raise ConfigCompilerError(f"Invalid YAML syntax in {path}: {err}") from err
46
+ except OSError as err:
47
+ raise ConfigCompilerError(
48
+ f"Failed to read configuration file {path}: {err}"
49
+ ) from err
50
+
51
+ if not isinstance(raw_data, dict):
52
+ raise ConfigCompilerError(
53
+ f"Invalid format in {path}. Expected a top-level YAML mapping."
54
+ )
55
+
56
+ try:
57
+ config = ProofrunConfig.model_validate(raw_data)
58
+ except ValidationError as err:
59
+ raise ConfigCompilerError(f"Config validation failed:\n{err}") from err
60
+
61
+ global_config = config.global_config or GlobalConfig()
62
+
63
+ global_base_url = global_config.base_url
64
+ global_timeout = global_config.timeout_seconds
65
+ global_headers = global_config.headers
66
+
67
+ for scenario in config.scenarios:
68
+ if scenario.base_url is None:
69
+ if global_base_url is None:
70
+ raise ConfigCompilerError(
71
+ f"Scenario '{scenario.name}' is missing 'base_url' "
72
+ "and no global 'base_url' is defined."
73
+ )
74
+
75
+ scenario.base_url = global_base_url
76
+
77
+ for step in scenario.steps:
78
+ if step.request.timeout is None:
79
+ step.request.timeout = global_timeout
80
+
81
+ step.request.headers = {
82
+ **global_headers,
83
+ **step.request.headers,
84
+ }
85
+
86
+ if step.expect.schema_data is None:
87
+ continue
88
+
89
+ raw_schema: dict[str, Any] | None = None
90
+
91
+ if isinstance(step.expect.schema_data, dict):
92
+ raw_schema = step.expect.schema_data
93
+
94
+ elif isinstance(step.expect.schema_data, str):
95
+ schema_file = path.parent / step.expect.schema_data
96
+
97
+ if not schema_file.exists():
98
+ schema_file = Path(step.expect.schema_data)
99
+
100
+ if not schema_file.exists():
101
+ raise ConfigCompilerError(
102
+ f"JSON Schema file not found: "
103
+ f"'{step.expect.schema_data}' "
104
+ f"(referenced in scenario '{scenario.name}', "
105
+ f"step '{step.name}')"
106
+ )
107
+
108
+ try:
109
+ with schema_file.open("r", encoding="utf-8") as file:
110
+ raw_schema = json.load(file)
111
+ except (OSError, json.JSONDecodeError) as err:
112
+ raise ConfigCompilerError(
113
+ f"Failed to read JSON schema file "
114
+ f"'{step.expect.schema_data}': {err}"
115
+ ) from err
116
+
117
+ if raw_schema is None: # pragma: no cover
118
+ raise ConfigCompilerError(
119
+ f"Invalid schema configuration in scenario "
120
+ f"'{scenario.name}', step '{step.name}'."
121
+ )
122
+
123
+ try:
124
+ compiled = fastjsonschema.compile(raw_schema)
125
+ step.expect._compiled_schema = cast(
126
+ Callable[[Any], Any],
127
+ compiled,
128
+ )
129
+ except Exception as err:
130
+ raise ConfigCompilerError(
131
+ f"Failed to compile JSON schema in "
132
+ f"scenario '{scenario.name}', "
133
+ f"step '{step.name}': {err}"
134
+ ) from err
135
+
136
+ return config
@@ -0,0 +1,150 @@
1
+ """Declarative configuration models for Proofrun synthetic monitoring scenarios.
2
+
3
+ This module defines the Pydantic schemas used to parse, validate, and structure
4
+ YAML monitoring scenarios, including global settings, request parameters,
5
+ expectations, schema validations, and extraction rules.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+ from typing import Any, Literal
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field, HttpUrl, PrivateAttr
14
+
15
+
16
+ class GlobalConfig(BaseModel):
17
+ """Global default settings applied across all scenarios and steps."""
18
+
19
+ base_url: HttpUrl | None = Field(
20
+ default=None,
21
+ description="Default root URL target for scenarios.",
22
+ )
23
+ timeout_seconds: float = Field(
24
+ default=10.0,
25
+ gt=0,
26
+ description="Default HTTP request timeout ceiling in seconds.",
27
+ )
28
+ headers: dict[str, str] = Field(
29
+ default_factory=dict,
30
+ description="Default HTTP headers sent with all requests.",
31
+ )
32
+
33
+
34
+ class RequestConfig(BaseModel):
35
+ """Defines the parameters for an outgoing HTTP request."""
36
+
37
+ method: Literal["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] = Field(
38
+ ...,
39
+ description="HTTP method.",
40
+ )
41
+ path: str = Field(
42
+ ...,
43
+ description="Endpoint path relative to base_url.",
44
+ )
45
+ headers: dict[str, str] = Field(
46
+ default_factory=dict,
47
+ description="Key-value pairs for HTTP request headers.",
48
+ )
49
+ params: dict[str, Any] = Field(
50
+ default_factory=dict,
51
+ description="URL query parameters.",
52
+ )
53
+ json_payload: Any = Field(
54
+ default=None,
55
+ alias="json",
56
+ description="JSON serializable body payload.",
57
+ )
58
+ timeout: float | None = Field(
59
+ default=None,
60
+ gt=0,
61
+ description="Step-specific HTTP request timeout ceiling in seconds.",
62
+ )
63
+
64
+
65
+ class ExpectConfig(BaseModel):
66
+ """Defines assertion criteria evaluated against the HTTP response."""
67
+
68
+ model_config = ConfigDict(arbitrary_types_allowed=True)
69
+
70
+ status: int | list[int] = Field(
71
+ ...,
72
+ description="Expected HTTP status code(s).",
73
+ )
74
+ headers: dict[str, str] = Field(
75
+ default_factory=dict,
76
+ description="Expected HTTP headers and their exact values.",
77
+ )
78
+ max_latency_ms: float | None = Field(
79
+ default=None,
80
+ gt=0,
81
+ description="Maximum allowed response latency in milliseconds.",
82
+ )
83
+ schema_data: str | dict[str, Any] | None = Field(
84
+ default=None,
85
+ alias="schema",
86
+ description="Path to a JSON schema file or inline schema dictionary.",
87
+ )
88
+
89
+ _compiled_schema: Callable[[Any], Any] | None = PrivateAttr(default=None)
90
+
91
+
92
+ class StepConfig(BaseModel):
93
+ """Represents an atomic execution step in a multi-step API scenario."""
94
+
95
+ name: str = Field(
96
+ ...,
97
+ description="Human-readable identifier for the scenario step.",
98
+ )
99
+ request: RequestConfig = Field(
100
+ ...,
101
+ description="HTTP request specifications.",
102
+ )
103
+ expect: ExpectConfig = Field(
104
+ ...,
105
+ description="Response assertion rules.",
106
+ )
107
+ extract: dict[str, str] = Field(
108
+ default_factory=dict,
109
+ description="Mapping of variable names to JSONPath expressions.",
110
+ )
111
+
112
+
113
+ class ScenarioConfig(BaseModel):
114
+ """Defines a sequence of dependent execution steps against a target host."""
115
+
116
+ name: str = Field(
117
+ ...,
118
+ description="Human-readable scenario suite name.",
119
+ )
120
+ base_url: HttpUrl | None = Field(
121
+ default=None,
122
+ description="Root URL target for all steps in this scenario.",
123
+ )
124
+ interval_seconds: int = Field(
125
+ default=60,
126
+ gt=0,
127
+ description="Execution interval in seconds for daemon mode.",
128
+ )
129
+ steps: list[StepConfig] = Field(
130
+ ...,
131
+ description="Ordered sequence of steps executed within shared context memory.",
132
+ )
133
+
134
+
135
+ class ProofrunConfig(BaseModel):
136
+ """Root configuration model representing a parsed Proofrun YAML file."""
137
+
138
+ version: Literal["1.0"] = Field(
139
+ ...,
140
+ description="Configuration schema version. Currently only '1.0' is supported.",
141
+ )
142
+ global_config: GlobalConfig | None = Field(
143
+ default=None,
144
+ alias="global",
145
+ description="Global configuration settings applied across scenarios.",
146
+ )
147
+ scenarios: list[ScenarioConfig] = Field(
148
+ ...,
149
+ description="List of scenario workflows defined in the configuration file.",
150
+ )