epi-recorder 1.0.0__py3-none-any.whl → 1.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.
- epi_cli/__main__.py +7 -0
- epi_cli/keys.py +3 -3
- epi_cli/ls.py +160 -0
- epi_cli/main.py +73 -8
- epi_cli/record.py +10 -3
- epi_cli/run.py +318 -0
- epi_cli/verify.py +20 -20
- epi_cli/view.py +68 -13
- epi_core/__init__.py +1 -1
- epi_core/redactor.py +14 -1
- epi_core/schemas.py +34 -3
- epi_recorder/__init__.py +1 -1
- epi_recorder/api.py +217 -20
- epi_recorder/environment.py +21 -0
- epi_recorder/patcher.py +88 -7
- epi_recorder-1.1.1.dist-info/METADATA +569 -0
- epi_recorder-1.1.1.dist-info/RECORD +28 -0
- epi_viewer_static/app.js +77 -1
- epi_recorder-1.0.0.dist-info/METADATA +0 -503
- epi_recorder-1.0.0.dist-info/RECORD +0 -25
- {epi_recorder-1.0.0.dist-info → epi_recorder-1.1.1.dist-info}/WHEEL +0 -0
- {epi_recorder-1.0.0.dist-info → epi_recorder-1.1.1.dist-info}/entry_points.txt +0 -0
- {epi_recorder-1.0.0.dist-info → epi_recorder-1.1.1.dist-info}/licenses/LICENSE +0 -0
- {epi_recorder-1.0.0.dist-info → epi_recorder-1.1.1.dist-info}/top_level.txt +0 -0
epi_cli/__main__.py
ADDED
epi_cli/keys.py
CHANGED
|
@@ -234,7 +234,7 @@ def generate_default_keypair_if_missing(console_output: bool = True) -> bool:
|
|
|
234
234
|
private_path, public_path = key_manager.generate_keypair("default")
|
|
235
235
|
|
|
236
236
|
if console_output:
|
|
237
|
-
console.print("\n[bold green]
|
|
237
|
+
console.print("\n[bold green]Welcome to EPI![/bold green]")
|
|
238
238
|
console.print("\n[dim]Generated default Ed25519 key pair for signing:[/dim]")
|
|
239
239
|
console.print(f" [cyan]Private:[/cyan] {private_path}")
|
|
240
240
|
console.print(f" [cyan]Public:[/cyan] {public_path}")
|
|
@@ -260,8 +260,8 @@ def print_keys_table(keys: list[dict[str, str]]) -> None:
|
|
|
260
260
|
table.add_column("Public Key", style="blue")
|
|
261
261
|
|
|
262
262
|
for key in keys:
|
|
263
|
-
private_status = "
|
|
264
|
-
public_status = "
|
|
263
|
+
private_status = "[Y]" if key["has_private"] else "[N]"
|
|
264
|
+
public_status = "[Y]" if key["has_public"] else "[N]"
|
|
265
265
|
|
|
266
266
|
table.add_row(
|
|
267
267
|
key["name"],
|
epi_cli/ls.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""
|
|
2
|
+
EPI CLI Ls - List recordings in ./epi-recordings/ directory.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
epi ls
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import zipfile
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Dict, Any
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
|
|
18
|
+
from epi_core.container import EPIContainer
|
|
19
|
+
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(name="ls", help="List local recordings (./epi-recordings/)")
|
|
23
|
+
|
|
24
|
+
DEFAULT_DIR = Path("epi-recordings")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _format_metrics(metrics: Dict[str, Any]) -> str:
|
|
28
|
+
"""Format metrics dictionary as a compact string."""
|
|
29
|
+
if not metrics:
|
|
30
|
+
return ""
|
|
31
|
+
|
|
32
|
+
formatted = []
|
|
33
|
+
for key, value in metrics.items():
|
|
34
|
+
if isinstance(value, float):
|
|
35
|
+
# Format floats to 2 decimal places
|
|
36
|
+
formatted.append(f"{key}={value:.2f}")
|
|
37
|
+
else:
|
|
38
|
+
formatted.append(f"{key}={value}")
|
|
39
|
+
|
|
40
|
+
return ", ".join(formatted)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _get_recording_info(epi_file: Path) -> dict:
|
|
44
|
+
"""
|
|
45
|
+
Extract basic info from a recording.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Dictionary with recording metadata
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
# Read manifest
|
|
52
|
+
manifest = EPIContainer.read_manifest(epi_file)
|
|
53
|
+
|
|
54
|
+
# Get file stats
|
|
55
|
+
stats = epi_file.stat()
|
|
56
|
+
size_mb = stats.st_size / (1024 * 1024)
|
|
57
|
+
modified = datetime.fromtimestamp(stats.st_mtime)
|
|
58
|
+
|
|
59
|
+
# Extract CLI command if available
|
|
60
|
+
cli_command = getattr(manifest, 'cli_command', None)
|
|
61
|
+
|
|
62
|
+
# Extract originating script from cli_command
|
|
63
|
+
script = "Unknown"
|
|
64
|
+
if cli_command:
|
|
65
|
+
parts = cli_command.split()
|
|
66
|
+
for i, part in enumerate(parts):
|
|
67
|
+
if part.endswith('.py'):
|
|
68
|
+
script = Path(part).name
|
|
69
|
+
break
|
|
70
|
+
|
|
71
|
+
# Check signature
|
|
72
|
+
signed = "Yes" if manifest.signature else "No"
|
|
73
|
+
|
|
74
|
+
# Quick integrity check
|
|
75
|
+
integrity_ok, _ = EPIContainer.verify_integrity(epi_file)
|
|
76
|
+
status = "[OK]" if integrity_ok else "[FAIL]"
|
|
77
|
+
|
|
78
|
+
# Extract new metadata fields
|
|
79
|
+
goal = getattr(manifest, 'goal', None)
|
|
80
|
+
metrics = getattr(manifest, 'metrics', None)
|
|
81
|
+
tags = getattr(manifest, 'tags', None)
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
"name": epi_file.name,
|
|
85
|
+
"script": script,
|
|
86
|
+
"size_mb": f"{size_mb:.2f}",
|
|
87
|
+
"modified": modified.strftime("%Y-%m-%d %H:%M:%S"),
|
|
88
|
+
"signed": signed,
|
|
89
|
+
"status": status,
|
|
90
|
+
"goal": goal or "",
|
|
91
|
+
"metrics_summary": _format_metrics(metrics) if metrics else "",
|
|
92
|
+
"tags_summary": ", ".join(tags) if tags else ""
|
|
93
|
+
}
|
|
94
|
+
except Exception as e:
|
|
95
|
+
return {
|
|
96
|
+
"name": epi_file.name,
|
|
97
|
+
"script": "Error",
|
|
98
|
+
"size_mb": "?",
|
|
99
|
+
"modified": "?",
|
|
100
|
+
"signed": "?",
|
|
101
|
+
"status": f"[ERR] {str(e)[:20]}",
|
|
102
|
+
"goal": "",
|
|
103
|
+
"metrics_summary": "",
|
|
104
|
+
"tags_summary": ""
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.callback(invoke_without_command=True)
|
|
109
|
+
def ls(
|
|
110
|
+
all_dirs: bool = typer.Option(False, "--all", "-a", help="Search current directory too"),
|
|
111
|
+
):
|
|
112
|
+
"""
|
|
113
|
+
List local recordings in ./epi-recordings/ directory.
|
|
114
|
+
|
|
115
|
+
Shows created/verified status and originating script if available.
|
|
116
|
+
"""
|
|
117
|
+
# Find recordings
|
|
118
|
+
recordings = []
|
|
119
|
+
|
|
120
|
+
# Check default directory
|
|
121
|
+
if DEFAULT_DIR.exists():
|
|
122
|
+
recordings.extend(DEFAULT_DIR.glob("*.epi"))
|
|
123
|
+
|
|
124
|
+
# Optionally check current directory
|
|
125
|
+
if all_dirs:
|
|
126
|
+
recordings.extend(Path(".").glob("*.epi"))
|
|
127
|
+
|
|
128
|
+
# Remove duplicates
|
|
129
|
+
recordings = list(set(recordings))
|
|
130
|
+
recordings.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
|
131
|
+
|
|
132
|
+
if not recordings:
|
|
133
|
+
console.print("[yellow]No recordings found[/yellow]")
|
|
134
|
+
if not DEFAULT_DIR.exists():
|
|
135
|
+
console.print(f"[dim]Directory {DEFAULT_DIR} does not exist yet[/dim]")
|
|
136
|
+
console.print("[dim]Tip: Run 'epi run script.py' to create your first recording[/dim]")
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
# Build table
|
|
140
|
+
table = Table(title=f"EPI Recordings ({len(recordings)} found)")
|
|
141
|
+
table.add_column("Name", style="cyan", no_wrap=True)
|
|
142
|
+
table.add_column("Modified", style="dim")
|
|
143
|
+
table.add_column("Goal", style="blue", no_wrap=False)
|
|
144
|
+
table.add_column("Metrics", style="purple", no_wrap=False)
|
|
145
|
+
table.add_column("Tags", style="green", no_wrap=False)
|
|
146
|
+
|
|
147
|
+
for recording in recordings:
|
|
148
|
+
info = _get_recording_info(recording)
|
|
149
|
+
table.add_row(
|
|
150
|
+
info["name"],
|
|
151
|
+
info["modified"],
|
|
152
|
+
info["goal"][:50] + "..." if len(info["goal"]) > 50 else info["goal"],
|
|
153
|
+
info["metrics_summary"],
|
|
154
|
+
info["tags_summary"]
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
console.print()
|
|
158
|
+
console.print(table)
|
|
159
|
+
console.print()
|
|
160
|
+
console.print(f"[dim]Tip: View a recording with 'epi view <name>'[/dim]")
|
epi_cli/main.py
CHANGED
|
@@ -12,7 +12,31 @@ from epi_cli.keys import generate_default_keypair_if_missing
|
|
|
12
12
|
# Create Typer app
|
|
13
13
|
app = typer.Typer(
|
|
14
14
|
name="epi",
|
|
15
|
-
help="EPI - Evidence Packaged Infrastructure for AI workflows
|
|
15
|
+
help="""EPI - Evidence Packaged Infrastructure for AI workflows
|
|
16
|
+
|
|
17
|
+
Commands:
|
|
18
|
+
run <script.py> Record, auto-verify and open viewer. (Zero-config)
|
|
19
|
+
record --out <file.epi> -- <cmd...>
|
|
20
|
+
Advanced: record any command, exact output file.
|
|
21
|
+
verify <file.epi> Verify a recording's integrity.
|
|
22
|
+
view <file.epi|name> Open recording in browser (name resolves ./epi-recordings/).
|
|
23
|
+
ls List local recordings (./epi-recordings/).
|
|
24
|
+
keys Manage keys (list/generate/export) - advanced.
|
|
25
|
+
help Show this quickstart.
|
|
26
|
+
|
|
27
|
+
Quickstart (first 30s):
|
|
28
|
+
1) Install: pip install epi-recorder
|
|
29
|
+
2) Record (simplest): epi run my_script.py
|
|
30
|
+
-> Saved: ./epi-recordings/my_script_20251121_231501.epi
|
|
31
|
+
-> Verified: OK
|
|
32
|
+
-> Viewer: opened in browser
|
|
33
|
+
3) See recordings: epi ls
|
|
34
|
+
4) Open a recording: epi view my_script_20251121_231501
|
|
35
|
+
|
|
36
|
+
Tips:
|
|
37
|
+
- Want explicit name? Use the advanced command: epi record --out experiment.epi -- python my_script.py
|
|
38
|
+
- For scripts using the API, use @record decorator or with record(): no filenames needed.
|
|
39
|
+
""",
|
|
16
40
|
add_completion=False,
|
|
17
41
|
no_args_is_help=True,
|
|
18
42
|
rich_markup_mode="rich"
|
|
@@ -40,20 +64,61 @@ def version():
|
|
|
40
64
|
console.print("[dim]The PDF for AI workflows[/dim]")
|
|
41
65
|
|
|
42
66
|
|
|
67
|
+
@app.command(name="help")
|
|
68
|
+
def show_help():
|
|
69
|
+
"""Show extended quickstart help."""
|
|
70
|
+
help_text = """[bold cyan]EPI Recorder - Quickstart Guide[/bold cyan]
|
|
71
|
+
|
|
72
|
+
[bold]Usage:[/bold] epi <command> [options]
|
|
73
|
+
|
|
74
|
+
[bold]Commands:[/bold]
|
|
75
|
+
[cyan]run[/cyan] <script.py> Record, auto-verify and open viewer. (Zero-config)
|
|
76
|
+
[cyan]record[/cyan] --out <file.epi> -- <cmd...>
|
|
77
|
+
Advanced: record any command, exact output file.
|
|
78
|
+
[cyan]verify[/cyan] <file.epi> Verify a recording's integrity.
|
|
79
|
+
[cyan]view[/cyan] <file.epi|name> Open recording in browser (name resolves ./epi-recordings/).
|
|
80
|
+
[cyan]ls[/cyan] List local recordings (./epi-recordings/).
|
|
81
|
+
[cyan]keys[/cyan] Manage keys (list/generate/export) - advanced.
|
|
82
|
+
[cyan]help[/cyan] Show this quickstart.
|
|
83
|
+
|
|
84
|
+
[bold]Quickstart (first 30s):[/bold]
|
|
85
|
+
1) Install: pip install epi-recorder
|
|
86
|
+
2) Record (simplest): [green]epi run my_script.py[/green]
|
|
87
|
+
-> Saved: ./epi-recordings/my_script_20251121_231501.epi
|
|
88
|
+
-> Verified: OK
|
|
89
|
+
-> Viewer: opened in browser
|
|
90
|
+
3) See recordings: [green]epi ls[/green]
|
|
91
|
+
4) Open a recording: [green]epi view my_script_20251121_231501[/green]
|
|
92
|
+
|
|
93
|
+
[bold]Tips:[/bold]
|
|
94
|
+
- Want explicit name? Use the advanced command: epi record --out experiment.epi -- python my_script.py
|
|
95
|
+
- For scripts using the API, use @record decorator or with record(): no filenames needed.
|
|
96
|
+
"""
|
|
97
|
+
console.print(help_text)
|
|
98
|
+
|
|
99
|
+
|
|
43
100
|
# Import and register subcommands
|
|
44
101
|
# These will be added as they're implemented
|
|
45
102
|
|
|
103
|
+
# NEW: run command (zero-config) - direct import
|
|
104
|
+
from epi_cli.run import run as run_command
|
|
105
|
+
app.command(name="run", help="Record, auto-verify and open viewer. (Zero-config)")(run_command)
|
|
106
|
+
|
|
46
107
|
# Phase 1: verify command
|
|
47
108
|
from epi_cli.verify import verify_app
|
|
48
109
|
app.add_typer(verify_app, name="verify", help="Verify .epi file integrity and authenticity")
|
|
49
110
|
|
|
50
|
-
# Phase 2: record command
|
|
111
|
+
# Phase 2: record command (legacy/advanced)
|
|
51
112
|
from epi_cli.record import app as record_app
|
|
52
|
-
app.add_typer(record_app, name="record", help="
|
|
113
|
+
app.add_typer(record_app, name="record", help="Advanced: record any command, exact output file.")
|
|
53
114
|
|
|
54
115
|
# Phase 3: view command
|
|
55
116
|
from epi_cli.view import app as view_app
|
|
56
|
-
app.add_typer(view_app, name="view", help="
|
|
117
|
+
app.add_typer(view_app, name="view", help="Open recording in browser (name resolves ./epi-recordings/)")
|
|
118
|
+
|
|
119
|
+
# NEW: ls command
|
|
120
|
+
from epi_cli.ls import ls as ls_command
|
|
121
|
+
app.command(name="ls", help="List local recordings (./epi-recordings/)")(ls_command)
|
|
57
122
|
|
|
58
123
|
# Phase 1: keys command (for manual key management)
|
|
59
124
|
@app.command()
|
|
@@ -70,11 +135,11 @@ def keys(
|
|
|
70
135
|
if action == "generate":
|
|
71
136
|
try:
|
|
72
137
|
private_path, public_path = key_manager.generate_keypair(name, overwrite=overwrite)
|
|
73
|
-
console.print(f"\n[bold green]
|
|
138
|
+
console.print(f"\n[bold green][OK] Generated key pair:[/bold green] {name}")
|
|
74
139
|
console.print(f" [cyan]Private:[/cyan] {private_path}")
|
|
75
140
|
console.print(f" [cyan]Public:[/cyan] {public_path}\n")
|
|
76
141
|
except FileExistsError as e:
|
|
77
|
-
console.print(f"[red]
|
|
142
|
+
console.print(f"[red][FAIL] Error:[/red] {e}")
|
|
78
143
|
raise typer.Exit(1)
|
|
79
144
|
|
|
80
145
|
elif action == "list":
|
|
@@ -87,11 +152,11 @@ def keys(
|
|
|
87
152
|
console.print(f"\n[bold]Public key for '{name}':[/bold]")
|
|
88
153
|
console.print(f"[cyan]{public_key_b64}[/cyan]\n")
|
|
89
154
|
except FileNotFoundError as e:
|
|
90
|
-
console.print(f"[red]
|
|
155
|
+
console.print(f"[red][FAIL] Error:[/red] {e}")
|
|
91
156
|
raise typer.Exit(1)
|
|
92
157
|
|
|
93
158
|
else:
|
|
94
|
-
console.print(f"[red]
|
|
159
|
+
console.print(f"[red][FAIL] Unknown action:[/red] {action}")
|
|
95
160
|
console.print("[dim]Valid actions: generate, list, export[/dim]")
|
|
96
161
|
raise typer.Exit(1)
|
|
97
162
|
|
epi_cli/record.py
CHANGED
|
@@ -94,10 +94,17 @@ def record(
|
|
|
94
94
|
):
|
|
95
95
|
"""
|
|
96
96
|
Record a command and package the run into a .epi file.
|
|
97
|
+
|
|
98
|
+
[NOTICE] For simpler usage, try: epi run script.py
|
|
99
|
+
This command (epi record --out) is for advanced/CI use cases.
|
|
97
100
|
"""
|
|
98
101
|
if not command:
|
|
99
|
-
console.print("[red]
|
|
102
|
+
console.print("[red][FAIL] No command provided[/red]")
|
|
100
103
|
raise typer.Exit(1)
|
|
104
|
+
|
|
105
|
+
# Show deprecation notice
|
|
106
|
+
console.print("[dim][NOTICE] For simpler usage, try: epi run script.py[/dim]")
|
|
107
|
+
console.print("[dim]This advanced command is for CI/exact-control use cases.[/dim]\n")
|
|
101
108
|
|
|
102
109
|
# Normalize command
|
|
103
110
|
cmd = _ensure_python_command(command)
|
|
@@ -171,11 +178,11 @@ def record(
|
|
|
171
178
|
temp_zip.replace(out)
|
|
172
179
|
signed = True
|
|
173
180
|
except Exception as e:
|
|
174
|
-
console.print(f"[yellow]
|
|
181
|
+
console.print(f"[yellow][WARN] Signing failed:[/yellow] {e}")
|
|
175
182
|
|
|
176
183
|
# Final output panel
|
|
177
184
|
size_mb = out.stat().st_size / (1024 * 1024)
|
|
178
|
-
title = "
|
|
185
|
+
title = "[OK] Recording complete" if rc == 0 else "[WARN] Recording finished with errors"
|
|
179
186
|
panel = Panel(
|
|
180
187
|
f"[bold]File:[/bold] {out}\n"
|
|
181
188
|
f"[bold]Size:[/bold] {size_mb:.1f} MB\n"
|
epi_cli/run.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""
|
|
2
|
+
EPI CLI Run - Zero-config recording command.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
epi run script.py
|
|
6
|
+
|
|
7
|
+
This command:
|
|
8
|
+
- Auto-generates output filename in ./epi-recordings/
|
|
9
|
+
- Records the script execution
|
|
10
|
+
- Verifies the recording
|
|
11
|
+
- Opens the viewer automatically
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import shlex
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
import time
|
|
19
|
+
import zipfile
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import List, Optional
|
|
23
|
+
|
|
24
|
+
import typer
|
|
25
|
+
from rich.console import Console
|
|
26
|
+
from rich.panel import Panel
|
|
27
|
+
|
|
28
|
+
from epi_core.container import EPIContainer
|
|
29
|
+
from epi_core.schemas import ManifestModel
|
|
30
|
+
from epi_core.trust import verify_signature, get_signer_name, create_verification_report
|
|
31
|
+
from epi_cli.keys import KeyManager
|
|
32
|
+
from epi_recorder.environment import save_environment_snapshot
|
|
33
|
+
|
|
34
|
+
console = Console()
|
|
35
|
+
|
|
36
|
+
app = typer.Typer(name="run", help="Zero-config recording: epi run my_script.py")
|
|
37
|
+
|
|
38
|
+
DEFAULT_DIR = Path("epi-recordings")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _gen_auto_name(script_path: Path) -> Path:
|
|
42
|
+
"""
|
|
43
|
+
Generate automatic output filename in ./epi-recordings/ directory.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
script_path: Path to the script being recorded
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Path to the .epi file
|
|
50
|
+
"""
|
|
51
|
+
base = script_path.stem if script_path.name != "-" else "recording"
|
|
52
|
+
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
|
53
|
+
DEFAULT_DIR.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
return DEFAULT_DIR / f"{base}_{timestamp}.epi"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _ensure_python_command(cmd: List[str]) -> List[str]:
|
|
58
|
+
"""
|
|
59
|
+
Ensure the command is run with Python if it looks like a Python script.
|
|
60
|
+
"""
|
|
61
|
+
if not cmd:
|
|
62
|
+
return cmd
|
|
63
|
+
first = cmd[0]
|
|
64
|
+
if first.lower().endswith('.py'):
|
|
65
|
+
return [sys.executable] + cmd
|
|
66
|
+
return cmd
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _build_env_for_child(steps_dir: Path, enable_redaction: bool) -> dict:
|
|
70
|
+
"""
|
|
71
|
+
Build environment variables for child process to enable recording via sitecustomize.
|
|
72
|
+
"""
|
|
73
|
+
env = os.environ.copy()
|
|
74
|
+
|
|
75
|
+
# Indicate recording mode and where to write steps
|
|
76
|
+
env["EPI_RECORD"] = "1"
|
|
77
|
+
env["EPI_STEPS_DIR"] = str(steps_dir)
|
|
78
|
+
env["EPI_REDACT"] = "1" if enable_redaction else "0"
|
|
79
|
+
|
|
80
|
+
# Create a temporary bootstrap dir with sitecustomize.py
|
|
81
|
+
bootstrap_dir = Path(tempfile.mkdtemp(prefix="epi_bootstrap_"))
|
|
82
|
+
sitecustomize = bootstrap_dir / "sitecustomize.py"
|
|
83
|
+
sitecustomize.write_text(
|
|
84
|
+
"from epi_recorder.bootstrap import initialize_recording\n",
|
|
85
|
+
encoding="utf-8",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Prepend bootstrap dir and project root to PYTHONPATH
|
|
89
|
+
project_root = Path(__file__).resolve().parent.parent
|
|
90
|
+
existing = env.get("PYTHONPATH", "")
|
|
91
|
+
sep = os.pathsep
|
|
92
|
+
env["PYTHONPATH"] = f"{bootstrap_dir}{sep}{project_root}{(sep + existing) if existing else ''}"
|
|
93
|
+
|
|
94
|
+
return env
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _verify_recording(epi_file: Path) -> tuple[bool, str]:
|
|
98
|
+
"""
|
|
99
|
+
Verify the recording and return status.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
(success, message) tuple
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
manifest = EPIContainer.read_manifest(epi_file)
|
|
106
|
+
integrity_ok, mismatches = EPIContainer.verify_integrity(epi_file)
|
|
107
|
+
|
|
108
|
+
if not integrity_ok:
|
|
109
|
+
return False, f"Integrity check failed ({len(mismatches)} mismatches)"
|
|
110
|
+
|
|
111
|
+
# Check signature
|
|
112
|
+
if manifest.signature:
|
|
113
|
+
signer_name = get_signer_name(manifest.signature)
|
|
114
|
+
key_manager = KeyManager()
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
public_key = key_manager.load_public_key(signer_name or "default")
|
|
118
|
+
signature_valid, msg = verify_signature(manifest, public_key)
|
|
119
|
+
|
|
120
|
+
if signature_valid:
|
|
121
|
+
return True, "OK (signed & verified)"
|
|
122
|
+
else:
|
|
123
|
+
return False, f"Signature invalid: {msg}"
|
|
124
|
+
except FileNotFoundError:
|
|
125
|
+
return True, "OK (unsigned - no public key)"
|
|
126
|
+
else:
|
|
127
|
+
return True, "OK (unsigned)"
|
|
128
|
+
|
|
129
|
+
except Exception as e:
|
|
130
|
+
return False, f"Verification failed: {e}"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _open_viewer(epi_file: Path) -> bool:
|
|
134
|
+
"""
|
|
135
|
+
Open the viewer for the recording.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
True if opened successfully
|
|
139
|
+
"""
|
|
140
|
+
try:
|
|
141
|
+
import webbrowser
|
|
142
|
+
|
|
143
|
+
# Extract viewer to temp location
|
|
144
|
+
temp_dir = Path(tempfile.mkdtemp(prefix="epi_view_"))
|
|
145
|
+
viewer_path = temp_dir / "viewer.html"
|
|
146
|
+
|
|
147
|
+
with zipfile.ZipFile(epi_file, "r") as zf:
|
|
148
|
+
if "viewer.html" in zf.namelist():
|
|
149
|
+
zf.extract("viewer.html", temp_dir)
|
|
150
|
+
file_url = viewer_path.as_uri()
|
|
151
|
+
return webbrowser.open(file_url)
|
|
152
|
+
|
|
153
|
+
return False
|
|
154
|
+
except Exception:
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@app.command()
|
|
159
|
+
def run(
|
|
160
|
+
script: Path = typer.Argument(..., help="Python script to record"),
|
|
161
|
+
no_verify: bool = typer.Option(False, "--no-verify", help="Skip verification"),
|
|
162
|
+
no_open: bool = typer.Option(False, "--no-open", help="Don't open viewer automatically"),
|
|
163
|
+
# New metadata options
|
|
164
|
+
goal: Optional[str] = typer.Option(None, "--goal", help="Goal or objective of this workflow"),
|
|
165
|
+
notes: Optional[str] = typer.Option(None, "--notes", help="Additional notes about this workflow"),
|
|
166
|
+
metric: Optional[List[str]] = typer.Option(None, "--metric", help="Key=value metrics (can be used multiple times)"),
|
|
167
|
+
approved_by: Optional[str] = typer.Option(None, "--approved-by", help="Person who approved this workflow"),
|
|
168
|
+
tag: Optional[List[str]] = typer.Option(None, "--tag", help="Tags for categorizing this workflow (can be used multiple times)"),
|
|
169
|
+
):
|
|
170
|
+
"""
|
|
171
|
+
Zero-config recording: record + verify + view.
|
|
172
|
+
|
|
173
|
+
Example:
|
|
174
|
+
epi run my_script.py
|
|
175
|
+
epi run script.py --goal "improve accuracy" --notes "test run" --metric accuracy=0.92 --metric latency=210 --approved-by "bob" --tag test --tag v1
|
|
176
|
+
"""
|
|
177
|
+
# Validate script exists
|
|
178
|
+
if not script.exists():
|
|
179
|
+
console.print(f"[red][FAIL] Error:[/red] Script not found: {script}")
|
|
180
|
+
raise typer.Exit(1)
|
|
181
|
+
|
|
182
|
+
# Parse metrics if provided
|
|
183
|
+
metrics_dict = None
|
|
184
|
+
if metric:
|
|
185
|
+
metrics_dict = {}
|
|
186
|
+
for m in metric:
|
|
187
|
+
if "=" in m:
|
|
188
|
+
key, value = m.split("=", 1)
|
|
189
|
+
# Try to convert to float if possible, otherwise keep as string
|
|
190
|
+
try:
|
|
191
|
+
metrics_dict[key] = float(value)
|
|
192
|
+
except ValueError:
|
|
193
|
+
metrics_dict[key] = value
|
|
194
|
+
else:
|
|
195
|
+
console.print(f"[yellow]Warning:[/yellow] Invalid metric format: {m} (expected key=value)")
|
|
196
|
+
|
|
197
|
+
# Auto-generate output filename
|
|
198
|
+
out = _gen_auto_name(script)
|
|
199
|
+
|
|
200
|
+
# Normalize command
|
|
201
|
+
cmd = _ensure_python_command([str(script)])
|
|
202
|
+
|
|
203
|
+
# Prepare workspace
|
|
204
|
+
temp_workspace = Path(tempfile.mkdtemp(prefix="epi_record_"))
|
|
205
|
+
steps_dir = temp_workspace
|
|
206
|
+
env_json = temp_workspace / "env.json"
|
|
207
|
+
|
|
208
|
+
# Capture environment snapshot
|
|
209
|
+
save_environment_snapshot(env_json, include_all_env_vars=False, redact_env_vars=True)
|
|
210
|
+
|
|
211
|
+
# Build child environment and run
|
|
212
|
+
child_env = _build_env_for_child(steps_dir, enable_redaction=True)
|
|
213
|
+
|
|
214
|
+
# Create stdout/stderr logs
|
|
215
|
+
stdout_log = temp_workspace / "stdout.log"
|
|
216
|
+
stderr_log = temp_workspace / "stderr.log"
|
|
217
|
+
|
|
218
|
+
console.print(f"[dim]Recording:[/dim] {script.name}")
|
|
219
|
+
|
|
220
|
+
import subprocess
|
|
221
|
+
|
|
222
|
+
start = time.time()
|
|
223
|
+
with open(stdout_log, "wb") as out_f, open(stderr_log, "wb") as err_f:
|
|
224
|
+
proc = subprocess.Popen(cmd, env=child_env, stdout=out_f, stderr=err_f)
|
|
225
|
+
rc = proc.wait()
|
|
226
|
+
duration = round(time.time() - start, 3)
|
|
227
|
+
|
|
228
|
+
# Build manifest with metadata
|
|
229
|
+
manifest = ManifestModel(
|
|
230
|
+
cli_command=" ".join(shlex.quote(c) for c in cmd),
|
|
231
|
+
goal=goal,
|
|
232
|
+
notes=notes,
|
|
233
|
+
metrics=metrics_dict,
|
|
234
|
+
approved_by=approved_by,
|
|
235
|
+
tags=tag
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# Package into .epi
|
|
239
|
+
EPIContainer.pack(temp_workspace, manifest, out)
|
|
240
|
+
|
|
241
|
+
# Auto-sign
|
|
242
|
+
signed = False
|
|
243
|
+
try:
|
|
244
|
+
km = KeyManager()
|
|
245
|
+
priv = km.load_private_key("default")
|
|
246
|
+
|
|
247
|
+
# Read manifest from ZIP
|
|
248
|
+
import json as _json
|
|
249
|
+
with zipfile.ZipFile(out, "r") as zf:
|
|
250
|
+
raw = zf.read("manifest.json").decode("utf-8")
|
|
251
|
+
data = _json.loads(raw)
|
|
252
|
+
|
|
253
|
+
# Sign manifest
|
|
254
|
+
from epi_core.schemas import ManifestModel as _MM
|
|
255
|
+
from epi_core.trust import sign_manifest as _sign
|
|
256
|
+
m = _MM(**data)
|
|
257
|
+
sm = _sign(m, priv, "default")
|
|
258
|
+
signed_json = sm.model_dump_json(indent=2)
|
|
259
|
+
|
|
260
|
+
# Replace manifest in ZIP
|
|
261
|
+
temp_zip = out.with_suffix(".epi.tmp")
|
|
262
|
+
with zipfile.ZipFile(out, "r") as zf_in:
|
|
263
|
+
with zipfile.ZipFile(temp_zip, "w", zipfile.ZIP_DEFLATED) as zf_out:
|
|
264
|
+
for item in zf_in.namelist():
|
|
265
|
+
if item != "manifest.json":
|
|
266
|
+
zf_out.writestr(item, zf_in.read(item))
|
|
267
|
+
zf_out.writestr("manifest.json", signed_json)
|
|
268
|
+
|
|
269
|
+
temp_zip.replace(out)
|
|
270
|
+
signed = True
|
|
271
|
+
except Exception:
|
|
272
|
+
pass # Non-fatal
|
|
273
|
+
|
|
274
|
+
# Verify
|
|
275
|
+
verified = False
|
|
276
|
+
verify_msg = "Skipped"
|
|
277
|
+
if not no_verify:
|
|
278
|
+
verified, verify_msg = _verify_recording(out)
|
|
279
|
+
|
|
280
|
+
# Open viewer
|
|
281
|
+
viewer_opened = False
|
|
282
|
+
if not no_open and rc == 0 and verified:
|
|
283
|
+
viewer_opened = _open_viewer(out)
|
|
284
|
+
|
|
285
|
+
# Print results
|
|
286
|
+
size_mb = out.stat().st_size / (1024 * 1024)
|
|
287
|
+
|
|
288
|
+
lines = []
|
|
289
|
+
lines.append(f"[bold]Saved:[/bold] {out}")
|
|
290
|
+
lines.append(f"[bold]Size:[/bold] {size_mb:.2f} MB")
|
|
291
|
+
lines.append(f"[bold]Duration:[/bold] {duration}s")
|
|
292
|
+
|
|
293
|
+
if not no_verify:
|
|
294
|
+
if verified:
|
|
295
|
+
lines.append(f"[bold]Verified:[/bold] [green]{verify_msg}[/green]")
|
|
296
|
+
else:
|
|
297
|
+
lines.append(f"[bold]Verified:[/bold] [red]{verify_msg}[/red]")
|
|
298
|
+
|
|
299
|
+
if viewer_opened:
|
|
300
|
+
lines.append(f"[bold]Viewer:[/bold] [green]Opened in browser[/green]")
|
|
301
|
+
elif not no_open:
|
|
302
|
+
lines.append(f"[bold]Viewer:[/bold] [yellow]Could not open automatically[/yellow]")
|
|
303
|
+
lines.append(f"[dim]Open with:[/dim] epi view {out.name}")
|
|
304
|
+
|
|
305
|
+
title = "[OK] Recording complete" if rc == 0 else "[WARN] Recording finished with errors"
|
|
306
|
+
panel = Panel(
|
|
307
|
+
"\n".join(lines),
|
|
308
|
+
title=title,
|
|
309
|
+
border_style="green" if rc == 0 else "yellow",
|
|
310
|
+
)
|
|
311
|
+
console.print(panel)
|
|
312
|
+
|
|
313
|
+
# Exit with appropriate code
|
|
314
|
+
if rc != 0:
|
|
315
|
+
raise typer.Exit(rc)
|
|
316
|
+
if not verified and not no_verify:
|
|
317
|
+
raise typer.Exit(1)
|
|
318
|
+
raise typer.Exit(0)
|