aicert-pro 0.1.0__tar.gz
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.
- aicert_pro-0.1.0/PKG-INFO +58 -0
- aicert_pro-0.1.0/README.md +43 -0
- aicert_pro-0.1.0/pyproject.toml +36 -0
- aicert_pro-0.1.0/setup.cfg +4 -0
- aicert_pro-0.1.0/src/aicert_pro/__init__.py +3 -0
- aicert_pro-0.1.0/src/aicert_pro/baseline.py +209 -0
- aicert_pro-0.1.0/src/aicert_pro/cli.py +171 -0
- aicert_pro-0.1.0/src/aicert_pro/license.py +144 -0
- aicert_pro-0.1.0/src/aicert_pro.egg-info/PKG-INFO +58 -0
- aicert_pro-0.1.0/src/aicert_pro.egg-info/SOURCES.txt +15 -0
- aicert_pro-0.1.0/src/aicert_pro.egg-info/dependency_links.txt +1 -0
- aicert_pro-0.1.0/src/aicert_pro.egg-info/entry_points.txt +2 -0
- aicert_pro-0.1.0/src/aicert_pro.egg-info/requires.txt +7 -0
- aicert_pro-0.1.0/src/aicert_pro.egg-info/top_level.txt +1 -0
- aicert_pro-0.1.0/tests/test_baseline.py +320 -0
- aicert_pro-0.1.0/tests/test_cli.py +392 -0
- aicert_pro-0.1.0/tests/test_license.py +180 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aicert-pro
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pro baseline regression enforcement for aicert CLI
|
|
5
|
+
Author-email: aicert <dev@aicert.io>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: typer>=0.9.0
|
|
10
|
+
Requires-Dist: cryptography>=41.0.0
|
|
11
|
+
Requires-Dist: aicert>=0.1.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
15
|
+
|
|
16
|
+
# aicert-pro
|
|
17
|
+
|
|
18
|
+
Pro baseline regression enforcement for aicert CLI.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install -e .
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
### License Verification
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
aicert-pro license verify
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Requires `AICERT_PRO_LICENSE` environment variable to be set with a valid license.
|
|
35
|
+
|
|
36
|
+
### Baseline Management
|
|
37
|
+
|
|
38
|
+
Save baseline from a run:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
aicert-pro baseline save --from-run /path/to/run/dir
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Check current run against baseline:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
aicert-pro baseline check --from-run /path/to/run/dir
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Show baseline metrics:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
aicert-pro baseline show <project>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## License
|
|
57
|
+
|
|
58
|
+
MIT
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# aicert-pro
|
|
2
|
+
|
|
3
|
+
Pro baseline regression enforcement for aicert CLI.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install -e .
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
### License Verification
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
aicert-pro license verify
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Requires `AICERT_PRO_LICENSE` environment variable to be set with a valid license.
|
|
20
|
+
|
|
21
|
+
### Baseline Management
|
|
22
|
+
|
|
23
|
+
Save baseline from a run:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
aicert-pro baseline save --from-run /path/to/run/dir
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Check current run against baseline:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
aicert-pro baseline check --from-run /path/to/run/dir
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Show baseline metrics:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
aicert-pro baseline show <project>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## License
|
|
42
|
+
|
|
43
|
+
MIT
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "aicert-pro"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Pro baseline regression enforcement for aicert CLI"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "aicert", email = "dev@aicert.io"}
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"typer>=0.9.0",
|
|
17
|
+
"cryptography>=41.0.0",
|
|
18
|
+
"aicert>=0.1.0",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
dev = [
|
|
23
|
+
"pytest>=7.0.0",
|
|
24
|
+
"pytest-cov>=4.0.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
aicert-pro = "aicert_pro.cli:app"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["src"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
35
|
+
python_files = ["test_*.py"]
|
|
36
|
+
python_functions = ["test_*"]
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Baseline regression enforcement for aicert-pro."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
# Default thresholds for regression detection
|
|
8
|
+
DEFAULT_MAX_STABILITY_DROP = 5
|
|
9
|
+
DEFAULT_MAX_COMPLIANCE_DROP = 2
|
|
10
|
+
DEFAULT_MAX_P95_LATENCY_REGRESSION_PCT = 25
|
|
11
|
+
DEFAULT_MAX_COST_REGRESSION_PCT = 20
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def provider_key(provider: str, model: str, temperature: float | None) -> str:
|
|
15
|
+
"""Generate unique provider key for baseline tracking.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
provider: Provider name (e.g., "anthropic", "openai").
|
|
19
|
+
model: Model name (e.g., "claude-3-5-sonnet-20241022").
|
|
20
|
+
temperature: Temperature setting or None.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Unique key string combining provider, model, and temperature.
|
|
24
|
+
"""
|
|
25
|
+
temp_str = f"_{temperature}" if temperature is not None else ""
|
|
26
|
+
return f"{provider}/{model}{temp_str}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_baseline(path: Path) -> dict:
|
|
30
|
+
"""Load baseline data from JSON file.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
path: Path to baseline JSON file.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Baseline data dictionary.
|
|
37
|
+
"""
|
|
38
|
+
with open(path, "r") as f:
|
|
39
|
+
return json.load(f)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def save_baseline(path: Path, data: dict) -> None:
|
|
43
|
+
"""Save baseline data to JSON file.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
path: Path to save baseline JSON file.
|
|
47
|
+
data: Baseline data dictionary.
|
|
48
|
+
"""
|
|
49
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
with open(path, "w") as f:
|
|
51
|
+
json.dump(data, f, indent=2)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def baseline_path(project: str, baseline_dir: Path | None) -> Path:
|
|
55
|
+
"""Get baseline file path for a project.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
project: Project name.
|
|
59
|
+
baseline_dir: Directory to store baselines, or None for default.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Path to baseline JSON file.
|
|
63
|
+
"""
|
|
64
|
+
if baseline_dir is None:
|
|
65
|
+
baseline_dir = Path.home() / ".aicert" / "baselines"
|
|
66
|
+
baseline_dir.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
return baseline_dir / f"{project}.json"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def save_from_run(run_dir: Path, baseline_dir: Path | None = None) -> dict:
|
|
71
|
+
"""Save baseline metrics from a run directory.
|
|
72
|
+
|
|
73
|
+
Reads summary.json from the run directory and saves provider metrics
|
|
74
|
+
to the baseline store.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
run_dir: Path to run directory containing summary.json.
|
|
78
|
+
baseline_dir: Directory to store baselines, or None for default.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
The saved baseline data dictionary.
|
|
82
|
+
"""
|
|
83
|
+
summary_path = run_dir / "summary.json"
|
|
84
|
+
if not summary_path.exists():
|
|
85
|
+
raise FileNotFoundError(f"summary.json not found in {run_dir}")
|
|
86
|
+
|
|
87
|
+
with open(summary_path, "r") as f:
|
|
88
|
+
summary = json.load(f)
|
|
89
|
+
|
|
90
|
+
project = summary.get("project", "default")
|
|
91
|
+
|
|
92
|
+
# Extract provider metrics from summary
|
|
93
|
+
metrics = summary.get("metrics", {})
|
|
94
|
+
provider = summary.get("provider", "")
|
|
95
|
+
model = summary.get("model", "")
|
|
96
|
+
temperature = summary.get("temperature")
|
|
97
|
+
|
|
98
|
+
provider_key_str = provider_key(provider, model, temperature)
|
|
99
|
+
|
|
100
|
+
baseline_data = {
|
|
101
|
+
"project": project,
|
|
102
|
+
"provider_key": provider_key_str,
|
|
103
|
+
"created_at": summary.get("timestamp", ""),
|
|
104
|
+
"metrics": {
|
|
105
|
+
"stability": metrics.get("stability", 0),
|
|
106
|
+
"compliance": metrics.get("compliance", 0),
|
|
107
|
+
"p95_latency_ms": metrics.get("p95_latency_ms", 0),
|
|
108
|
+
"mean_latency_ms": metrics.get("mean_latency_ms", 0),
|
|
109
|
+
"mean_cost_usd": metrics.get("mean_cost_usd"),
|
|
110
|
+
"prompt_hash": summary.get("prompt_hash", ""),
|
|
111
|
+
"schema_hash": summary.get("schema_hash", ""),
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
# Remove None values
|
|
116
|
+
baseline_data["metrics"] = {k: v for k, v in baseline_data["metrics"].items() if v is not None}
|
|
117
|
+
|
|
118
|
+
path = baseline_path(project, baseline_dir)
|
|
119
|
+
save_baseline(path, baseline_data)
|
|
120
|
+
|
|
121
|
+
return baseline_data
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def check_from_run(
|
|
125
|
+
run_dir: Path,
|
|
126
|
+
baseline_dir: Path | None = None,
|
|
127
|
+
max_stability_drop: int = DEFAULT_MAX_STABILITY_DROP,
|
|
128
|
+
max_compliance_drop: int = DEFAULT_MAX_COMPLIANCE_DROP,
|
|
129
|
+
max_p95_latency_regression_pct: int = DEFAULT_MAX_P95_LATENCY_REGRESSION_PCT,
|
|
130
|
+
max_cost_regression_pct: int = DEFAULT_MAX_COST_REGRESSION_PCT,
|
|
131
|
+
) -> bool:
|
|
132
|
+
"""Check current run against baseline for regressions.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
run_dir: Path to run directory containing summary.json.
|
|
136
|
+
baseline_dir: Directory to store baselines, or None for default.
|
|
137
|
+
max_stability_drop: Maximum allowed stability drop (percentage points).
|
|
138
|
+
max_compliance_drop: Maximum allowed compliance drop (percentage points).
|
|
139
|
+
max_p95_latency_regression_pct: Maximum allowed P95 latency regression (%).
|
|
140
|
+
max_cost_regression_pct: Maximum allowed cost regression (%).
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
True if all metrics pass baseline thresholds, False otherwise.
|
|
144
|
+
"""
|
|
145
|
+
summary_path = run_dir / "summary.json"
|
|
146
|
+
if not summary_path.exists():
|
|
147
|
+
raise FileNotFoundError(f"summary.json not found in {run_dir}")
|
|
148
|
+
|
|
149
|
+
with open(summary_path, "r") as f:
|
|
150
|
+
summary = json.load(f)
|
|
151
|
+
|
|
152
|
+
project = summary.get("project", "default")
|
|
153
|
+
baseline_file = baseline_path(project, baseline_dir)
|
|
154
|
+
|
|
155
|
+
if not baseline_file.exists():
|
|
156
|
+
raise FileNotFoundError(f"Baseline not found for project '{project}'. Run 'baseline save' first.")
|
|
157
|
+
|
|
158
|
+
baseline = load_baseline(baseline_file)
|
|
159
|
+
baseline_metrics = baseline.get("metrics", {})
|
|
160
|
+
current_metrics = summary.get("metrics", {})
|
|
161
|
+
|
|
162
|
+
failures: list[str] = []
|
|
163
|
+
|
|
164
|
+
# Check stability drop
|
|
165
|
+
baseline_stability = baseline_metrics.get("stability", 0)
|
|
166
|
+
current_stability = current_metrics.get("stability", 0)
|
|
167
|
+
stability_drop = baseline_stability - current_stability
|
|
168
|
+
if stability_drop > max_stability_drop:
|
|
169
|
+
failures.append(
|
|
170
|
+
f"Stability dropped by {stability_drop:.1f} (max: {max_stability_drop})"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Check compliance drop
|
|
174
|
+
baseline_compliance = baseline_metrics.get("compliance", 0)
|
|
175
|
+
current_compliance = current_metrics.get("compliance", 0)
|
|
176
|
+
compliance_drop = baseline_compliance - current_compliance
|
|
177
|
+
if compliance_drop > max_compliance_drop:
|
|
178
|
+
failures.append(
|
|
179
|
+
f"Compliance dropped by {compliance_drop:.1f} (max: {max_compliance_drop})"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# Check P95 latency regression
|
|
183
|
+
baseline_p95 = baseline_metrics.get("p95_latency_ms", 0)
|
|
184
|
+
current_p95 = current_metrics.get("p95_latency_ms", 0)
|
|
185
|
+
if baseline_p95 > 0 and current_p95 > 0:
|
|
186
|
+
p95_regression_pct = ((current_p95 - baseline_p95) / baseline_p95) * 100
|
|
187
|
+
if p95_regression_pct > max_p95_latency_regression_pct:
|
|
188
|
+
failures.append(
|
|
189
|
+
f"P95 latency regressed by {p95_regression_pct:.1f}% (max: {max_p95_latency_regression_pct}%)"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Check cost regression
|
|
193
|
+
baseline_cost = baseline_metrics.get("mean_cost_usd")
|
|
194
|
+
current_cost = current_metrics.get("mean_cost_usd")
|
|
195
|
+
if baseline_cost is not None and current_cost is not None and baseline_cost > 0:
|
|
196
|
+
cost_regression_pct = ((current_cost - baseline_cost) / baseline_cost) * 100
|
|
197
|
+
if cost_regression_pct > max_cost_regression_pct:
|
|
198
|
+
failures.append(
|
|
199
|
+
f"Cost regressed by {cost_regression_pct:.1f}% (max: {max_cost_regression_pct}%)"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if failures:
|
|
203
|
+
print("Baseline regression detected:")
|
|
204
|
+
for failure in failures:
|
|
205
|
+
print(f" - {failure}")
|
|
206
|
+
return False
|
|
207
|
+
|
|
208
|
+
print("All metrics pass baseline thresholds.")
|
|
209
|
+
return True
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""CLI for aicert-pro baseline regression enforcement."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from .baseline import (
|
|
12
|
+
baseline_path,
|
|
13
|
+
check_from_run,
|
|
14
|
+
load_baseline,
|
|
15
|
+
save_from_run,
|
|
16
|
+
)
|
|
17
|
+
from .license import LicenseError, require_pro
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(
|
|
20
|
+
name="aicert-pro",
|
|
21
|
+
help="Pro baseline regression enforcement for aicert CLI.",
|
|
22
|
+
add_completion=False,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run_aicert(config_path: Path) -> Path:
|
|
27
|
+
"""Run aicert stability evaluation and return the run directory.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
config_path: Path to aicert.yaml configuration file.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Path to the run directory containing summary.json.
|
|
34
|
+
|
|
35
|
+
Exits:
|
|
36
|
+
With the same exit code if aicert fails.
|
|
37
|
+
"""
|
|
38
|
+
cmd = [sys.executable, "-m", "aicert", "stability", str(config_path), "--ci"]
|
|
39
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
40
|
+
|
|
41
|
+
if result.returncode != 0:
|
|
42
|
+
typer.echo(f"aicert failed:\n{result.stderr}", err=True)
|
|
43
|
+
raise typer.Exit(result.returncode)
|
|
44
|
+
|
|
45
|
+
# Try to extract run directory from stdout
|
|
46
|
+
# aicert outputs something like "Run complete. Results in: /path/to/run/dir"
|
|
47
|
+
for line in result.stdout.strip().split("\n"):
|
|
48
|
+
if "Run complete" in line or "Results in" in line:
|
|
49
|
+
# Extract path from line
|
|
50
|
+
parts = line.split()
|
|
51
|
+
for part in parts:
|
|
52
|
+
if part.startswith("/") or (len(part) > 1 and part[1] == ":"):
|
|
53
|
+
run_dir = Path(part)
|
|
54
|
+
if run_dir.exists():
|
|
55
|
+
return run_dir
|
|
56
|
+
|
|
57
|
+
# Fallback: assume default artifact location
|
|
58
|
+
# Default is typically ~/.aicert/runs/<timestamp>
|
|
59
|
+
from datetime import datetime
|
|
60
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
61
|
+
run_dir = Path.home() / ".aicert" / "runs" / timestamp
|
|
62
|
+
|
|
63
|
+
if not run_dir.exists():
|
|
64
|
+
raise typer.BadParameter(f"Could not determine run directory from aicert output. "
|
|
65
|
+
f"Please specify run directory directly.")
|
|
66
|
+
|
|
67
|
+
return run_dir
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.command("license")
|
|
71
|
+
def license_verify() -> None:
|
|
72
|
+
"""Verify pro license."""
|
|
73
|
+
require_pro()
|
|
74
|
+
typer.echo("License is valid.")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
baseline_app = typer.Typer(help="Manage baselines for regression enforcement.")
|
|
78
|
+
app.add_typer(baseline_app, name="baseline")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@baseline_app.command("save")
|
|
82
|
+
def baseline_save(
|
|
83
|
+
path: Path = typer.Argument(
|
|
84
|
+
...,
|
|
85
|
+
help="Path to run directory or .yaml config file",
|
|
86
|
+
),
|
|
87
|
+
baseline_dir: Path | None = typer.Option(
|
|
88
|
+
None,
|
|
89
|
+
"--baseline-dir",
|
|
90
|
+
"-b",
|
|
91
|
+
help="Directory to store baselines",
|
|
92
|
+
),
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Save baseline metrics from a run directory or run aicert first."""
|
|
95
|
+
try:
|
|
96
|
+
require_pro()
|
|
97
|
+
except LicenseError:
|
|
98
|
+
raise typer.Exit(5)
|
|
99
|
+
|
|
100
|
+
# Determine if path is a config file or run directory
|
|
101
|
+
if path.suffix == ".yaml" or path.suffix == ".yml":
|
|
102
|
+
# Run aicert first
|
|
103
|
+
run_dir = run_aicert(path)
|
|
104
|
+
typer.echo(f"Evaluation complete. Run directory: {run_dir}")
|
|
105
|
+
else:
|
|
106
|
+
run_dir = path
|
|
107
|
+
|
|
108
|
+
data = save_from_run(run_dir, baseline_dir)
|
|
109
|
+
result_path = baseline_path(data["project"], baseline_dir)
|
|
110
|
+
typer.echo(f"Baseline saved to {result_path}")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@baseline_app.command("check")
|
|
114
|
+
def baseline_check(
|
|
115
|
+
path: Path = typer.Argument(
|
|
116
|
+
...,
|
|
117
|
+
help="Path to run directory or .yaml config file",
|
|
118
|
+
),
|
|
119
|
+
baseline_dir: Path | None = typer.Option(
|
|
120
|
+
None,
|
|
121
|
+
"--baseline-dir",
|
|
122
|
+
"-b",
|
|
123
|
+
help="Directory to store baselines",
|
|
124
|
+
),
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Check current run against baseline for regressions."""
|
|
127
|
+
try:
|
|
128
|
+
require_pro()
|
|
129
|
+
except LicenseError:
|
|
130
|
+
raise typer.Exit(5)
|
|
131
|
+
|
|
132
|
+
# Determine if path is a config file or run directory
|
|
133
|
+
if path.suffix == ".yaml" or path.suffix == ".yml":
|
|
134
|
+
# Run aicert first
|
|
135
|
+
run_dir = run_aicert(path)
|
|
136
|
+
typer.echo(f"Evaluation complete. Run directory: {run_dir}")
|
|
137
|
+
else:
|
|
138
|
+
run_dir = path
|
|
139
|
+
|
|
140
|
+
passed = check_from_run(run_dir, baseline_dir)
|
|
141
|
+
if not passed:
|
|
142
|
+
raise typer.Exit(2)
|
|
143
|
+
typer.echo("Baseline check passed.")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@baseline_app.command("show")
|
|
147
|
+
def baseline_show(
|
|
148
|
+
project: str = typer.Argument(..., help="Project name"),
|
|
149
|
+
baseline_dir: Path | None = typer.Option(
|
|
150
|
+
None,
|
|
151
|
+
"--baseline-dir",
|
|
152
|
+
"-b",
|
|
153
|
+
help="Directory to store baselines",
|
|
154
|
+
),
|
|
155
|
+
) -> None:
|
|
156
|
+
"""Show baseline metrics for a project."""
|
|
157
|
+
require_pro()
|
|
158
|
+
path = baseline_path(project, baseline_dir)
|
|
159
|
+
if not path.exists():
|
|
160
|
+
raise typer.BadParameter(f"Baseline not found for project '{project}'")
|
|
161
|
+
data = load_baseline(path)
|
|
162
|
+
typer.echo(json.dumps(data, indent=2))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def main() -> None:
|
|
166
|
+
"""Entry point for aicert-pro CLI."""
|
|
167
|
+
app()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
if __name__ == "__main__":
|
|
171
|
+
app()
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""License verification for aicert-pro using Ed25519 signatures."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from cryptography.hazmat.primitives.asymmetric import ed25519
|
|
11
|
+
from cryptography.hazmat.primitives import serialization
|
|
12
|
+
from cryptography.exceptions import InvalidSignature
|
|
13
|
+
|
|
14
|
+
# Embedded Ed25519 public key for license verification (placeholder)
|
|
15
|
+
# Replace with actual public key for production
|
|
16
|
+
LICENSE_PUBLIC_KEY = ed25519.Ed25519PublicKey.from_public_bytes(
|
|
17
|
+
base64.b64decode(
|
|
18
|
+
"ewWxgkhpOJpLGcQKky3gMuEKaBlvlEZjy9MJ6YYCpuY="
|
|
19
|
+
)
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
LICENSE_ENV_VAR = "AICERT_PRO_LICENSE"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LicenseError(Exception):
|
|
26
|
+
"""Base exception for license errors."""
|
|
27
|
+
|
|
28
|
+
exit_code = 5
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class LicenseMissingError(LicenseError):
|
|
32
|
+
"""Raised when license is not found."""
|
|
33
|
+
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
super().__init__("License not found. Set AICERT_PRO_LICENSE environment variable.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LicenseInvalidError(LicenseError):
|
|
39
|
+
"""Raised when license signature is invalid."""
|
|
40
|
+
|
|
41
|
+
def __init__(self) -> None:
|
|
42
|
+
super().__init__("License signature is invalid.")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class LicenseExpiredError(LicenseError):
|
|
46
|
+
"""Raised when license has expired."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, expired_at: str) -> None:
|
|
49
|
+
super().__init__(f"License expired at {expired_at}.")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class LicenseData:
|
|
54
|
+
"""License data parsed from base64-encoded JSON."""
|
|
55
|
+
|
|
56
|
+
license_id: str
|
|
57
|
+
owner: str
|
|
58
|
+
issued_at: str
|
|
59
|
+
expires_at: Optional[str] = None
|
|
60
|
+
features: Optional[list[str]] = None
|
|
61
|
+
signature: Optional[str] = None
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def is_expired(self) -> bool:
|
|
65
|
+
"""Check if license is expired."""
|
|
66
|
+
if self.expires_at is None:
|
|
67
|
+
return False
|
|
68
|
+
try:
|
|
69
|
+
expires = datetime.fromisoformat(self.expires_at.replace("Z", "+00:00"))
|
|
70
|
+
return datetime.now(timezone.utc) > expires
|
|
71
|
+
except ValueError:
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def verify_license(license_data: LicenseData) -> bool:
|
|
76
|
+
"""Verify Ed25519 signature on license data.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
license_data: Parsed license data including signature.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
True if signature is valid.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
LicenseInvalidError: If signature verification fails.
|
|
86
|
+
LicenseExpiredError: If license has expired.
|
|
87
|
+
"""
|
|
88
|
+
if license_data.is_expired:
|
|
89
|
+
raise LicenseExpiredError(license_data.expires_at)
|
|
90
|
+
|
|
91
|
+
if license_data.signature is None:
|
|
92
|
+
raise LicenseInvalidError()
|
|
93
|
+
|
|
94
|
+
# Create signable payload (license data without signature)
|
|
95
|
+
payload = f"{license_data.license_id}:{license_data.owner}:{license_data.issued_at}"
|
|
96
|
+
if license_data.expires_at:
|
|
97
|
+
payload += f":{license_data.expires_at}"
|
|
98
|
+
if license_data.features:
|
|
99
|
+
payload += f":{','.join(license_data.features)}"
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
signature_bytes = base64.b64decode(license_data.signature)
|
|
103
|
+
LICENSE_PUBLIC_KEY.verify(signature_bytes, payload.encode("utf-8"))
|
|
104
|
+
except (InvalidSignature, ValueError) as e:
|
|
105
|
+
raise LicenseInvalidError()
|
|
106
|
+
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def read_license_from_env() -> str:
|
|
111
|
+
"""Read license string from environment variable.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Base64-encoded license string.
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
LicenseMissingError: If environment variable is not set.
|
|
118
|
+
"""
|
|
119
|
+
license_str = os.environ.get(LICENSE_ENV_VAR)
|
|
120
|
+
if not license_str:
|
|
121
|
+
raise LicenseMissingError()
|
|
122
|
+
return license_str
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def require_pro() -> LicenseData:
|
|
126
|
+
"""Require and verify a valid pro license.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Parsed and verified license data.
|
|
130
|
+
|
|
131
|
+
Exits:
|
|
132
|
+
Exit code 5 if license is missing or invalid.
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
license_str = read_license_from_env()
|
|
136
|
+
import json
|
|
137
|
+
data = json.loads(base64.b64decode(license_str))
|
|
138
|
+
license_data = LicenseData(**data)
|
|
139
|
+
verify_license(license_data)
|
|
140
|
+
return license_data
|
|
141
|
+
except LicenseError:
|
|
142
|
+
raise
|
|
143
|
+
except Exception:
|
|
144
|
+
raise LicenseInvalidError()
|