trap-cli 0.0.6__tar.gz → 0.0.8__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.
- {trap_cli-0.0.6 → trap_cli-0.0.8}/PKG-INFO +1 -1
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/_version.py +2 -2
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/cli/__init__.py +66 -39
- trap_cli-0.0.8/src/trap/display/__init__.py +19 -0
- trap_cli-0.0.6/src/trap/report/rich.py → trap_cli-0.0.8/src/trap/display/report.py +23 -1
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/git_ops/url.py +15 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/loader/trap_yaml.py +1 -1
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/loader/traptask_yaml.py +22 -10
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/runner/task.py +0 -14
- trap_cli-0.0.8/src/trap/workspace/__init__.py +23 -0
- trap_cli-0.0.8/src/trap/workspace/identity.py +58 -0
- trap_cli-0.0.8/src/trap/workspace/store.py +113 -0
- trap_cli-0.0.6/src/trap/display/__init__.py +0 -4
- trap_cli-0.0.6/src/trap/report/__init__.py +0 -19
- trap_cli-0.0.6/src/trap/report/base.py +0 -8
- trap_cli-0.0.6/src/trap/report/handle.py +0 -54
- trap_cli-0.0.6/src/trap/report/json.py +0 -11
- {trap_cli-0.0.6 → trap_cli-0.0.8}/.gitignore +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/LICENSE +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/README.md +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/examples/echo/task/README.md +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/pyproject.toml +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/__init__.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/auth/__init__.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/auth/client.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/auth/login.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/auth/oauth.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/auth/store.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/cli/_auth.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/cli/_console.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/cost/__init__.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/cost/calculator.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/cost/providers.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/cost/proxy.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/display/progress.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/display/submit.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/environment/__init__.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/environment/detector.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/git_ops/__init__.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/git_ops/base.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/git_ops/local.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/git_ops/remote.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/git_ops/rev.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/loader/__init__.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/loader/errors.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/models/__init__.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/models/cost.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/models/environment.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/models/provenance.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/models/report.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/models/results.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/models/trap_yaml.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/models/traptask_yaml.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/runner/__init__.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/runner/capture.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/runner/grader.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/runner/judge.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/runner/layout.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/runner/proc.py +0 -0
- {trap_cli-0.0.6 → trap_cli-0.0.8}/src/trap/runner/solution.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: trap-cli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.8
|
|
4
4
|
Summary: tp — non-invasive CLI testing framework for AI workflows. Submits results to trapstreet.run.
|
|
5
5
|
Project-URL: Homepage, https://trapstreet.run
|
|
6
6
|
Project-URL: Repository, https://github.com/trapstreet/trap
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 0,
|
|
21
|
+
__version__ = version = '0.0.8'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 0, 8)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -13,13 +13,13 @@ from trap import __version__
|
|
|
13
13
|
from trap.auth import DEFAULT_SERVER, ApiClient, ApiError, AuthStore
|
|
14
14
|
from trap.cli._auth import auth_app
|
|
15
15
|
from trap.cli._console import _die, _env_truthy, console, err_console
|
|
16
|
-
from trap.display import CaseProgress, render_submit_result
|
|
16
|
+
from trap.display import CaseProgress, OutputFormat, render_submit_result, renderer_factory
|
|
17
17
|
from trap.environment import EnvironmentDetector
|
|
18
18
|
from trap.git_ops import GitOpsError, LocalRepo, ParsedGitUrl
|
|
19
19
|
from trap.loader import ConfigError, TrapLoader, TraptaskLoader
|
|
20
|
-
from trap.models import Provenance
|
|
21
|
-
from trap.report import OutputFormat, ReportHandle, renderer_factory
|
|
20
|
+
from trap.models import Provenance, ReportData
|
|
22
21
|
from trap.runner import TaskRunner
|
|
22
|
+
from trap.workspace import SolutionIdentity, Workspace
|
|
23
23
|
|
|
24
24
|
app = typer.Typer(help="AI prompt / agent / workflow / testing framework.")
|
|
25
25
|
app.add_typer(auth_app, name="auth")
|
|
@@ -46,6 +46,22 @@ def _main(
|
|
|
46
46
|
"""AI prompt / agent / workflow / testing framework."""
|
|
47
47
|
|
|
48
48
|
|
|
49
|
+
def _no_report(workspace: Workspace, error: FileNotFoundError) -> typer.Exit:
|
|
50
|
+
"""Exit for a report miss, with advice matching the likely cause: an empty
|
|
51
|
+
workspace means the user never ran; existing runs under *other* solution keys
|
|
52
|
+
mean a cwd / SOLUTION mismatch, where re-running would just miss again."""
|
|
53
|
+
msg = str(error)
|
|
54
|
+
if keys := workspace.solution_keys():
|
|
55
|
+
msg += (
|
|
56
|
+
"\nsolutions with runs in this workspace: "
|
|
57
|
+
+ ", ".join(keys)
|
|
58
|
+
+ "\ncheck that SOLUTION (and the cwd) match the ones used for [bold]tp run[/bold]"
|
|
59
|
+
)
|
|
60
|
+
else:
|
|
61
|
+
msg += ". Run [bold]tp run[/bold] first."
|
|
62
|
+
return _die(msg)
|
|
63
|
+
|
|
64
|
+
|
|
49
65
|
def _confirm_remote(url: str, *, trust: bool) -> None:
|
|
50
66
|
"""Gate trap's auto-download-and-run of a remote (git+ URL) source — the one place
|
|
51
67
|
trap fetches and executes code the user may not have seen. Returns to proceed;
|
|
@@ -103,14 +119,19 @@ def _confirm_unanchored(provenance: Provenance, *, allow: bool) -> None:
|
|
|
103
119
|
|
|
104
120
|
@app.command()
|
|
105
121
|
def run(
|
|
106
|
-
task: Annotated[str | None, typer.Argument()] = None,
|
|
107
122
|
solution: Annotated[
|
|
108
123
|
str | None,
|
|
109
|
-
typer.
|
|
124
|
+
typer.Argument(help="Solution to run: a local path or a git+ URL (default: cwd)."),
|
|
110
125
|
] = None,
|
|
126
|
+
task: Annotated[
|
|
127
|
+
str | None,
|
|
128
|
+
typer.Option("--task", help="Task alias from trap.yaml (default: the first task)."),
|
|
129
|
+
] = None,
|
|
130
|
+
workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(Workspace.DEFAULT_DIRNAME),
|
|
131
|
+
output: Annotated[OutputFormat, typer.Option("--output", "-o")] = OutputFormat.rich,
|
|
111
132
|
clone_to: Annotated[
|
|
112
133
|
Path | None,
|
|
113
|
-
typer.Option("--clone-to", help="Where to clone a git+ URL
|
|
134
|
+
typer.Option("--clone-to", help="Where to clone a git+ URL SOLUTION (default: ./<repo>)."),
|
|
114
135
|
] = None,
|
|
115
136
|
trust_remote: Annotated[
|
|
116
137
|
bool,
|
|
@@ -129,7 +150,6 @@ def run(
|
|
|
129
150
|
),
|
|
130
151
|
] = False,
|
|
131
152
|
tags: Annotated[list[str] | None, typer.Option("--tag", "-t")] = None,
|
|
132
|
-
output: Annotated[OutputFormat, typer.Option("--output", "-o")] = OutputFormat.rich,
|
|
133
153
|
fail_fast: Annotated[bool, typer.Option("--fail-fast")] = False,
|
|
134
154
|
setup_solution: Annotated[
|
|
135
155
|
bool,
|
|
@@ -145,7 +165,6 @@ def run(
|
|
|
145
165
|
help="Force-run the task's setup_cmd even when no remote pull brought new code.",
|
|
146
166
|
),
|
|
147
167
|
] = False,
|
|
148
|
-
workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(".trap"),
|
|
149
168
|
environment: Annotated[
|
|
150
169
|
bool,
|
|
151
170
|
typer.Option(
|
|
@@ -163,11 +182,11 @@ def run(
|
|
|
163
182
|
) -> None:
|
|
164
183
|
"""Run a task against a solution.
|
|
165
184
|
|
|
166
|
-
|
|
167
|
-
|
|
185
|
+
SOLUTION is a local path, or a git+ URL to clone into ./<repo> (or
|
|
186
|
+
--clone-to). Omit it to use the trap.yaml in the cwd.
|
|
168
187
|
"""
|
|
169
188
|
# Gate trap's auto-download-and-run of any remote source before it happens — once
|
|
170
|
-
# for a remote
|
|
189
|
+
# for a remote SOLUTION, once for a remote task source (resolved from trap.yaml).
|
|
171
190
|
trust = trust_remote or _env_truthy("TRAP_TRUST_REMOTE")
|
|
172
191
|
if solution is not None and ParsedGitUrl.looks_remote(solution):
|
|
173
192
|
_confirm_remote(solution, trust=trust)
|
|
@@ -184,8 +203,8 @@ def run(
|
|
|
184
203
|
task_binding = trap_yaml_loader.resolve_task(task)
|
|
185
204
|
if ParsedGitUrl.looks_remote(task_binding.source):
|
|
186
205
|
_confirm_remote(task_binding.source, trust=trust)
|
|
187
|
-
traptask_yaml_loader = TraptaskLoader.
|
|
188
|
-
task_binding, trap_yaml_loader.trap_dir, setup=setup_task
|
|
206
|
+
traptask_yaml_loader = TraptaskLoader.from_task_binding(
|
|
207
|
+
task_binding, trap_yaml_loader.trap_dir, setup=setup_task, workspace_root=workspace.resolve()
|
|
189
208
|
)
|
|
190
209
|
except (GitOpsError, ConfigError, subprocess.CalledProcessError) as e:
|
|
191
210
|
raise _die(e) from None
|
|
@@ -204,14 +223,14 @@ def run(
|
|
|
204
223
|
|
|
205
224
|
started_at_local = datetime.now()
|
|
206
225
|
ts = started_at_local.isoformat(timespec="seconds")
|
|
207
|
-
|
|
226
|
+
ws = Workspace(workspace.resolve(), SolutionIdentity.from_spec(solution).dirname, task_binding.alias)
|
|
208
227
|
|
|
209
228
|
runner = TaskRunner(
|
|
210
229
|
trap_config=trap_yaml_loader.config,
|
|
211
230
|
trap_dir=trap_yaml_loader.trap_dir,
|
|
212
231
|
traptask_config=traptask_yaml_loader.traptask,
|
|
213
232
|
traptask_dir=traptask_yaml_loader.traptask_dir,
|
|
214
|
-
run_dir=
|
|
233
|
+
run_dir=ws.run_dir(ts),
|
|
215
234
|
cost_enabled=cost,
|
|
216
235
|
)
|
|
217
236
|
prog_console = console if output == OutputFormat.rich else None
|
|
@@ -233,8 +252,8 @@ def run(
|
|
|
233
252
|
except Exception:
|
|
234
253
|
environment_info = None
|
|
235
254
|
|
|
236
|
-
report_data =
|
|
237
|
-
|
|
255
|
+
report_data = ReportData.from_run(
|
|
256
|
+
cases_results=case_results,
|
|
238
257
|
trap_config=trap_yaml_loader.config,
|
|
239
258
|
started_at_utc=started_at_local.astimezone(UTC),
|
|
240
259
|
finished_at_utc=finished_at_utc,
|
|
@@ -242,7 +261,11 @@ def run(
|
|
|
242
261
|
provenance=provenance,
|
|
243
262
|
environment=environment_info,
|
|
244
263
|
)
|
|
264
|
+
ws.save_as_report(ts, report_data)
|
|
245
265
|
renderer_factory(output).render(report_data)
|
|
266
|
+
if output == OutputFormat.rich:
|
|
267
|
+
# Where the artifacts landed — the run id doubles as the `--run` handle.
|
|
268
|
+
console.print(f"[dim]run {ts} → {ws.report_json_path(ts)}[/dim]")
|
|
246
269
|
# trap reports facts, not a verdict: a completed run exits 0 regardless of per-case
|
|
247
270
|
# exit codes or scores (read the grader / report.json to gate CI). trap-level
|
|
248
271
|
# failures (bad config, git errors) still exit non-zero via _die.
|
|
@@ -250,43 +273,47 @@ def run(
|
|
|
250
273
|
|
|
251
274
|
@app.command()
|
|
252
275
|
def report(
|
|
253
|
-
task: Annotated[str | None, typer.Argument()] = None,
|
|
254
|
-
run: Annotated[str, typer.Argument()] = "latest",
|
|
255
276
|
solution: Annotated[
|
|
256
277
|
str | None,
|
|
257
|
-
typer.
|
|
278
|
+
typer.Argument(help="Local solution path holding trap.yaml (default: cwd)."),
|
|
279
|
+
] = None,
|
|
280
|
+
task: Annotated[
|
|
281
|
+
str | None,
|
|
282
|
+
typer.Option("--task", help="Task alias from trap.yaml (default: the first task)."),
|
|
258
283
|
] = None,
|
|
284
|
+
run: Annotated[str, typer.Option("--run", "-r", help="Which run to display.")] = "latest",
|
|
285
|
+
workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(Workspace.DEFAULT_DIRNAME),
|
|
259
286
|
output: Annotated[OutputFormat, typer.Option("--output", "-o")] = OutputFormat.rich,
|
|
260
|
-
workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(".trap"),
|
|
261
287
|
) -> None:
|
|
262
288
|
"""Display a report for a task (defaults to latest run)."""
|
|
263
289
|
try:
|
|
264
290
|
task_alias = TrapLoader.from_solution(solution).resolve_task(task).alias
|
|
265
291
|
except (GitOpsError, ConfigError) as e:
|
|
266
292
|
raise _die(e) from None
|
|
267
|
-
|
|
293
|
+
ws = Workspace(workspace.resolve(), SolutionIdentity.from_spec(solution).dirname, task_alias)
|
|
268
294
|
try:
|
|
269
|
-
report_data =
|
|
270
|
-
except FileNotFoundError:
|
|
271
|
-
raise
|
|
295
|
+
report_data = ws.load(run)
|
|
296
|
+
except FileNotFoundError as e:
|
|
297
|
+
raise _no_report(ws, e) from None
|
|
272
298
|
renderer_factory(output).render(report_data)
|
|
273
299
|
|
|
274
300
|
|
|
275
301
|
@app.command()
|
|
276
302
|
def submit(
|
|
303
|
+
solution: Annotated[
|
|
304
|
+
str | None,
|
|
305
|
+
typer.Argument(help="Local solution path holding trap.yaml (default: cwd)."),
|
|
306
|
+
] = None,
|
|
277
307
|
task: Annotated[
|
|
278
308
|
str | None,
|
|
279
|
-
typer.
|
|
280
|
-
|
|
309
|
+
typer.Option(
|
|
310
|
+
"--task",
|
|
311
|
+
help="Task alias from trap.yaml (default: the first task). "
|
|
281
312
|
"Used as both the local run dir and the trapstreet task_id.",
|
|
282
313
|
),
|
|
283
314
|
] = None,
|
|
284
|
-
solution: Annotated[
|
|
285
|
-
str | None,
|
|
286
|
-
typer.Option("--solution", help="Local solution path holding trap.yaml (default: cwd)."),
|
|
287
|
-
] = None,
|
|
288
|
-
workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(".trap"),
|
|
289
315
|
run: Annotated[str, typer.Option("--run", "-r", help="Which run to upload.")] = "latest",
|
|
316
|
+
workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(Workspace.DEFAULT_DIRNAME),
|
|
290
317
|
allow_unanchored: Annotated[
|
|
291
318
|
bool,
|
|
292
319
|
typer.Option(
|
|
@@ -298,8 +325,8 @@ def submit(
|
|
|
298
325
|
) -> None:
|
|
299
326
|
"""Upload a report.json to trapstreet.
|
|
300
327
|
|
|
301
|
-
Reads from the .trap/<task>/<run>/report.json workspace
|
|
302
|
-
populated.
|
|
328
|
+
Reads from the .trap/runs/<solution>/<task>/<run>/report.json workspace
|
|
329
|
+
that `tp run` populated.
|
|
303
330
|
"""
|
|
304
331
|
stored = AuthStore().load()
|
|
305
332
|
# priority: TRAPSTREET_URL env > stored > default
|
|
@@ -315,17 +342,17 @@ def submit(
|
|
|
315
342
|
task_alias = TrapLoader.from_solution(solution).resolve_task(task).alias
|
|
316
343
|
except (GitOpsError, ConfigError) as e:
|
|
317
344
|
raise _die(e) from None
|
|
318
|
-
|
|
345
|
+
ws = Workspace(workspace.resolve(), SolutionIdentity.from_spec(solution).dirname, task_alias)
|
|
319
346
|
try:
|
|
320
|
-
report_data =
|
|
321
|
-
except FileNotFoundError:
|
|
322
|
-
raise
|
|
347
|
+
report_data = ws.load(run)
|
|
348
|
+
except FileNotFoundError as e:
|
|
349
|
+
raise _no_report(ws, e) from None
|
|
323
350
|
# Repeat the unanchored-provenance gate at upload time — the report records
|
|
324
351
|
# what `tp run` saw, so the checkouts aren't re-probed here.
|
|
325
352
|
_confirm_unanchored(
|
|
326
353
|
report_data.provenance, allow=allow_unanchored or _env_truthy("TRAP_ALLOW_UNANCHORED")
|
|
327
354
|
)
|
|
328
|
-
report_path =
|
|
355
|
+
report_path = ws.report_json_path(run)
|
|
329
356
|
|
|
330
357
|
client = ApiClient(resolved_server, resolved_key)
|
|
331
358
|
try:
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from trap.display.progress import CaseProgress
|
|
2
|
+
from trap.display.report import (
|
|
3
|
+
BaseRenderer,
|
|
4
|
+
JsonRenderer,
|
|
5
|
+
OutputFormat,
|
|
6
|
+
RichRenderer,
|
|
7
|
+
renderer_factory,
|
|
8
|
+
)
|
|
9
|
+
from trap.display.submit import render_submit_result
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"BaseRenderer",
|
|
13
|
+
"CaseProgress",
|
|
14
|
+
"JsonRenderer",
|
|
15
|
+
"OutputFormat",
|
|
16
|
+
"RichRenderer",
|
|
17
|
+
"render_submit_result",
|
|
18
|
+
"renderer_factory",
|
|
19
|
+
]
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
"""Report renderers: turn a ReportData into terminal output (rich table or JSON)."""
|
|
2
|
+
|
|
1
3
|
from __future__ import annotations
|
|
2
4
|
|
|
5
|
+
import enum
|
|
3
6
|
import json
|
|
7
|
+
import sys
|
|
4
8
|
|
|
5
9
|
from rich import box
|
|
6
10
|
from rich.console import Console
|
|
@@ -10,7 +14,21 @@ from rich.table import Table
|
|
|
10
14
|
from rich.text import Text
|
|
11
15
|
|
|
12
16
|
from trap.models import CaseResult, ReportData
|
|
13
|
-
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OutputFormat(enum.StrEnum):
|
|
20
|
+
rich = "rich"
|
|
21
|
+
json = "json"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BaseRenderer:
|
|
25
|
+
def render(self, data: ReportData) -> None:
|
|
26
|
+
raise NotImplementedError
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class JsonRenderer(BaseRenderer):
|
|
30
|
+
def render(self, data: ReportData) -> None:
|
|
31
|
+
sys.stdout.write(data.model_dump_json(indent=2) + "\n")
|
|
14
32
|
|
|
15
33
|
|
|
16
34
|
class RichRenderer(BaseRenderer):
|
|
@@ -114,3 +132,7 @@ class RichRenderer(BaseRenderer):
|
|
|
114
132
|
def render(self, data: ReportData) -> None:
|
|
115
133
|
self.console.print(self._build_summary(data))
|
|
116
134
|
self.console.print(self._build_table(data))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def renderer_factory(fmt: OutputFormat) -> BaseRenderer:
|
|
138
|
+
return {OutputFormat.rich: RichRenderer, OutputFormat.json: JsonRenderer}[fmt]()
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
4
|
from dataclasses import dataclass
|
|
5
|
+
from pathlib import PurePosixPath
|
|
5
6
|
|
|
6
7
|
from trap.git_ops.base import GitOpsError
|
|
7
8
|
|
|
@@ -96,3 +97,17 @@ class ParsedGitUrl:
|
|
|
96
97
|
if m:
|
|
97
98
|
return f"https://{m.group(1)}/{m.group(2)}"
|
|
98
99
|
return re.sub(r"\.git$", "", self.repo) # https/http: drop trailing .git
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def dir_basename(self) -> str:
|
|
103
|
+
"""Basename of the directory the URL addresses: the subdirectory when one
|
|
104
|
+
is set, else the repo."""
|
|
105
|
+
return PurePosixPath(self.subdirectory).name if self.subdirectory else self.basename
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def normalised_dir_url(self) -> str:
|
|
109
|
+
"""Canonical, rev-independent address of the directory the URL points at:
|
|
110
|
+
``normalised_url`` plus ``#<subdirectory>`` when one is set. The rev is
|
|
111
|
+
excluded on purpose — it can float without changing *which* directory
|
|
112
|
+
this is."""
|
|
113
|
+
return self.normalised_url + (f"#{self.subdirectory}" if self.subdirectory else "")
|
|
@@ -68,7 +68,7 @@ class TrapLoader:
|
|
|
68
68
|
The solution's `setup_cmd` (in its trap.yaml, so it travels with the
|
|
69
69
|
solution) prepares the checkout. It auto-runs when a remote pull brought
|
|
70
70
|
new code, and otherwise only when `setup` is set (`tp run --setup-solution`).
|
|
71
|
-
Mirrors `TraptaskLoader.
|
|
71
|
+
Mirrors `TraptaskLoader.from_task_binding`.
|
|
72
72
|
"""
|
|
73
73
|
from trap.git_ops import GitOpsError, ParsedGitUrl, RemoteRepo
|
|
74
74
|
|
|
@@ -10,6 +10,7 @@ from pydantic import ValidationError
|
|
|
10
10
|
|
|
11
11
|
from trap.loader.errors import ConfigError
|
|
12
12
|
from trap.models import TaskBinding, TraptaskCase, TraptaskConfig
|
|
13
|
+
from trap.workspace import Workspace
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
class TraptaskLoader:
|
|
@@ -41,13 +42,21 @@ class TraptaskLoader:
|
|
|
41
42
|
return TraptaskConfig(cases=tuple(TraptaskCase(id=case_id) for case_id in case_ids))
|
|
42
43
|
|
|
43
44
|
@classmethod
|
|
44
|
-
def
|
|
45
|
+
def from_task_binding(
|
|
46
|
+
cls,
|
|
47
|
+
task_binding: TaskBinding,
|
|
48
|
+
trap_dir: Path,
|
|
49
|
+
setup: bool = False,
|
|
50
|
+
workspace_root: Path = Path(Workspace.DEFAULT_DIRNAME),
|
|
51
|
+
) -> TraptaskLoader:
|
|
45
52
|
"""Resolve traptask.yaml from a TaskBinding's source and the trap.yaml directory.
|
|
46
53
|
|
|
47
54
|
Mirrors `TrapLoader.from_solution`: `source` is a local path or a git+ URL.
|
|
48
|
-
A URL clones into `clone_to` (
|
|
49
|
-
|
|
50
|
-
|
|
55
|
+
A URL clones into `clone_to` (resolved against `trap_dir`, since it is the
|
|
56
|
+
solution author's config) or, when omitted, the workspace's hidden
|
|
57
|
+
`<workspace_root>/repos/<repo>` cache — the same root that holds run
|
|
58
|
+
artifacts; a local path uses it in place and rejects `clone_to`.
|
|
59
|
+
Raises GitOpsError on a bad spec (caller maps it to a CLI error).
|
|
51
60
|
|
|
52
61
|
The task's `setup_cmd` (declared in its traptask.yaml, so it travels with the
|
|
53
62
|
task version) prepares the checkout. It auto-runs when a remote pull brought
|
|
@@ -56,17 +65,20 @@ class TraptaskLoader:
|
|
|
56
65
|
"""
|
|
57
66
|
from trap.git_ops import GitOpsError, ParsedGitUrl, RemoteRepo
|
|
58
67
|
|
|
59
|
-
if ParsedGitUrl.looks_remote(
|
|
60
|
-
parsed = ParsedGitUrl.from_full_url(
|
|
61
|
-
|
|
62
|
-
|
|
68
|
+
if ParsedGitUrl.looks_remote(task_binding.source):
|
|
69
|
+
parsed = ParsedGitUrl.from_full_url(task_binding.source)
|
|
70
|
+
if task_binding.clone_to is not None:
|
|
71
|
+
dest = trap_dir / task_binding.clone_to
|
|
72
|
+
else:
|
|
73
|
+
dest = Workspace.clone_cache_dir(workspace_root, parsed.basename)
|
|
74
|
+
remote_repo = RemoteRepo(parsed, dest.resolve())
|
|
63
75
|
is_local_changed = remote_repo.ensure()
|
|
64
76
|
traptask_dir = remote_repo.local_dir
|
|
65
77
|
else:
|
|
66
|
-
if
|
|
78
|
+
if task_binding.clone_to is not None:
|
|
67
79
|
raise GitOpsError("clone_to only applies to a remote (git URL) source")
|
|
68
80
|
is_local_changed = False
|
|
69
|
-
traptask_dir = (trap_dir /
|
|
81
|
+
traptask_dir = (trap_dir / task_binding.source).resolve()
|
|
70
82
|
loader = cls(traptask_dir / "traptask.yaml")
|
|
71
83
|
if (is_local_changed or setup) and loader.traptask.setup_cmd:
|
|
72
84
|
# raises subprocess.CalledProcessError on non-zero exit
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from collections.abc import Callable, Iterable, Iterator
|
|
4
|
-
from datetime import datetime
|
|
5
4
|
from functools import cached_property
|
|
6
5
|
from pathlib import Path
|
|
7
6
|
from typing import Any
|
|
@@ -40,18 +39,6 @@ class TaskRunner:
|
|
|
40
39
|
"""The task's expected/ dir (traptask_dir / dirs.expected), resolved once on first use."""
|
|
41
40
|
return (self.traptask_dir / self.traptask_config.dirs.expected).resolve()
|
|
42
41
|
|
|
43
|
-
def _update_latest(self) -> None:
|
|
44
|
-
latest = self.run_dir.parent / "latest"
|
|
45
|
-
if latest.is_symlink():
|
|
46
|
-
latest.unlink()
|
|
47
|
-
elif latest.exists():
|
|
48
|
-
# Path exists but isn't a symlink — likely an interrupted prior run
|
|
49
|
-
# or a sync tool that materialized the symlink as a real directory.
|
|
50
|
-
# Move it aside (non-destructive) so subsequent runs self-heal.
|
|
51
|
-
suffix = datetime.now().isoformat(timespec="microseconds")
|
|
52
|
-
latest.rename(latest.with_name(f"latest.broken.{suffix}"))
|
|
53
|
-
latest.symlink_to(self.run_dir.name)
|
|
54
|
-
|
|
55
42
|
def _iter_cases(
|
|
56
43
|
self,
|
|
57
44
|
cases: Iterable[TraptaskCase],
|
|
@@ -98,5 +85,4 @@ class TaskRunner:
|
|
|
98
85
|
# still completes and the report still saves).
|
|
99
86
|
grader_metrics = GraderRunner(self, case_results).run()
|
|
100
87
|
|
|
101
|
-
self._update_latest()
|
|
102
88
|
return case_results, grader_metrics
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Workspace addressing: where a solution's runs live inside `.trap/`.
|
|
2
|
+
|
|
3
|
+
Layout: ``<workspace>/runs/<solution-key>/<task_alias>/<run>/``.
|
|
4
|
+
|
|
5
|
+
Two objects own this package's concerns: :class:`SolutionIdentity`
|
|
6
|
+
(``identity.py``) derives which solution a run belongs to and projects it to a
|
|
7
|
+
directory name (its ``dirname`` is the solution key), and :class:`Workspace`
|
|
8
|
+
(``store.py``) answers queries and does report IO against one `.trap/` store,
|
|
9
|
+
scoped to one (solution, task) pair fixed at construction. The key
|
|
10
|
+
carries the
|
|
11
|
+
run's *identity* inside the store, so ``latest`` is scoped per (solution, task)
|
|
12
|
+
by construction — a cwd mismatch between ``tp run`` and ``tp submit`` degrades
|
|
13
|
+
to a loud "not found" instead of silently reading another solution's report.
|
|
14
|
+
Runs live under the ``runs/`` namespace so run storage never collides with
|
|
15
|
+
sibling namespaces like ``repos/`` (the clone cache).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from trap.workspace.identity import SolutionIdentity
|
|
21
|
+
from trap.workspace.store import Workspace
|
|
22
|
+
|
|
23
|
+
__all__ = ["SolutionIdentity", "Workspace"]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""SolutionIdentity — the identity a solution's runs are stored under."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from trap.git_ops import ParsedGitUrl
|
|
11
|
+
|
|
12
|
+
_UNSAFE = re.compile(r"[^A-Za-z0-9._-]+")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class SolutionIdentity:
|
|
17
|
+
"""The identity a solution's runs are stored under: ``<readable>-<hash8>``.
|
|
18
|
+
|
|
19
|
+
``readable`` is the solution's basename (never encodes path separators,
|
|
20
|
+
never leaks ``..``); ``ident`` is the full identity the hash disambiguates
|
|
21
|
+
same-named solutions by — the resolved absolute path, or the normalised
|
|
22
|
+
URL + subdirectory for a remote. Aliases of one solution (``./x``, ``x``,
|
|
23
|
+
an absolute path, a symlinked path) all derive the same key."""
|
|
24
|
+
|
|
25
|
+
readable: str
|
|
26
|
+
ident: str
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_spec(cls, solution: str | None) -> SolutionIdentity:
|
|
30
|
+
"""Derive the key from a ``--solution`` spec (local path or git+ URL)."""
|
|
31
|
+
if solution is not None and ParsedGitUrl.looks_remote(solution):
|
|
32
|
+
return cls._from_remote(ParsedGitUrl.from_full_url(solution))
|
|
33
|
+
return cls._from_local(solution)
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def _from_remote(cls, parsed: ParsedGitUrl) -> SolutionIdentity:
|
|
37
|
+
# The URL is the stable identity — the clone dir can be moved (--clone-to)
|
|
38
|
+
# without changing which solution this is.
|
|
39
|
+
return cls(readable=parsed.dir_basename, ident=parsed.normalised_dir_url)
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def _from_local(cls, solution: str | None) -> SolutionIdentity:
|
|
43
|
+
resolved = (Path.cwd() / (solution or ".")).resolve()
|
|
44
|
+
return cls(readable=resolved.name, ident=str(resolved))
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def dirname(self) -> str:
|
|
48
|
+
"""The key as it appears on disk: the directory name under ``runs/``."""
|
|
49
|
+
return f"{self._safe_readable}-{self._digest}"
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def _safe_readable(self) -> str:
|
|
53
|
+
"""`readable` reduced to dirname-safe characters, with a non-empty fallback."""
|
|
54
|
+
return _UNSAFE.sub("-", self.readable).strip("-.") or "solution"
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def _digest(self) -> str:
|
|
58
|
+
return hashlib.sha256(self.ident.encode()).hexdigest()[:8]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Workspace — one `.trap/` store and the queries it answers.
|
|
2
|
+
|
|
3
|
+
``latest`` is a *derived* value — the newest timestamp-named run directory that
|
|
4
|
+
holds a report, detected at every use. Nothing on disk records which run is
|
|
5
|
+
latest, so there is no pointer to go stale.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from trap.models import ReportData
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Workspace:
|
|
17
|
+
"""One `.trap/` store, scoped to one (solution, task) pair.
|
|
18
|
+
|
|
19
|
+
A CLI invocation resolves its solution key and task alias once, so both are
|
|
20
|
+
fixed at construction time — they address one run *series*. Which run inside
|
|
21
|
+
the series to touch is a per-call choice, so ``run`` stays a parameter."""
|
|
22
|
+
|
|
23
|
+
DEFAULT_DIRNAME = ".trap"
|
|
24
|
+
RUNS_DIR = "runs"
|
|
25
|
+
REPOS_DIR = "repos"
|
|
26
|
+
REPORT_FILENAME = "report.json"
|
|
27
|
+
|
|
28
|
+
def __init__(self, root: Path, solution_key: str, task_alias: str) -> None:
|
|
29
|
+
self.root = root
|
|
30
|
+
self.solution_key = solution_key
|
|
31
|
+
self.task_alias = task_alias
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def clone_cache_dir(cls, root: Path, repo_basename: str) -> Path:
|
|
35
|
+
"""Where a remote task clones when no ``clone_to`` is given: the hidden
|
|
36
|
+
``repos/`` cache inside the workspace at ``root`` — the same root that
|
|
37
|
+
holds ``runs/``, so the whole store lives in one place."""
|
|
38
|
+
return root / cls.REPOS_DIR / repo_basename
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def runs_root(self) -> Path:
|
|
42
|
+
return self.root / self.RUNS_DIR
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def solution_dir(self) -> Path:
|
|
46
|
+
"""This solution's namespace inside the store."""
|
|
47
|
+
return self.runs_root / self.solution_key
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def solution_task_alias_dir(self) -> Path:
|
|
51
|
+
"""Where this (solution, task) pair's runs live."""
|
|
52
|
+
return self.solution_dir / self.task_alias
|
|
53
|
+
|
|
54
|
+
def latest_run(self) -> str | None:
|
|
55
|
+
"""The newest completed run for this (solution, task), or None when there is none.
|
|
56
|
+
|
|
57
|
+
Derived from the directory names at every call — stray symlinks and
|
|
58
|
+
non-run entries are ignored."""
|
|
59
|
+
task_dir = self.solution_task_alias_dir
|
|
60
|
+
if not task_dir.is_dir():
|
|
61
|
+
return None
|
|
62
|
+
runs = (path.name for path in task_dir.iterdir() if self._is_completed_run(path))
|
|
63
|
+
return max(runs, default=None)
|
|
64
|
+
|
|
65
|
+
def resolved_run(self, run: str) -> str:
|
|
66
|
+
"""``"latest"`` resolves to the newest completed run; anything else names
|
|
67
|
+
a run directly (whether its report exists is ``assert_exists``'s concern).
|
|
68
|
+
Raises FileNotFoundError when there is no completed run to resolve
|
|
69
|
+
``"latest"`` to."""
|
|
70
|
+
if run != "latest":
|
|
71
|
+
return run
|
|
72
|
+
latest = self.latest_run()
|
|
73
|
+
if latest is None:
|
|
74
|
+
raise FileNotFoundError(f"no completed runs in {self.solution_task_alias_dir}")
|
|
75
|
+
return latest
|
|
76
|
+
|
|
77
|
+
def run_dir(self, run: str) -> Path:
|
|
78
|
+
"""One run's directory; ``run="latest"`` is resolved at every use."""
|
|
79
|
+
return self.solution_task_alias_dir / self.resolved_run(run)
|
|
80
|
+
|
|
81
|
+
def report_json_path(self, run: str) -> Path:
|
|
82
|
+
return self.run_dir(run) / self.REPORT_FILENAME
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def _is_completed_run(cls, path: Path) -> bool:
|
|
86
|
+
"""A run counts when its directory is named by an ISO timestamp and holds
|
|
87
|
+
a report — half-written directories from a crashed run never win."""
|
|
88
|
+
if path.is_symlink() or not path.is_dir():
|
|
89
|
+
return False
|
|
90
|
+
try:
|
|
91
|
+
datetime.fromisoformat(path.name)
|
|
92
|
+
except ValueError:
|
|
93
|
+
return False
|
|
94
|
+
return (path / cls.REPORT_FILENAME).is_file()
|
|
95
|
+
|
|
96
|
+
def solution_keys(self) -> list[str]:
|
|
97
|
+
"""Solution keys with runs in this workspace — the self-help list for a miss."""
|
|
98
|
+
if not self.runs_root.is_dir():
|
|
99
|
+
return []
|
|
100
|
+
return sorted(path.name for path in self.runs_root.iterdir() if path.is_dir())
|
|
101
|
+
|
|
102
|
+
def save_as_report(self, run: str, data: ReportData) -> None:
|
|
103
|
+
"""Write ``run``'s report; ``run`` must name a concrete run, never ``"latest"``."""
|
|
104
|
+
self.run_dir(run).mkdir(parents=True, exist_ok=True)
|
|
105
|
+
self.report_json_path(run).write_text(data.model_dump_json(indent=2))
|
|
106
|
+
|
|
107
|
+
def assert_exists(self, run: str) -> None:
|
|
108
|
+
if not self.report_json_path(run).exists():
|
|
109
|
+
raise FileNotFoundError(f"no report found in {self.run_dir(run)}")
|
|
110
|
+
|
|
111
|
+
def load(self, run: str) -> ReportData:
|
|
112
|
+
self.assert_exists(run)
|
|
113
|
+
return ReportData.model_validate_json(self.report_json_path(run).read_text())
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import enum
|
|
4
|
-
|
|
5
|
-
from trap.report.base import BaseRenderer
|
|
6
|
-
from trap.report.handle import ReportHandle
|
|
7
|
-
from trap.report.json import JsonRenderer
|
|
8
|
-
from trap.report.rich import RichRenderer
|
|
9
|
-
|
|
10
|
-
__all__ = ["BaseRenderer", "JsonRenderer", "OutputFormat", "ReportHandle", "RichRenderer", "renderer_factory"]
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class OutputFormat(enum.StrEnum):
|
|
14
|
-
rich = "rich"
|
|
15
|
-
json = "json"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def renderer_factory(fmt: OutputFormat) -> BaseRenderer:
|
|
19
|
-
return {OutputFormat.rich: RichRenderer, OutputFormat.json: JsonRenderer}[fmt]()
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from datetime import datetime
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
from typing import Any
|
|
6
|
-
|
|
7
|
-
from trap.models import CaseResult, Environment, Provenance, ReportData, TrapConfig
|
|
8
|
-
|
|
9
|
-
_REPORT_FILENAME = "report.json"
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class ReportHandle:
|
|
13
|
-
def __init__(self, workspace: Path, task_alias: str, run: str) -> None:
|
|
14
|
-
self._workspace = workspace
|
|
15
|
-
self._task_alias = task_alias
|
|
16
|
-
self._run = run
|
|
17
|
-
|
|
18
|
-
@property
|
|
19
|
-
def run_dir(self) -> Path:
|
|
20
|
-
return self._workspace / self._task_alias / self._run
|
|
21
|
-
|
|
22
|
-
@property
|
|
23
|
-
def report_json_path(self) -> Path:
|
|
24
|
-
return self.run_dir / _REPORT_FILENAME
|
|
25
|
-
|
|
26
|
-
def save(
|
|
27
|
-
self,
|
|
28
|
-
trap_config: TrapConfig,
|
|
29
|
-
case_results: tuple[CaseResult, ...],
|
|
30
|
-
started_at_utc: datetime,
|
|
31
|
-
finished_at_utc: datetime,
|
|
32
|
-
provenance: Provenance,
|
|
33
|
-
grader_metrics: Any = None,
|
|
34
|
-
environment: Environment | None = None,
|
|
35
|
-
) -> ReportData:
|
|
36
|
-
data = ReportData.from_run(
|
|
37
|
-
trap_config=trap_config,
|
|
38
|
-
provenance=provenance,
|
|
39
|
-
cases_results=case_results,
|
|
40
|
-
started_at_utc=started_at_utc,
|
|
41
|
-
finished_at_utc=finished_at_utc,
|
|
42
|
-
grader_metrics=grader_metrics,
|
|
43
|
-
environment=environment,
|
|
44
|
-
)
|
|
45
|
-
self.report_json_path.write_text(data.model_dump_json(indent=2))
|
|
46
|
-
return data
|
|
47
|
-
|
|
48
|
-
def assert_exists(self) -> None:
|
|
49
|
-
if not self.report_json_path.exists():
|
|
50
|
-
raise FileNotFoundError(f"no report found in {self.run_dir}")
|
|
51
|
-
|
|
52
|
-
def load(self) -> ReportData:
|
|
53
|
-
self.assert_exists()
|
|
54
|
-
return ReportData.model_validate_json(self.report_json_path.read_text())
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import sys
|
|
4
|
-
|
|
5
|
-
from trap.models import ReportData
|
|
6
|
-
from trap.report.base import BaseRenderer
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class JsonRenderer(BaseRenderer):
|
|
10
|
-
def render(self, data: ReportData) -> None:
|
|
11
|
-
sys.stdout.write(data.model_dump_json(indent=2) + "\n")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|