evalgate-sdk 3.3.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.
- evalgate_sdk/__init__.py +707 -0
- evalgate_sdk/_version.py +3 -0
- evalgate_sdk/assertions.py +1362 -0
- evalgate_sdk/auto.py +247 -0
- evalgate_sdk/batch.py +174 -0
- evalgate_sdk/cache.py +111 -0
- evalgate_sdk/ci_context.py +123 -0
- evalgate_sdk/cli/__init__.py +111 -0
- evalgate_sdk/cli/api.py +261 -0
- evalgate_sdk/cli/cli_constants.py +20 -0
- evalgate_sdk/cli/commands.py +1041 -0
- evalgate_sdk/cli/config.py +228 -0
- evalgate_sdk/cli/env.py +43 -0
- evalgate_sdk/cli/formatters/types.py +132 -0
- evalgate_sdk/cli/golden_commands.py +322 -0
- evalgate_sdk/cli/manifest.py +301 -0
- evalgate_sdk/cli/new_commands.py +435 -0
- evalgate_sdk/cli/policy_packs.py +103 -0
- evalgate_sdk/cli/profiles.py +12 -0
- evalgate_sdk/cli/regression_gate.py +312 -0
- evalgate_sdk/cli/render/__init__.py +1 -0
- evalgate_sdk/cli/render/snippet.py +18 -0
- evalgate_sdk/cli/render/sort.py +29 -0
- evalgate_sdk/cli/report/__init__.py +1 -0
- evalgate_sdk/cli/report/build_check_report.py +209 -0
- evalgate_sdk/cli/traces.py +186 -0
- evalgate_sdk/cli/workspace.py +63 -0
- evalgate_sdk/client.py +609 -0
- evalgate_sdk/cluster.py +359 -0
- evalgate_sdk/collector.py +161 -0
- evalgate_sdk/constants.py +6 -0
- evalgate_sdk/context.py +151 -0
- evalgate_sdk/errors.py +236 -0
- evalgate_sdk/export.py +238 -0
- evalgate_sdk/formatters/__init__.py +11 -0
- evalgate_sdk/formatters/github.py +51 -0
- evalgate_sdk/formatters/human.py +68 -0
- evalgate_sdk/formatters/json_fmt.py +11 -0
- evalgate_sdk/formatters/pr_comment.py +80 -0
- evalgate_sdk/golden.py +426 -0
- evalgate_sdk/integrations/__init__.py +1 -0
- evalgate_sdk/integrations/anthropic.py +99 -0
- evalgate_sdk/integrations/autogen.py +62 -0
- evalgate_sdk/integrations/crewai.py +61 -0
- evalgate_sdk/integrations/langchain.py +100 -0
- evalgate_sdk/integrations/openai.py +155 -0
- evalgate_sdk/integrations/openai_eval.py +221 -0
- evalgate_sdk/local.py +144 -0
- evalgate_sdk/logger.py +123 -0
- evalgate_sdk/matchers.py +62 -0
- evalgate_sdk/otel.py +256 -0
- evalgate_sdk/pagination.py +145 -0
- evalgate_sdk/py.typed +0 -0
- evalgate_sdk/pytest_plugin.py +96 -0
- evalgate_sdk/reason_codes.py +103 -0
- evalgate_sdk/regression.py +196 -0
- evalgate_sdk/replay_decision.py +115 -0
- evalgate_sdk/runtime/__init__.py +50 -0
- evalgate_sdk/runtime/adapters/__init__.py +1 -0
- evalgate_sdk/runtime/adapters/config_to_dsl.py +270 -0
- evalgate_sdk/runtime/adapters/testsuite_to_dsl.py +213 -0
- evalgate_sdk/runtime/context.py +68 -0
- evalgate_sdk/runtime/eval.py +318 -0
- evalgate_sdk/runtime/execution_mode.py +170 -0
- evalgate_sdk/runtime/executor.py +92 -0
- evalgate_sdk/runtime/registry.py +125 -0
- evalgate_sdk/runtime/run_report.py +249 -0
- evalgate_sdk/runtime/types.py +143 -0
- evalgate_sdk/snapshot.py +219 -0
- evalgate_sdk/streaming.py +124 -0
- evalgate_sdk/synthesize.py +226 -0
- evalgate_sdk/testing.py +128 -0
- evalgate_sdk/types.py +666 -0
- evalgate_sdk/utils/__init__.py +1 -0
- evalgate_sdk/utils/input_hash.py +42 -0
- evalgate_sdk/workflows.py +264 -0
- evalgate_sdk-3.3.1.dist-info/METADATA +608 -0
- evalgate_sdk-3.3.1.dist-info/RECORD +80 -0
- evalgate_sdk-3.3.1.dist-info/WHEEL +4 -0
- evalgate_sdk-3.3.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Deterministic RunReport serialization (T4).
|
|
2
|
+
|
|
3
|
+
Port of the TypeScript SDK's ``run-report.ts``.
|
|
4
|
+
Provides a stable report format for downstream processing (explain, diff, history).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import platform
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import asdict, dataclass, field
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
RUN_REPORT_SCHEMA_VERSION = "1"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class RunResult:
|
|
21
|
+
"""Individual test result."""
|
|
22
|
+
|
|
23
|
+
test_id: str
|
|
24
|
+
test_name: str
|
|
25
|
+
file_path: str
|
|
26
|
+
position: dict[str, int]
|
|
27
|
+
input: str
|
|
28
|
+
passed: bool
|
|
29
|
+
score: float
|
|
30
|
+
duration_ms: float
|
|
31
|
+
metadata: dict[str, Any] | None = None
|
|
32
|
+
tags: list[str] = field(default_factory=list)
|
|
33
|
+
assertions: list[dict[str, Any]] = field(default_factory=list)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class RunFailure:
|
|
38
|
+
"""Failure or error information."""
|
|
39
|
+
|
|
40
|
+
test_id: str
|
|
41
|
+
test_name: str
|
|
42
|
+
file_path: str
|
|
43
|
+
position: dict[str, int]
|
|
44
|
+
classification: str # "failed" | "error" | "timeout"
|
|
45
|
+
message: str
|
|
46
|
+
timestamp: str = ""
|
|
47
|
+
error_envelope: dict[str, Any] | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class RunSummary:
|
|
52
|
+
"""Execution summary statistics."""
|
|
53
|
+
|
|
54
|
+
total: int = 0
|
|
55
|
+
passed: int = 0
|
|
56
|
+
failed: int = 0
|
|
57
|
+
errors: int = 0
|
|
58
|
+
timeouts: int = 0
|
|
59
|
+
pass_rate: float = 0.0
|
|
60
|
+
average_score: float = 0.0
|
|
61
|
+
total_duration_ms: float = 0.0
|
|
62
|
+
success: bool = True
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class RunConfig:
|
|
67
|
+
"""Execution configuration."""
|
|
68
|
+
|
|
69
|
+
executor_type: str = "local"
|
|
70
|
+
max_parallel: int | None = None
|
|
71
|
+
default_timeout: int = 30_000
|
|
72
|
+
environment: dict[str, str] = field(default_factory=dict)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class RunReport:
|
|
77
|
+
"""Main run report structure."""
|
|
78
|
+
|
|
79
|
+
schema_version: str = RUN_REPORT_SCHEMA_VERSION
|
|
80
|
+
run_id: str = ""
|
|
81
|
+
started_at: str = ""
|
|
82
|
+
finished_at: str = ""
|
|
83
|
+
runtime: dict[str, str] = field(default_factory=dict)
|
|
84
|
+
results: list[RunResult] = field(default_factory=list)
|
|
85
|
+
failures: list[RunFailure] = field(default_factory=list)
|
|
86
|
+
summary: RunSummary = field(default_factory=RunSummary)
|
|
87
|
+
config: RunConfig = field(default_factory=RunConfig)
|
|
88
|
+
|
|
89
|
+
def to_json(self) -> str:
|
|
90
|
+
"""Serialize to deterministic JSON string."""
|
|
91
|
+
return json.dumps(asdict(self), indent=2, default=str)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class RunReportBuilder:
|
|
95
|
+
"""Builder for creating deterministic RunReport instances."""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
run_id: str,
|
|
100
|
+
runtime_info: dict[str, str],
|
|
101
|
+
) -> None:
|
|
102
|
+
self._report = RunReport(
|
|
103
|
+
run_id=run_id,
|
|
104
|
+
started_at=datetime.now(timezone.utc).isoformat(),
|
|
105
|
+
runtime=runtime_info,
|
|
106
|
+
config=RunConfig(
|
|
107
|
+
environment={
|
|
108
|
+
"python_version": sys.version.split()[0],
|
|
109
|
+
"platform": platform.system().lower(),
|
|
110
|
+
"arch": platform.machine(),
|
|
111
|
+
},
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
self._scores: list[float] = []
|
|
115
|
+
|
|
116
|
+
def add_result(
|
|
117
|
+
self,
|
|
118
|
+
test_id: str,
|
|
119
|
+
test_name: str,
|
|
120
|
+
file_path: str,
|
|
121
|
+
position: dict[str, int],
|
|
122
|
+
input: str,
|
|
123
|
+
*,
|
|
124
|
+
passed: bool,
|
|
125
|
+
score: float,
|
|
126
|
+
duration_ms: float = 0.0,
|
|
127
|
+
metadata: dict[str, Any] | None = None,
|
|
128
|
+
tags: list[str] | None = None,
|
|
129
|
+
assertions: list[dict[str, Any]] | None = None,
|
|
130
|
+
classification: str = "passed",
|
|
131
|
+
error: str | None = None,
|
|
132
|
+
error_envelope: dict[str, Any] | None = None,
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Add a test result to the report."""
|
|
135
|
+
result = RunResult(
|
|
136
|
+
test_id=test_id,
|
|
137
|
+
test_name=test_name,
|
|
138
|
+
file_path=file_path,
|
|
139
|
+
position=position,
|
|
140
|
+
input=input,
|
|
141
|
+
passed=passed,
|
|
142
|
+
score=score,
|
|
143
|
+
duration_ms=duration_ms,
|
|
144
|
+
metadata=metadata,
|
|
145
|
+
tags=tags or [],
|
|
146
|
+
assertions=assertions or [],
|
|
147
|
+
)
|
|
148
|
+
self._report.results.append(result)
|
|
149
|
+
|
|
150
|
+
# Update summary
|
|
151
|
+
s = self._report.summary
|
|
152
|
+
s.total += 1
|
|
153
|
+
s.total_duration_ms += duration_ms
|
|
154
|
+
|
|
155
|
+
if passed:
|
|
156
|
+
s.passed += 1
|
|
157
|
+
elif classification == "error":
|
|
158
|
+
s.errors += 1
|
|
159
|
+
s.success = False
|
|
160
|
+
elif classification == "timeout":
|
|
161
|
+
s.timeouts += 1
|
|
162
|
+
s.success = False
|
|
163
|
+
else:
|
|
164
|
+
s.failed += 1
|
|
165
|
+
s.success = False
|
|
166
|
+
|
|
167
|
+
s.pass_rate = (s.passed / s.total * 100) if s.total > 0 else 0.0
|
|
168
|
+
if score > 0:
|
|
169
|
+
self._scores.append(score)
|
|
170
|
+
s.average_score = (sum(self._scores) / len(self._scores)) if self._scores else 0.0
|
|
171
|
+
|
|
172
|
+
# Add to failures if needed
|
|
173
|
+
if not passed or classification in ("error", "timeout"):
|
|
174
|
+
failure = RunFailure(
|
|
175
|
+
test_id=test_id,
|
|
176
|
+
test_name=test_name,
|
|
177
|
+
file_path=file_path,
|
|
178
|
+
position=position,
|
|
179
|
+
classification=classification if classification in ("error", "timeout") else "failed",
|
|
180
|
+
message=error or "Test failed",
|
|
181
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
182
|
+
error_envelope=error_envelope,
|
|
183
|
+
)
|
|
184
|
+
self._report.failures.append(failure)
|
|
185
|
+
|
|
186
|
+
def set_config(self, **kwargs: Any) -> None:
|
|
187
|
+
"""Update execution configuration fields."""
|
|
188
|
+
for key, value in kwargs.items():
|
|
189
|
+
if hasattr(self._report.config, key):
|
|
190
|
+
setattr(self._report.config, key, value)
|
|
191
|
+
|
|
192
|
+
def build(self) -> RunReport:
|
|
193
|
+
"""Finalize and return the complete report."""
|
|
194
|
+
# Sort for determinism
|
|
195
|
+
self._report.results.sort(key=lambda r: r.test_id)
|
|
196
|
+
self._report.failures.sort(key=lambda f: f.test_id)
|
|
197
|
+
self._report.finished_at = datetime.now(timezone.utc).isoformat()
|
|
198
|
+
return self._report
|
|
199
|
+
|
|
200
|
+
def to_json(self) -> str:
|
|
201
|
+
"""Build and serialize to JSON."""
|
|
202
|
+
return self.build().to_json()
|
|
203
|
+
|
|
204
|
+
async def write_to_file(self, file_path: str) -> None:
|
|
205
|
+
"""Write report to file."""
|
|
206
|
+
from pathlib import Path
|
|
207
|
+
|
|
208
|
+
Path(file_path).write_text(self.to_json(), encoding="utf-8")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def create_run_report(
|
|
212
|
+
run_id: str,
|
|
213
|
+
runtime_info: dict[str, str],
|
|
214
|
+
) -> RunReportBuilder:
|
|
215
|
+
"""Create a new RunReport builder."""
|
|
216
|
+
return RunReportBuilder(run_id, runtime_info)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _filter_dataclass_fields(cls: type, d: dict[str, Any]) -> dict[str, Any]:
|
|
220
|
+
"""Keep only keys that match dataclass field names — prevents TypeError on extras."""
|
|
221
|
+
import dataclasses
|
|
222
|
+
|
|
223
|
+
valid = {f.name for f in dataclasses.fields(cls)}
|
|
224
|
+
return {k: v for k, v in d.items() if k in valid}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def parse_run_report(json_str: str) -> RunReport:
|
|
228
|
+
"""Parse a RunReport from a JSON string."""
|
|
229
|
+
data = json.loads(json_str)
|
|
230
|
+
version = data.get("schema_version", "")
|
|
231
|
+
if version != RUN_REPORT_SCHEMA_VERSION:
|
|
232
|
+
raise ValueError(f"Unsupported RunReport schema version: {version}")
|
|
233
|
+
|
|
234
|
+
summary = RunSummary(**_filter_dataclass_fields(RunSummary, data.get("summary", {})))
|
|
235
|
+
config = RunConfig(**_filter_dataclass_fields(RunConfig, data.get("config", {})))
|
|
236
|
+
results = [RunResult(**_filter_dataclass_fields(RunResult, r)) for r in data.get("results", [])]
|
|
237
|
+
failures = [RunFailure(**_filter_dataclass_fields(RunFailure, f)) for f in data.get("failures", [])]
|
|
238
|
+
|
|
239
|
+
return RunReport(
|
|
240
|
+
schema_version=data["schema_version"],
|
|
241
|
+
run_id=data["run_id"],
|
|
242
|
+
started_at=data.get("started_at", ""),
|
|
243
|
+
finished_at=data.get("finished_at", ""),
|
|
244
|
+
runtime=data.get("runtime", {}),
|
|
245
|
+
results=results,
|
|
246
|
+
failures=failures,
|
|
247
|
+
summary=summary,
|
|
248
|
+
config=config,
|
|
249
|
+
)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Core types for the runtime foundation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class DependsOn:
|
|
11
|
+
"""Dependency hints for impact analysis."""
|
|
12
|
+
|
|
13
|
+
prompts: list[str] = field(default_factory=list)
|
|
14
|
+
datasets: list[str] = field(default_factory=list)
|
|
15
|
+
tools: list[str] = field(default_factory=list)
|
|
16
|
+
code: list[str] = field(default_factory=list)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class SpecOptions:
|
|
21
|
+
timeout_ms: int = 30_000
|
|
22
|
+
retries: int = 0
|
|
23
|
+
tags: list[str] = field(default_factory=list)
|
|
24
|
+
skip: bool = False
|
|
25
|
+
only: bool = False
|
|
26
|
+
description: str | None = None
|
|
27
|
+
budget: str | None = None
|
|
28
|
+
model: str | None = None
|
|
29
|
+
metadata: dict[str, Any] | None = None
|
|
30
|
+
depends_on: DependsOn | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class SpecConfig:
|
|
35
|
+
name: str
|
|
36
|
+
executor: Any = None
|
|
37
|
+
options: SpecOptions = field(default_factory=SpecOptions)
|
|
38
|
+
description: str | None = None
|
|
39
|
+
suite: str | None = None
|
|
40
|
+
tags: list[str] | None = None
|
|
41
|
+
timeout: int | None = None
|
|
42
|
+
retries: int | None = None
|
|
43
|
+
budget: str | None = None
|
|
44
|
+
model: str | None = None
|
|
45
|
+
metadata: dict[str, Any] | None = None
|
|
46
|
+
depends_on: DependsOn | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class EvalSpec:
|
|
51
|
+
id: str
|
|
52
|
+
name: str
|
|
53
|
+
executor: Any
|
|
54
|
+
options: SpecOptions = field(default_factory=SpecOptions)
|
|
55
|
+
file_path: str | None = None
|
|
56
|
+
suite: str | None = None
|
|
57
|
+
description: str | None = None
|
|
58
|
+
position: dict[str, int] | None = None
|
|
59
|
+
tags: list[str] = field(default_factory=list)
|
|
60
|
+
metadata: dict[str, Any] | None = None
|
|
61
|
+
config: dict[str, Any] | None = None
|
|
62
|
+
mode: Literal["normal", "skip", "only"] = "normal"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class EvalContext:
|
|
67
|
+
input: Any = None
|
|
68
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
69
|
+
options: dict[str, Any] = field(default_factory=dict)
|
|
70
|
+
trace_id: str | None = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class EvalResult:
|
|
75
|
+
passed: bool
|
|
76
|
+
score: float = 0.0
|
|
77
|
+
assertions: list[Any] = field(default_factory=list)
|
|
78
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
79
|
+
error: str | None = None
|
|
80
|
+
duration_ms: float = 0.0
|
|
81
|
+
status: Literal["passed", "failed", "error", "timeout"] = "passed"
|
|
82
|
+
output: str | None = None
|
|
83
|
+
tokens: int | None = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class ExecutorCapabilities:
|
|
88
|
+
supports_async: bool = True
|
|
89
|
+
supports_timeout: bool = True
|
|
90
|
+
supports_retries: bool = True
|
|
91
|
+
supports_parallel: bool = False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class ExecutionErrorEnvelope:
|
|
96
|
+
error_type: str
|
|
97
|
+
message: str
|
|
98
|
+
stack: str | None = None
|
|
99
|
+
retryable: bool = False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class RuntimeHealth:
|
|
104
|
+
status: Literal["healthy", "degraded", "unhealthy"] = "healthy"
|
|
105
|
+
spec_count: int = 0
|
|
106
|
+
memory_estimate_mb: float = 0.0
|
|
107
|
+
uptime_ms: float = 0.0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ── Error classes ────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class EvalRuntimeError(Exception):
|
|
114
|
+
"""Base error for runtime operations."""
|
|
115
|
+
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class SpecRegistrationError(EvalRuntimeError):
|
|
120
|
+
"""Raised when a spec fails to register."""
|
|
121
|
+
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class SpecExecutionError(EvalRuntimeError):
|
|
126
|
+
"""Raised when a spec fails to execute."""
|
|
127
|
+
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class RuntimeError(EvalRuntimeError):
|
|
132
|
+
"""Raised for general runtime errors."""
|
|
133
|
+
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class EvalExecutionError(EvalRuntimeError):
|
|
138
|
+
"""Raised during eval execution with context."""
|
|
139
|
+
|
|
140
|
+
def __init__(self, message: str, spec_id: str, cause: Exception | None = None) -> None:
|
|
141
|
+
super().__init__(message)
|
|
142
|
+
self.spec_id = spec_id
|
|
143
|
+
self.cause = cause
|
evalgate_sdk/snapshot.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Snapshot testing — save, load, compare LLM outputs against golden snapshots."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class SnapshotMetadata:
|
|
15
|
+
name: str
|
|
16
|
+
created_at: str
|
|
17
|
+
content_hash: str
|
|
18
|
+
version: int = 1
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class SnapshotData:
|
|
23
|
+
name: str
|
|
24
|
+
output: str
|
|
25
|
+
metadata: SnapshotMetadata
|
|
26
|
+
tags: list[str] = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class SnapshotComparison:
|
|
31
|
+
name: str
|
|
32
|
+
matches: bool
|
|
33
|
+
similarity: float
|
|
34
|
+
current_output: str
|
|
35
|
+
snapshot_output: str
|
|
36
|
+
diff_lines: list[str] = field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_DEFAULT_DIR = ".snapshots"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _safe_name(name: str) -> str:
|
|
43
|
+
"""Sanitize snapshot name for filesystem use."""
|
|
44
|
+
safe = re.sub(r"[^\w\-.]", "_", name)
|
|
45
|
+
if ".." in safe or safe.startswith("/") or safe.startswith("\\"):
|
|
46
|
+
raise ValueError(f"Invalid snapshot name: {name}")
|
|
47
|
+
return safe
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _content_hash(text: str) -> str:
|
|
51
|
+
return hashlib.sha256(text.encode()).hexdigest()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _similarity(a: str, b: str) -> float:
|
|
55
|
+
"""Line-by-line similarity ratio."""
|
|
56
|
+
lines_a = a.splitlines()
|
|
57
|
+
lines_b = b.splitlines()
|
|
58
|
+
if not lines_a and not lines_b:
|
|
59
|
+
return 1.0
|
|
60
|
+
total = max(len(lines_a), len(lines_b))
|
|
61
|
+
matches = sum(1 for la, lb in zip(lines_a, lines_b, strict=False) if la == lb)
|
|
62
|
+
return matches / total if total > 0 else 1.0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SnapshotManager:
|
|
66
|
+
"""Manage snapshot files on disk.
|
|
67
|
+
|
|
68
|
+
Usage::
|
|
69
|
+
|
|
70
|
+
mgr = SnapshotManager()
|
|
71
|
+
snap = mgr.save("my-test", "Hello world output")
|
|
72
|
+
comparison = mgr.compare("my-test", "Hello world output v2")
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, directory: str = _DEFAULT_DIR) -> None:
|
|
76
|
+
self._dir = Path(directory)
|
|
77
|
+
|
|
78
|
+
def _path(self, name: str) -> Path:
|
|
79
|
+
return self._dir / f"{_safe_name(name)}.json"
|
|
80
|
+
|
|
81
|
+
def save(self, name: str, output: str, tags: list[str] | None = None) -> SnapshotData:
|
|
82
|
+
"""Save a snapshot to disk."""
|
|
83
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
snap = SnapshotData(
|
|
85
|
+
name=name,
|
|
86
|
+
output=output,
|
|
87
|
+
metadata=SnapshotMetadata(
|
|
88
|
+
name=name,
|
|
89
|
+
created_at=datetime.now(timezone.utc).isoformat(),
|
|
90
|
+
content_hash=_content_hash(output),
|
|
91
|
+
),
|
|
92
|
+
tags=tags or [],
|
|
93
|
+
)
|
|
94
|
+
path = self._path(name)
|
|
95
|
+
path.write_text(
|
|
96
|
+
json.dumps(
|
|
97
|
+
{
|
|
98
|
+
"name": snap.name,
|
|
99
|
+
"output": snap.output,
|
|
100
|
+
"metadata": {
|
|
101
|
+
"name": snap.metadata.name,
|
|
102
|
+
"created_at": snap.metadata.created_at,
|
|
103
|
+
"content_hash": snap.metadata.content_hash,
|
|
104
|
+
"version": snap.metadata.version,
|
|
105
|
+
},
|
|
106
|
+
"tags": snap.tags,
|
|
107
|
+
},
|
|
108
|
+
indent=2,
|
|
109
|
+
),
|
|
110
|
+
encoding="utf-8",
|
|
111
|
+
)
|
|
112
|
+
return snap
|
|
113
|
+
|
|
114
|
+
def load(self, name: str) -> SnapshotData | None:
|
|
115
|
+
"""Load a snapshot from disk."""
|
|
116
|
+
path = self._path(name)
|
|
117
|
+
if not path.exists():
|
|
118
|
+
return None
|
|
119
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
120
|
+
return SnapshotData(
|
|
121
|
+
name=raw["name"],
|
|
122
|
+
output=raw["output"],
|
|
123
|
+
metadata=SnapshotMetadata(**raw["metadata"]),
|
|
124
|
+
tags=raw.get("tags", []),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def compare(self, name: str, current_output: str) -> SnapshotComparison:
|
|
128
|
+
"""Compare current output against a saved snapshot."""
|
|
129
|
+
existing = self.load(name)
|
|
130
|
+
if existing is None:
|
|
131
|
+
self.save(name, current_output)
|
|
132
|
+
return SnapshotComparison(
|
|
133
|
+
name=name,
|
|
134
|
+
matches=True,
|
|
135
|
+
similarity=1.0,
|
|
136
|
+
current_output=current_output,
|
|
137
|
+
snapshot_output=current_output,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
matches = existing.output == current_output
|
|
141
|
+
sim = _similarity(existing.output, current_output)
|
|
142
|
+
|
|
143
|
+
diff: list[str] = []
|
|
144
|
+
old_lines = existing.output.splitlines()
|
|
145
|
+
new_lines = current_output.splitlines()
|
|
146
|
+
for i in range(max(len(old_lines), len(new_lines))):
|
|
147
|
+
old = old_lines[i] if i < len(old_lines) else ""
|
|
148
|
+
new = new_lines[i] if i < len(new_lines) else ""
|
|
149
|
+
if old != new:
|
|
150
|
+
diff.append(f"L{i + 1}: -{old!r} +{new!r}")
|
|
151
|
+
|
|
152
|
+
return SnapshotComparison(
|
|
153
|
+
name=name,
|
|
154
|
+
matches=matches,
|
|
155
|
+
similarity=sim,
|
|
156
|
+
current_output=current_output,
|
|
157
|
+
snapshot_output=existing.output,
|
|
158
|
+
diff_lines=diff,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def delete(self, name: str) -> bool:
|
|
162
|
+
path = self._path(name)
|
|
163
|
+
if path.exists():
|
|
164
|
+
path.unlink()
|
|
165
|
+
return True
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
def list_snapshots(self) -> list[SnapshotData]:
|
|
169
|
+
if not self._dir.exists():
|
|
170
|
+
return []
|
|
171
|
+
results: list[SnapshotData] = []
|
|
172
|
+
for p in sorted(self._dir.glob("*.json")):
|
|
173
|
+
try:
|
|
174
|
+
raw = json.loads(p.read_text(encoding="utf-8"))
|
|
175
|
+
results.append(
|
|
176
|
+
SnapshotData(
|
|
177
|
+
name=raw["name"],
|
|
178
|
+
output=raw["output"],
|
|
179
|
+
metadata=SnapshotMetadata(**raw["metadata"]),
|
|
180
|
+
tags=raw.get("tags", []),
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
except (json.JSONDecodeError, KeyError):
|
|
184
|
+
continue
|
|
185
|
+
return results
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# Module-level convenience functions using a default manager
|
|
189
|
+
|
|
190
|
+
_default_manager: SnapshotManager | None = None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _get_manager(directory: str | None = None) -> SnapshotManager:
|
|
194
|
+
global _default_manager
|
|
195
|
+
if directory is not None:
|
|
196
|
+
return SnapshotManager(directory)
|
|
197
|
+
if _default_manager is None:
|
|
198
|
+
_default_manager = SnapshotManager()
|
|
199
|
+
return _default_manager
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def snapshot(output: str, name: str, *, directory: str | None = None, tags: list[str] | None = None) -> SnapshotData:
|
|
203
|
+
return _get_manager(directory).save(name, output, tags)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def load_snapshot(name: str, *, directory: str | None = None) -> SnapshotData | None:
|
|
207
|
+
return _get_manager(directory).load(name)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def compare_with_snapshot(name: str, current_output: str, *, directory: str | None = None) -> SnapshotComparison:
|
|
211
|
+
return _get_manager(directory).compare(name, current_output)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def delete_snapshot(name: str, *, directory: str | None = None) -> bool:
|
|
215
|
+
return _get_manager(directory).delete(name)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def list_snapshots(*, directory: str | None = None) -> list[SnapshotData]:
|
|
219
|
+
return _get_manager(directory).list_snapshots()
|