slmeval-cli 0.1.0__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.
Files changed (94) hide show
  1. slmeval/__init__.py +71 -0
  2. slmeval/__main__.py +14 -0
  3. slmeval/adapters/__init__.py +24 -0
  4. slmeval/adapters/instrument.py +135 -0
  5. slmeval/adapters/langgraph.py +237 -0
  6. slmeval/cli/__init__.py +15 -0
  7. slmeval/cli/commands/__init__.py +35 -0
  8. slmeval/cli/commands/baseline_cmd.py +201 -0
  9. slmeval/cli/commands/cache_cmd.py +249 -0
  10. slmeval/cli/commands/dataset_cmd.py +52 -0
  11. slmeval/cli/commands/doctor_cmd.py +68 -0
  12. slmeval/cli/commands/gate_cmd.py +602 -0
  13. slmeval/cli/commands/init_cmd.py +144 -0
  14. slmeval/cli/commands/judge_cmd.py +284 -0
  15. slmeval/cli/commands/record_cmd.py +154 -0
  16. slmeval/cli/commands/report_cmd.py +98 -0
  17. slmeval/cli/commands/run_cmd.py +247 -0
  18. slmeval/cli/commands/show_cmd.py +482 -0
  19. slmeval/cli/commands/stubs.py +57 -0
  20. slmeval/cli/commands/validate_cmd.py +296 -0
  21. slmeval/cli/loading.py +149 -0
  22. slmeval/cli/main.py +91 -0
  23. slmeval/cli/render.py +251 -0
  24. slmeval/cli/wrap.py +31 -0
  25. slmeval/config/__init__.py +47 -0
  26. slmeval/config/loader.py +363 -0
  27. slmeval/config/locate.py +97 -0
  28. slmeval/config/models.py +430 -0
  29. slmeval/config/templates.py +246 -0
  30. slmeval/core/__init__.py +78 -0
  31. slmeval/core/adapter.py +253 -0
  32. slmeval/core/context.py +418 -0
  33. slmeval/core/outcome.py +260 -0
  34. slmeval/core/router.py +319 -0
  35. slmeval/core/runner.py +1209 -0
  36. slmeval/core/trace.py +188 -0
  37. slmeval/core/verdict.py +127 -0
  38. slmeval/dataset/__init__.py +8 -0
  39. slmeval/dataset/loader.py +130 -0
  40. slmeval/dataset/models.py +108 -0
  41. slmeval/doctor/__init__.py +7 -0
  42. slmeval/doctor/checks.py +380 -0
  43. slmeval/errors.py +76 -0
  44. slmeval/eval/__init__.py +64 -0
  45. slmeval/eval/assertions.py +356 -0
  46. slmeval/eval/calibration.py +364 -0
  47. slmeval/eval/cost.py +291 -0
  48. slmeval/eval/pipeline.py +273 -0
  49. slmeval/eval/stats.py +376 -0
  50. slmeval/eval/tier1.py +510 -0
  51. slmeval/eval/tier2.py +582 -0
  52. slmeval/eval/tier3.py +415 -0
  53. slmeval/exit_codes.py +28 -0
  54. slmeval/gate.py +722 -0
  55. slmeval/providers/__init__.py +69 -0
  56. slmeval/providers/base.py +426 -0
  57. slmeval/providers/fake.py +760 -0
  58. slmeval/providers/jsonx.py +239 -0
  59. slmeval/providers/ladder.py +339 -0
  60. slmeval/providers/openai_compat.py +392 -0
  61. slmeval/providers/registry.py +185 -0
  62. slmeval/providers/retry.py +81 -0
  63. slmeval/providers/schema.py +136 -0
  64. slmeval/providers/tokens.py +150 -0
  65. slmeval/py.typed +0 -0
  66. slmeval/report/__init__.py +115 -0
  67. slmeval/report/attribution.py +197 -0
  68. slmeval/report/baseline.py +353 -0
  69. slmeval/report/drilldown.py +1395 -0
  70. slmeval/report/export.py +450 -0
  71. slmeval/report/model.py +562 -0
  72. slmeval/report/progress.py +257 -0
  73. slmeval/report/scorecard.py +367 -0
  74. slmeval/sandbox/__init__.py +145 -0
  75. slmeval/sandbox/diagnosis.py +690 -0
  76. slmeval/sandbox/envelope.py +486 -0
  77. slmeval/sandbox/faults.py +794 -0
  78. slmeval/sandbox/keys.py +458 -0
  79. slmeval/sandbox/normalizers.py +333 -0
  80. slmeval/sandbox/store.py +525 -0
  81. slmeval/sandbox/tool.py +685 -0
  82. slmeval/storage/__init__.py +73 -0
  83. slmeval/storage/blobs.py +308 -0
  84. slmeval/storage/codec.py +93 -0
  85. slmeval/storage/db.py +1334 -0
  86. slmeval/storage/ingest.py +346 -0
  87. slmeval/storage/migrations.py +358 -0
  88. slmeval/storage/models.py +554 -0
  89. slmeval/storage/provenance.py +201 -0
  90. slmeval_cli-0.1.0.dist-info/METADATA +484 -0
  91. slmeval_cli-0.1.0.dist-info/RECORD +94 -0
  92. slmeval_cli-0.1.0.dist-info/WHEEL +4 -0
  93. slmeval_cli-0.1.0.dist-info/entry_points.txt +2 -0
  94. slmeval_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,296 @@
1
+ """`slmeval validate` — check the config and dataset before spending GPU time.
2
+
3
+ Also home to the cross-checks that neither schema can do alone. The most
4
+ valuable one is the `target_nodes` spell-check: "I benchmarked for an hour and
5
+ got identical numbers" is always a candidate pointing at a node name that does
6
+ not exist.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import inspect
12
+ import json
13
+ from pathlib import Path
14
+ from typing import Annotated, Any
15
+
16
+ import typer
17
+ from rich.table import Table
18
+ from rich.text import Text
19
+
20
+ from slmeval import exit_codes
21
+ from slmeval.cli.render import console, symbols
22
+ from slmeval.cli.wrap import handle_errors
23
+ from slmeval.config import (
24
+ LoadedConfig,
25
+ find_config,
26
+ load_config,
27
+ require_dataset,
28
+ resolve_entrypoint,
29
+ )
30
+ from slmeval.config.locate import locate
31
+ from slmeval.dataset import Dataset, load_dataset
32
+ from slmeval.errors import Problem
33
+
34
+ MIN_RUNS_FOR_CONSISTENCY = 3
35
+
36
+
37
+ def cross_check(loaded: LoadedConfig, dataset: Dataset) -> list[Problem]:
38
+ """Problems that need both the config and the dataset to see."""
39
+ cfg = loaded.config
40
+ text = loaded.text
41
+ warnings: list[Problem] = []
42
+
43
+ if not cfg.candidates:
44
+ warnings.append(
45
+ Problem(
46
+ path="candidates",
47
+ message="no candidates defined - there is nothing to compare the baseline against",
48
+ )
49
+ )
50
+
51
+ # Node names, spell-checked against the trajectories declared in the dataset.
52
+ known_nodes = {
53
+ node for case in dataset.cases for path in case.assertions.reference_trajectory
54
+ for node in path
55
+ }
56
+ if known_nodes:
57
+ for i, candidate in enumerate(cfg.candidates):
58
+ unknown = [n for n in candidate.target_nodes if n not in known_nodes]
59
+ if unknown:
60
+ warnings.append(
61
+ Problem(
62
+ path=f"candidates[{i}].target_nodes",
63
+ message=f"{', '.join(repr(n) for n in unknown)} "
64
+ f"appear in no reference_trajectory. If misspelled, this candidate "
65
+ f"changes nothing and will score identically to the baseline. "
66
+ f"Known nodes: {', '.join(sorted(known_nodes))}",
67
+ line=locate(text, ("candidates", i, "target_nodes")),
68
+ )
69
+ )
70
+ elif any(c.target_nodes for c in cfg.candidates):
71
+ warnings.append(
72
+ Problem(
73
+ path="assert.reference_trajectory",
74
+ message="no case declares a reference_trajectory, so target_nodes cannot be "
75
+ "checked for typos",
76
+ )
77
+ )
78
+
79
+ if cfg.benchmark.runs_per_case < MIN_RUNS_FOR_CONSISTENCY:
80
+ warnings.append(
81
+ Problem(
82
+ path="benchmark.runs_per_case",
83
+ message=f"{cfg.benchmark.runs_per_case} run per case cannot measure "
84
+ "consistency. Agents are not deterministic even at temperature 0; "
85
+ "3 is the floor for pass^k.",
86
+ line=locate(text, ("benchmark", "runs_per_case")),
87
+ )
88
+ )
89
+
90
+ if cfg.benchmark.tool_mode == "live":
91
+ warnings.append(
92
+ Problem(
93
+ path="benchmark.tool_mode",
94
+ message="'live' executes your real tools against real APIs on every run",
95
+ line=locate(text, ("benchmark", "tool_mode")),
96
+ )
97
+ )
98
+
99
+ if cfg.benchmark.fault_injection_rate > 0 and cfg.benchmark.tool_mode == "record":
100
+ warnings.append(
101
+ Problem(
102
+ path="benchmark.fault_injection_rate",
103
+ message="faults are not injected while recording (the recording would capture "
104
+ "the fault instead of the real response), so this setting has no effect",
105
+ line=locate(text, ("benchmark", "fault_injection_rate")),
106
+ )
107
+ )
108
+
109
+ empty = [c.case_id for c in dataset.cases if c.assertions.is_empty]
110
+ if empty:
111
+ warnings.append(
112
+ Problem(
113
+ path="dataset",
114
+ message=f"case(s) {', '.join(empty[:5])} declare no assertions and therefore "
115
+ "can never fail",
116
+ )
117
+ )
118
+
119
+ if len(dataset.cases) >= 3 and not any(c.assertions.forbidden_tools for c in dataset.cases):
120
+ warnings.append(
121
+ Problem(
122
+ path="dataset",
123
+ message="no case declares forbidden_tools. A small model taking a destructive "
124
+ "action it was not asked to take is the failure worth catching first.",
125
+ )
126
+ )
127
+
128
+ return warnings
129
+
130
+
131
+ def as_payload(
132
+ loaded: LoadedConfig, dataset: Dataset, warnings: list[Problem]
133
+ ) -> dict[str, Any]:
134
+ """The same summary as a machine-readable dict.
135
+
136
+ Exists for one job, and it is worth naming: a CI workflow has to key its
137
+ cassette cache on something, and the only correct key is the *content* hash
138
+ of the dataset the cassettes were recorded for. A workflow that keyed on a
139
+ branch name, or on nothing, would restore a stale cache and then report
140
+ every uncovered path as a model regression.
141
+
142
+ The config hash rides along because it is what `gate` refuses to compare
143
+ across, so a workflow can print both and a reviewer can see at a glance
144
+ whether two runs were even the same experiment.
145
+ """
146
+ cfg = loaded.config
147
+ return {
148
+ "schema": "slmeval.validate/1",
149
+ "ok": True,
150
+ "warnings": [
151
+ {"path": w.path, "message": w.message, "line": w.line} for w in warnings
152
+ ],
153
+ "benchmark": cfg.benchmark.name,
154
+ "config": str(loaded.path),
155
+ "config_hash": cfg.config_hash(),
156
+ "dataset": str(dataset.path),
157
+ "dataset_hash": dataset.content_hash,
158
+ "cases": len(dataset),
159
+ "tags": list(dataset.tags),
160
+ "target": cfg.target.entrypoint,
161
+ "adapter": cfg.target.adapter,
162
+ "baseline": cfg.baseline.id,
163
+ "candidates": [c.id for c in cfg.candidates],
164
+ "runs_per_case": cfg.benchmark.runs_per_case,
165
+ "tool_mode": cfg.benchmark.tool_mode,
166
+ "executions": len(cfg.all_models) * len(dataset) * cfg.benchmark.runs_per_case,
167
+ }
168
+
169
+
170
+ def _summary(loaded: LoadedConfig, dataset: Dataset) -> Table:
171
+ cfg = loaded.config
172
+ table = Table.grid(padding=(0, 2))
173
+ table.add_column(style="dim", no_wrap=True)
174
+ table.add_column(overflow="fold")
175
+
176
+ table.add_row("benchmark", f"{cfg.benchmark.name} [config {cfg.config_hash()}]")
177
+ table.add_row("config", str(loaded.path))
178
+ table.add_row(
179
+ "dataset",
180
+ f"{dataset.path.name} - {len(dataset)} case(s), hash {dataset.content_hash}"
181
+ + (f", tags: {', '.join(dataset.tags)}" if dataset.tags else ""),
182
+ )
183
+ table.add_row("target", f"{cfg.target.entrypoint} (adapter: {cfg.target.adapter})")
184
+ table.add_row(
185
+ "matrix",
186
+ f"{len(cfg.all_models)} model(s) x {len(dataset)} case(s) x "
187
+ f"{cfg.benchmark.runs_per_case} run(s) = "
188
+ f"{len(cfg.all_models) * len(dataset) * cfg.benchmark.runs_per_case} executions",
189
+ )
190
+ table.add_row(
191
+ "tools",
192
+ f"mode={cfg.benchmark.tool_mode}, "
193
+ f"faults={cfg.benchmark.fault_injection_rate:.0%}",
194
+ )
195
+ table.add_row("baseline", cfg.baseline.describe())
196
+ for candidate in cfg.candidates:
197
+ table.add_row(f"candidate {candidate.id}", candidate.describe())
198
+ return table
199
+
200
+
201
+ def register(app: typer.Typer) -> None:
202
+ @app.command()
203
+ @handle_errors
204
+ def validate(
205
+ config: Annotated[
206
+ Path | None,
207
+ typer.Option("--config", "-c", help="Path to eval.toml. Default: search upwards."),
208
+ ] = None,
209
+ strict: Annotated[
210
+ bool,
211
+ typer.Option(
212
+ "--strict",
213
+ help="Import the target entrypoint and treat warnings as errors (for CI).",
214
+ ),
215
+ ] = False,
216
+ as_json: Annotated[
217
+ bool,
218
+ typer.Option(
219
+ "--json",
220
+ help="Emit the summary - including the config and dataset hashes - "
221
+ "as JSON. For CI cache keys and provenance checks.",
222
+ ),
223
+ ] = False,
224
+ ) -> None:
225
+ """Validate eval.toml, the dataset, and the links between them."""
226
+ out = console()
227
+ sym = symbols()
228
+
229
+ loaded = load_config(find_config(config))
230
+ dataset = load_dataset(require_dataset(loaded))
231
+ warnings = cross_check(loaded, dataset)
232
+
233
+ if as_json:
234
+ # print, not rich-print: this is data on its way into a pipe or a
235
+ # `$GITHUB_OUTPUT`, and Rich would wrap and style it into something
236
+ # no parser can read.
237
+ print(json.dumps(as_payload(loaded, dataset, warnings), indent=2))
238
+ if warnings and strict:
239
+ raise typer.Exit(exit_codes.USAGE)
240
+ return
241
+
242
+ out.print()
243
+ out.print(_summary(loaded, dataset))
244
+
245
+ if strict:
246
+ func = resolve_entrypoint(loaded)
247
+ kind = "async" if inspect.iscoroutinefunction(func) else "sync"
248
+ out.print()
249
+ out.print(
250
+ Text(f" {sym.ok} ", style="green"),
251
+ end="",
252
+ )
253
+ out.print(
254
+ Text(
255
+ f"imported {loaded.config.target.entrypoint} ({kind})",
256
+ style="dim",
257
+ )
258
+ )
259
+ if kind == "sync":
260
+ warnings.append(
261
+ Problem(
262
+ path="target.entrypoint",
263
+ message="target is a plain function; it will be run in a worker thread, "
264
+ "where contextvars-based tool interception is more fragile. An "
265
+ "`async def` target is strongly preferred.",
266
+ )
267
+ )
268
+
269
+ out.print()
270
+ for warning in warnings:
271
+ out.print(Text(f" {sym.warn} {warning.path}", style="yellow"), end="")
272
+ out.print(Text(f" {sym.arrow} {warning.message}"))
273
+ if warning.line:
274
+ out.print(
275
+ Text(
276
+ f" {loaded.path.name}:{warning.line}",
277
+ style="dim",
278
+ )
279
+ )
280
+ if warnings:
281
+ out.print()
282
+
283
+ count = len(warnings)
284
+ if count and strict:
285
+ out.print(
286
+ Text(
287
+ f" {sym.fail} {count} warning(s), and --strict was given",
288
+ style="bold red",
289
+ )
290
+ )
291
+ out.print()
292
+ raise typer.Exit(exit_codes.USAGE)
293
+
294
+ label = f"valid - {count} warning(s)" if count else "valid"
295
+ out.print(Text(f" {sym.ok} {label}", style="bold green"))
296
+ out.print()
slmeval/cli/loading.py ADDED
@@ -0,0 +1,149 @@
1
+ """Getting from a command-line invocation to a loaded project.
2
+
3
+ Four commands need the same five things - config, dataset, target, trace store,
4
+ cassette store - and they have to fail in the same order every time: config
5
+ before dataset, dataset before importing the user's agent. A typo in
6
+ ``eval.toml`` must not be reported as an import error, and importing the user's
7
+ module before validating their dataset makes a trivial mistake arrive as a
8
+ stack trace from somebody else's code.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import contextlib
14
+ from collections.abc import Iterator
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ from slmeval.config.loader import LoadedConfig, find_config, load_config
19
+ from slmeval.config.models import EvalConfig
20
+ from slmeval.core.adapter import Target, load_target
21
+ from slmeval.dataset.loader import Dataset, load_dataset
22
+ from slmeval.errors import ConfigError, DatasetError
23
+ from slmeval.eval.cost import PriceTable
24
+ from slmeval.sandbox.store import CassetteStore
25
+ from slmeval.storage.db import RunStore
26
+
27
+
28
+ @dataclass(slots=True)
29
+ class Project:
30
+ """Everything a run needs, opened and ready."""
31
+
32
+ loaded: LoadedConfig
33
+ dataset: Dataset
34
+ target: Target
35
+ store: RunStore
36
+ cassettes: CassetteStore
37
+ prices: PriceTable
38
+
39
+ @property
40
+ def config(self) -> EvalConfig:
41
+ return self.loaded.config
42
+
43
+ @property
44
+ def root(self) -> Path:
45
+ return self.loaded.root
46
+
47
+ def close(self) -> None:
48
+ # Suppressed individually: a failure closing one store must not leave
49
+ # the other open, and neither is worth masking the error that brought
50
+ # us here.
51
+ with contextlib.suppress(Exception):
52
+ self.cassettes.close()
53
+ with contextlib.suppress(Exception):
54
+ self.store.close()
55
+
56
+
57
+ @contextlib.contextmanager
58
+ def open_project(
59
+ config_path: Path | None = None,
60
+ *,
61
+ gpu_hourly_usd: float | None = None,
62
+ load_agent: bool = True,
63
+ ) -> Iterator[Project]:
64
+ """Open a project, closing both stores on the way out.
65
+
66
+ ``load_agent=False`` skips importing the user's module, for commands that
67
+ only read stored results. ``slmeval report`` must work on a checkout whose
68
+ agent no longer imports - the results are still valid, and refusing to
69
+ print them would be the tool losing data it already has.
70
+ """
71
+ path = find_config(config_path)
72
+ loaded = load_config(path)
73
+ dataset = _dataset(loaded)
74
+
75
+ target = (
76
+ load_target(
77
+ loaded.config.target.entrypoint,
78
+ adapter=loaded.config.target.adapter,
79
+ root=loaded.root,
80
+ # Resolved against eval.toml, not the working directory: the same
81
+ # command has to mean the same thing run from the project root and
82
+ # from two levels down.
83
+ extra_paths=[
84
+ p if p.is_absolute() else (loaded.root / p).resolve()
85
+ for p in loaded.config.target.sys_path
86
+ ],
87
+ )
88
+ if load_agent
89
+ else Target(entrypoint=loaded.config.target.entrypoint, func=_unloaded, is_async=False)
90
+ )
91
+
92
+ store = RunStore.for_project(loaded.root)
93
+ cassettes = CassetteStore.for_project(loaded.root)
94
+ project = Project(
95
+ loaded=loaded,
96
+ dataset=dataset,
97
+ target=target,
98
+ store=store,
99
+ cassettes=cassettes,
100
+ prices=PriceTable.load(loaded.root, gpu_hourly_usd=gpu_hourly_usd),
101
+ )
102
+ try:
103
+ yield project
104
+ finally:
105
+ project.close()
106
+
107
+
108
+ LATEST = "latest"
109
+ """What every command accepts in place of a run id."""
110
+
111
+
112
+ def resolve_run(store: RunStore, run_id: str = LATEST) -> str:
113
+ """``latest``, a full run id, or an unambiguous prefix, resolved to one id.
114
+
115
+ Shared by ``report``, ``show`` and ``gate`` rather than written three
116
+ times: all three have to treat ``latest`` identically, and a prefix that
117
+ matches two runs has to be refused rather than resolved to whichever one
118
+ sorted first - a report for a run the user was not asking about is worse
119
+ than an error.
120
+ """
121
+ if run_id == LATEST:
122
+ latest = store.latest_run()
123
+ if latest is None:
124
+ raise ConfigError(
125
+ "no runs stored yet",
126
+ hint="run `slmeval run` first",
127
+ )
128
+ return latest.run_id
129
+ return store.resolve_run_id(run_id)
130
+
131
+
132
+ def _dataset(loaded: LoadedConfig) -> Dataset:
133
+ path = loaded.dataset_path
134
+ if not path.is_file():
135
+ raise DatasetError(
136
+ f"dataset not found: {path}",
137
+ hint=(
138
+ f"[benchmark].dataset in {loaded.path.name} points at "
139
+ f"{loaded.config.benchmark.dataset}, resolved against {loaded.root}"
140
+ ),
141
+ )
142
+ return load_dataset(path)
143
+
144
+
145
+ def _unloaded(_: object) -> object: # pragma: no cover - never called
146
+ raise ConfigError("the agent was not loaded for this command")
147
+
148
+
149
+ __all__ = ["LATEST", "Project", "open_project", "resolve_run"]
slmeval/cli/main.py ADDED
@@ -0,0 +1,91 @@
1
+ """CLI entrypoint.
2
+
3
+ Commands are registered in the order they should appear in `--help`. The planned
4
+ ones come last and stay hidden from it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Annotated
10
+
11
+ import typer
12
+
13
+ from slmeval import __version__
14
+ from slmeval.cli.commands import (
15
+ baseline_cmd,
16
+ cache_cmd,
17
+ dataset_cmd,
18
+ doctor_cmd,
19
+ gate_cmd,
20
+ init_cmd,
21
+ judge_cmd,
22
+ record_cmd,
23
+ report_cmd,
24
+ run_cmd,
25
+ show_cmd,
26
+ stubs,
27
+ validate_cmd,
28
+ )
29
+ from slmeval.cli.render import console
30
+
31
+ app = typer.Typer(
32
+ name="slmeval",
33
+ no_args_is_help=True,
34
+ add_completion=True,
35
+ rich_markup_mode="rich",
36
+ # Locals can hold a resolved API key, so they never go into a traceback.
37
+ pretty_exceptions_show_locals=False,
38
+ context_settings={"help_option_names": ["-h", "--help"]},
39
+ )
40
+
41
+
42
+ def _version_callback(value: bool) -> None:
43
+ if value:
44
+ console().print(f"slmeval {__version__}")
45
+ raise typer.Exit()
46
+
47
+
48
+ @app.callback()
49
+ def cli(
50
+ version: Annotated[
51
+ bool,
52
+ typer.Option(
53
+ "--version",
54
+ "-V",
55
+ callback=_version_callback,
56
+ is_eager=True,
57
+ help="Show the version and exit.",
58
+ ),
59
+ ] = False,
60
+ ) -> None:
61
+ """Benchmark agent workflows across small language models.
62
+
63
+ Record your agent's tool calls once, replay them deterministically against
64
+ every candidate model, and see what each swap costs you in reliability.
65
+
66
+ Start with [cyan]slmeval init[/cyan], then [cyan]slmeval doctor[/cyan].
67
+ """
68
+
69
+
70
+ init_cmd.register(app)
71
+ doctor_cmd.register(app)
72
+ validate_cmd.register(app)
73
+ dataset_cmd.register(app)
74
+ record_cmd.register(app)
75
+ run_cmd.register(app)
76
+ report_cmd.register(app)
77
+ show_cmd.register(app)
78
+ gate_cmd.register(app)
79
+ baseline_cmd.register(app)
80
+ judge_cmd.register(app)
81
+ cache_cmd.register(app)
82
+ stubs.register(app)
83
+
84
+
85
+ def main() -> None:
86
+ """Console-script entrypoint."""
87
+ app()
88
+
89
+
90
+ if __name__ == "__main__": # pragma: no cover
91
+ main()