manifold-cli 0.0.39__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.
- manifold_cli/__init__.py +3 -0
- manifold_cli/__main__.py +4 -0
- manifold_cli/bridge.py +70 -0
- manifold_cli/cli.py +54 -0
- manifold_cli/clients/__init__.py +0 -0
- manifold_cli/clients/auth_flow.py +169 -0
- manifold_cli/clients/credentials.py +53 -0
- manifold_cli/clients/docker_engine.py +43 -0
- manifold_cli/clients/image.py +130 -0
- manifold_cli/clients/manifold.py +1288 -0
- manifold_cli/clients/runner_home.py +165 -0
- manifold_cli/clients/workos.py +99 -0
- manifold_cli/commands/__init__.py +0 -0
- manifold_cli/commands/auth.py +101 -0
- manifold_cli/commands/benchmark.py +539 -0
- manifold_cli/commands/organization.py +154 -0
- manifold_cli/commands/policy.py +749 -0
- manifold_cli/commands/run.py +337 -0
- manifold_cli/commands/run_log.py +291 -0
- manifold_cli/commands/runner.py +852 -0
- manifold_cli/commands/table.py +101 -0
- manifold_cli/commands/token.py +144 -0
- manifold_cli/dependencies.py +116 -0
- manifold_cli/models/__init__.py +0 -0
- manifold_cli/models/auth.py +49 -0
- manifold_cli/models/benchmark.py +87 -0
- manifold_cli/models/catalog.py +189 -0
- manifold_cli/models/credentials.py +19 -0
- manifold_cli/models/errors.py +121 -0
- manifold_cli/models/log.py +114 -0
- manifold_cli/models/observability.py +121 -0
- manifold_cli/models/organization.py +19 -0
- manifold_cli/models/pagination.py +59 -0
- manifold_cli/models/policy.py +49 -0
- manifold_cli/models/run.py +381 -0
- manifold_cli/models/runner.py +186 -0
- manifold_cli/models/runner_home.py +69 -0
- manifold_cli/models/task_queue.py +40 -0
- manifold_cli/models/token.py +39 -0
- manifold_cli/replay/__init__.py +68 -0
- manifold_cli/replay/log.py +814 -0
- manifold_cli/runner/__init__.py +72 -0
- manifold_cli/runner/api_retry.py +36 -0
- manifold_cli/runner/benchmark/__init__.py +800 -0
- manifold_cli/runner/benchmark/live_view.py +248 -0
- manifold_cli/runner/benchmark/preassigned_scheduling.py +325 -0
- manifold_cli/runner/benchmark/recording.py +1208 -0
- manifold_cli/runner/benchmark/scheduling.py +256 -0
- manifold_cli/runner/benchmark/task_queue_scheduling.py +717 -0
- manifold_cli/runner/benchmark/task_server.py +123 -0
- manifold_cli/runner/benchmark/video.py +124 -0
- manifold_cli/runner/benchmark_steps.py +178 -0
- manifold_cli/runner/cancellation.py +22 -0
- manifold_cli/runner/capture.py +282 -0
- manifold_cli/runner/container.py +370 -0
- manifold_cli/runner/current_dispatch.py +25 -0
- manifold_cli/runner/daemon.py +520 -0
- manifold_cli/runner/dispatch.py +38 -0
- manifold_cli/runner/gpu.py +182 -0
- manifold_cli/runner/heartbeat.py +202 -0
- manifold_cli/runner/loopback.py +102 -0
- manifold_cli/runner/loopback_bench.py +183 -0
- manifold_cli/runner/loopback_policy.py +39 -0
- manifold_cli/runner/output.py +218 -0
- manifold_cli/runner/pairing_acceptance.py +57 -0
- manifold_cli/runner/plan.py +369 -0
- manifold_cli/runner/policy.py +350 -0
- manifold_cli/runner/policy_steps.py +164 -0
- manifold_cli/runner/preparation.py +47 -0
- manifold_cli/runner/process.py +219 -0
- manifold_cli/runner/ram.py +17 -0
- manifold_cli/runner/resource_usage.py +265 -0
- manifold_cli/runner/served.py +218 -0
- manifold_cli/runner/service.py +120 -0
- manifold_cli/runner/side_failure.py +68 -0
- manifold_cli/runner/signature.py +98 -0
- manifold_cli/runner/source.py +66 -0
- manifold_cli/runner/stages.py +241 -0
- manifold_cli/runner/step_timing.py +229 -0
- manifold_cli/runner/worker.py +534 -0
- manifold_cli/runner/workload.py +401 -0
- manifold_cli/services/__init__.py +0 -0
- manifold_cli/services/auth.py +89 -0
- manifold_cli/services/benchmark.py +357 -0
- manifold_cli/services/organization.py +22 -0
- manifold_cli/services/policy.py +234 -0
- manifold_cli/services/run.py +304 -0
- manifold_cli/services/runner.py +207 -0
- manifold_cli/services/runner_daemon.py +146 -0
- manifold_cli/services/token.py +52 -0
- manifold_cli/settings.py +30 -0
- manifold_cli/transport/__init__.py +31 -0
- manifold_cli/transport/diagnostics.py +72 -0
- manifold_cli/transport/interfaces.py +69 -0
- manifold_cli/transport/iroh.py +317 -0
- manifold_cli/transport/registry.py +34 -0
- manifold_cli/transport/tcp.py +146 -0
- manifold_cli-0.0.39.dist-info/METADATA +406 -0
- manifold_cli-0.0.39.dist-info/RECORD +102 -0
- manifold_cli-0.0.39.dist-info/WHEEL +4 -0
- manifold_cli-0.0.39.dist-info/entry_points.txt +2 -0
- manifold_cli-0.0.39.dist-info/licenses/LICENSE +373 -0
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
"""The `manifold benchmark` command group."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.markup import escape
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from manifold_cli.bridge import run_bridge
|
|
15
|
+
from manifold_cli.clients.image import ImageInspectError
|
|
16
|
+
from manifold_cli.commands.organization import (
|
|
17
|
+
ORGANIZATION_CALL_ERRORS,
|
|
18
|
+
exit_for_call_error,
|
|
19
|
+
resolve_owner,
|
|
20
|
+
)
|
|
21
|
+
from manifold_cli.commands.table import catalog_table, print_table, terminal_width
|
|
22
|
+
from manifold_cli.dependencies import Dependencies
|
|
23
|
+
from manifold_cli.models.benchmark import (
|
|
24
|
+
BenchmarkNotFoundError,
|
|
25
|
+
BenchmarkVersion,
|
|
26
|
+
InvalidImageRubricError,
|
|
27
|
+
InvalidImageSpecError,
|
|
28
|
+
)
|
|
29
|
+
from manifold_cli.models.catalog import (
|
|
30
|
+
CatalogRegisterError,
|
|
31
|
+
InvalidImageError,
|
|
32
|
+
InvalidReferenceError,
|
|
33
|
+
Visibility,
|
|
34
|
+
join_versioned_ref,
|
|
35
|
+
split_ref,
|
|
36
|
+
split_versioned_ref,
|
|
37
|
+
)
|
|
38
|
+
from manifold_cli.models.run import SchedulingType
|
|
39
|
+
from manifold_cli.transport import DEFAULT_TRANSPORTS, TcpTransport, TransportError
|
|
40
|
+
from manifold_cli.transport.diagnostics import PingResult, ping
|
|
41
|
+
|
|
42
|
+
DEFAULT_BRIDGE_LISTEN = "127.0.0.1:9001"
|
|
43
|
+
|
|
44
|
+
DEFAULT_PING_BYTES = 4 * 1024
|
|
45
|
+
DEFAULT_PING_TIMEOUT_SECONDS = 30.0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _publish_rubric(
|
|
49
|
+
console: Console,
|
|
50
|
+
deps: Dependencies,
|
|
51
|
+
slug: str,
|
|
52
|
+
version: str,
|
|
53
|
+
owner: str,
|
|
54
|
+
*,
|
|
55
|
+
image: str,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Publish the rubric the just-registered image carries, and say so.
|
|
58
|
+
|
|
59
|
+
Silent for an image carrying none. An image this box does not have is
|
|
60
|
+
reported as unread rather than as carrying nothing.
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
tasks = deps.benchmark_service.publish_image_rubric(
|
|
64
|
+
slug, version, owner, image=image
|
|
65
|
+
)
|
|
66
|
+
except ImageInspectError as exc:
|
|
67
|
+
console.print(f"[dim]Rubric not read: {escape(str(exc))}.[/dim]")
|
|
68
|
+
return
|
|
69
|
+
except InvalidImageRubricError as exc:
|
|
70
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
71
|
+
raise typer.Exit(1) from exc
|
|
72
|
+
except (CatalogRegisterError, BenchmarkNotFoundError) as exc:
|
|
73
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
74
|
+
raise typer.Exit(1) from exc
|
|
75
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
76
|
+
raise exit_for_call_error(console, exc) from exc
|
|
77
|
+
if tasks is None:
|
|
78
|
+
return
|
|
79
|
+
console.print(
|
|
80
|
+
f"[green]✓ Published rubric[/green] "
|
|
81
|
+
f"[dim]{tasks} task{'' if tasks == 1 else 's'}[/dim]"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _read_spec(console: Console, deps: Dependencies, image: str) -> dict | None:
|
|
86
|
+
"""The spec read from `image`, or None when the image has no spec file or
|
|
87
|
+
cannot be read.
|
|
88
|
+
|
|
89
|
+
An unreadable image registers without a spec, and the output says so. A
|
|
90
|
+
spec that is not a JSON object stops the command before it registers
|
|
91
|
+
anything.
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
return deps.benchmark_service.read_image_spec(image)
|
|
95
|
+
except ImageInspectError as exc:
|
|
96
|
+
console.print(f"[dim]Spec not read: {escape(str(exc))}.[/dim]")
|
|
97
|
+
return None
|
|
98
|
+
except InvalidImageSpecError as exc:
|
|
99
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
100
|
+
raise typer.Exit(1) from exc
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _describe_shards(version: BenchmarkVersion) -> str:
|
|
104
|
+
if version.shards_per_gpu is not None:
|
|
105
|
+
return f"{version.shards_per_gpu} shards per GPU"
|
|
106
|
+
return f"{version.shards} shard{'' if version.shards == 1 else 's'}"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def register(app: typer.Typer, deps: Dependencies) -> None:
|
|
110
|
+
console = Console()
|
|
111
|
+
benchmark_app = typer.Typer(
|
|
112
|
+
help="Run benchmarks against a policy.", no_args_is_help=True
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
@benchmark_app.command("list")
|
|
116
|
+
def list_benchmarks() -> None:
|
|
117
|
+
"""List the benchmarks you can see."""
|
|
118
|
+
try:
|
|
119
|
+
benchmarks = deps.benchmark_service.list_benchmarks()
|
|
120
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
121
|
+
raise exit_for_call_error(console, exc) from exc
|
|
122
|
+
if len(benchmarks) == 0:
|
|
123
|
+
console.print("There are no benchmarks yet.")
|
|
124
|
+
return
|
|
125
|
+
print_table(console, catalog_table(benchmarks, terminal_width(console)))
|
|
126
|
+
|
|
127
|
+
@benchmark_app.command()
|
|
128
|
+
def versions(
|
|
129
|
+
benchmark: Annotated[
|
|
130
|
+
str,
|
|
131
|
+
typer.Argument(
|
|
132
|
+
help="Benchmark identifier, or <org>/<identifier> to specify its owner."
|
|
133
|
+
),
|
|
134
|
+
],
|
|
135
|
+
) -> None:
|
|
136
|
+
"""List the versions of a benchmark, newest first."""
|
|
137
|
+
try:
|
|
138
|
+
owner, slug = split_ref(benchmark)
|
|
139
|
+
except InvalidReferenceError as exc:
|
|
140
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
141
|
+
raise typer.Exit(1) from exc
|
|
142
|
+
# A qualified reference names its owner outright. A bare slug resolves
|
|
143
|
+
# the way `run submit` resolves one: the caller's own benchmark first,
|
|
144
|
+
# then a public benchmark of that slug.
|
|
145
|
+
list_versions = (
|
|
146
|
+
deps.benchmark_service.list_versions
|
|
147
|
+
if owner is not None
|
|
148
|
+
else deps.benchmark_service.list_own_or_public_versions
|
|
149
|
+
)
|
|
150
|
+
if owner is None:
|
|
151
|
+
try:
|
|
152
|
+
owner = resolve_owner(console, deps.organization_service)
|
|
153
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
154
|
+
raise exit_for_call_error(console, exc) from exc
|
|
155
|
+
try:
|
|
156
|
+
found = list_versions(slug, owner)
|
|
157
|
+
except BenchmarkNotFoundError as exc:
|
|
158
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
159
|
+
raise typer.Exit(1) from exc
|
|
160
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
161
|
+
raise exit_for_call_error(console, exc) from exc
|
|
162
|
+
if len(found) == 0:
|
|
163
|
+
console.print(f"'{escape(benchmark)}' has no versions yet.")
|
|
164
|
+
return
|
|
165
|
+
table = Table(box=None, pad_edge=False)
|
|
166
|
+
table.add_column("Version")
|
|
167
|
+
table.add_column("Adapter", style="dim")
|
|
168
|
+
table.add_column("Episodes", style="dim", justify="right")
|
|
169
|
+
table.add_column("Shards", style="dim", justify="right")
|
|
170
|
+
table.add_column("Shards/GPU", style="dim", justify="right")
|
|
171
|
+
for version in found:
|
|
172
|
+
table.add_row(
|
|
173
|
+
Text(version.version, style="bold"),
|
|
174
|
+
version.adapter.value,
|
|
175
|
+
Text(str(version.episodes)),
|
|
176
|
+
Text(str(version.shards)),
|
|
177
|
+
Text(
|
|
178
|
+
str(version.shards_per_gpu)
|
|
179
|
+
if version.shards_per_gpu is not None
|
|
180
|
+
else "-"
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
print_table(console, table)
|
|
184
|
+
|
|
185
|
+
@benchmark_app.command()
|
|
186
|
+
def init(
|
|
187
|
+
benchmark: Annotated[
|
|
188
|
+
str,
|
|
189
|
+
typer.Argument(
|
|
190
|
+
help="Benchmark identifier to register the image under, "
|
|
191
|
+
"or <org>/<identifier> to specify the organization that owns it."
|
|
192
|
+
),
|
|
193
|
+
],
|
|
194
|
+
image: Annotated[
|
|
195
|
+
str,
|
|
196
|
+
typer.Option(
|
|
197
|
+
"--image",
|
|
198
|
+
help="Container image, e.g. ghcr.io/bifrost/libero-spatial:0.1.0. "
|
|
199
|
+
"Its tag is the version this registers.",
|
|
200
|
+
),
|
|
201
|
+
],
|
|
202
|
+
episodes: Annotated[
|
|
203
|
+
int,
|
|
204
|
+
typer.Option(
|
|
205
|
+
"--episodes",
|
|
206
|
+
min=1,
|
|
207
|
+
help="Episodes this image runs. Every run of the version "
|
|
208
|
+
"executes that many.",
|
|
209
|
+
),
|
|
210
|
+
],
|
|
211
|
+
minimum_gpu_memory_gb: Annotated[
|
|
212
|
+
int,
|
|
213
|
+
typer.Option(
|
|
214
|
+
"--minimum-gpu-memory-gb",
|
|
215
|
+
min=0,
|
|
216
|
+
help="Minimum memory required on one GPU. Set to 0 to allow "
|
|
217
|
+
"placement on runners without a GPU.",
|
|
218
|
+
),
|
|
219
|
+
],
|
|
220
|
+
minimum_driver_major: Annotated[
|
|
221
|
+
int | None,
|
|
222
|
+
typer.Option(
|
|
223
|
+
"--minimum-driver-major",
|
|
224
|
+
min=1,
|
|
225
|
+
help="Minimum NVIDIA driver major version required on a "
|
|
226
|
+
"runner. Omit to leave the lower bound open.",
|
|
227
|
+
),
|
|
228
|
+
] = None,
|
|
229
|
+
maximum_driver_major: Annotated[
|
|
230
|
+
int | None,
|
|
231
|
+
typer.Option(
|
|
232
|
+
"--maximum-driver-major",
|
|
233
|
+
min=1,
|
|
234
|
+
help="Maximum NVIDIA driver major version allowed on a "
|
|
235
|
+
"runner. Omit to leave the upper bound open.",
|
|
236
|
+
),
|
|
237
|
+
] = None,
|
|
238
|
+
minimum_gpu_count: Annotated[
|
|
239
|
+
int | None,
|
|
240
|
+
typer.Option(
|
|
241
|
+
"--minimum-gpu-count",
|
|
242
|
+
min=0,
|
|
243
|
+
help="Minimum number of GPUs required on one runner. If omitted, "
|
|
244
|
+
"the default is 1, or 0 when --minimum-gpu-memory-gb is 0.",
|
|
245
|
+
),
|
|
246
|
+
] = None,
|
|
247
|
+
shards: Annotated[
|
|
248
|
+
int,
|
|
249
|
+
typer.Option(
|
|
250
|
+
"--shards",
|
|
251
|
+
min=1,
|
|
252
|
+
help="Parallel shards the image partitions its episodes across. "
|
|
253
|
+
"A run fans out to this many unless `run submit --shards` names "
|
|
254
|
+
"another count; 1 does not partition. A count above `--episodes` "
|
|
255
|
+
"is clamped to it at registration.",
|
|
256
|
+
),
|
|
257
|
+
] = 1,
|
|
258
|
+
shards_per_gpu: Annotated[
|
|
259
|
+
int | None,
|
|
260
|
+
typer.Option(
|
|
261
|
+
"--shards-per-gpu",
|
|
262
|
+
min=1,
|
|
263
|
+
help="Parallel shards per GPU of each benchmark runner, in place "
|
|
264
|
+
"of --shards. A run fans out to this many per GPU, up to one shard "
|
|
265
|
+
"per episode, unless `run submit` sets another count.",
|
|
266
|
+
),
|
|
267
|
+
] = None,
|
|
268
|
+
scheduling_type: Annotated[
|
|
269
|
+
SchedulingType,
|
|
270
|
+
typer.Option(
|
|
271
|
+
"--scheduling-type",
|
|
272
|
+
help="How the benchmark side divides a run's episodes among its "
|
|
273
|
+
"shards: with `preassigned`, each shard's worker runs the episodes "
|
|
274
|
+
"of its own shard, and with `task_queue`, each worker runs tasks "
|
|
275
|
+
"claimed from the run's task queue.",
|
|
276
|
+
),
|
|
277
|
+
] = SchedulingType.PREASSIGNED,
|
|
278
|
+
allow_preemptible: Annotated[
|
|
279
|
+
bool,
|
|
280
|
+
typer.Option(
|
|
281
|
+
"--allow-preemptible",
|
|
282
|
+
help="Let a run of this version use runners whose VM the "
|
|
283
|
+
"provider may stop at any time, for both its policy and its "
|
|
284
|
+
"benchmark side.",
|
|
285
|
+
),
|
|
286
|
+
] = False,
|
|
287
|
+
display_name: Annotated[
|
|
288
|
+
str | None,
|
|
289
|
+
typer.Option(
|
|
290
|
+
"--display-name",
|
|
291
|
+
help="Name shown in `benchmark list`. Left off, an existing "
|
|
292
|
+
"benchmark keeps the name it has and a new one uses its identifier.",
|
|
293
|
+
),
|
|
294
|
+
] = None,
|
|
295
|
+
visibility: Annotated[
|
|
296
|
+
Visibility | None,
|
|
297
|
+
typer.Option(
|
|
298
|
+
"--visibility",
|
|
299
|
+
help="Who can read the benchmark. Left off, an existing "
|
|
300
|
+
"benchmark keeps the visibility it has and a new one is visible "
|
|
301
|
+
"to its organization.",
|
|
302
|
+
),
|
|
303
|
+
] = None,
|
|
304
|
+
) -> None:
|
|
305
|
+
"""Register a container image as a version of a benchmark."""
|
|
306
|
+
try:
|
|
307
|
+
owner, slug = split_ref(benchmark)
|
|
308
|
+
except InvalidReferenceError as exc:
|
|
309
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
310
|
+
raise typer.Exit(1) from exc
|
|
311
|
+
spec = _read_spec(console, deps, image)
|
|
312
|
+
try:
|
|
313
|
+
if owner is None:
|
|
314
|
+
owner = resolve_owner(console, deps.organization_service)
|
|
315
|
+
version = deps.benchmark_service.register_container_version(
|
|
316
|
+
slug,
|
|
317
|
+
owner,
|
|
318
|
+
image=image,
|
|
319
|
+
episodes=episodes,
|
|
320
|
+
minimum_gpu_memory_gb=minimum_gpu_memory_gb,
|
|
321
|
+
minimum_driver_major=minimum_driver_major,
|
|
322
|
+
maximum_driver_major=maximum_driver_major,
|
|
323
|
+
minimum_gpu_count=minimum_gpu_count,
|
|
324
|
+
scheduling_type=scheduling_type,
|
|
325
|
+
allow_preemptible=allow_preemptible,
|
|
326
|
+
shards=shards,
|
|
327
|
+
shards_per_gpu=shards_per_gpu,
|
|
328
|
+
display_name=display_name,
|
|
329
|
+
visibility=visibility,
|
|
330
|
+
spec=spec,
|
|
331
|
+
)
|
|
332
|
+
except (
|
|
333
|
+
InvalidImageError,
|
|
334
|
+
CatalogRegisterError,
|
|
335
|
+
BenchmarkNotFoundError,
|
|
336
|
+
) as exc:
|
|
337
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
338
|
+
raise typer.Exit(1) from exc
|
|
339
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
340
|
+
raise exit_for_call_error(console, exc) from exc
|
|
341
|
+
console.print(
|
|
342
|
+
f"[green]✓ Registered[/green] [bold]{escape(benchmark)}[/bold] "
|
|
343
|
+
f"[bold]{escape(version.version)}[/bold] "
|
|
344
|
+
f"[dim]{version.adapter.value}, {version.episodes} episodes, "
|
|
345
|
+
f"{_describe_shards(version)}[/dim]"
|
|
346
|
+
)
|
|
347
|
+
console.print(f" {escape(image)}")
|
|
348
|
+
if spec is not None:
|
|
349
|
+
console.print("[green]✓ Recorded spec[/green]")
|
|
350
|
+
_publish_rubric(console, deps, slug, version.version, owner, image=image)
|
|
351
|
+
console.print(
|
|
352
|
+
f"Run it with: manifold run submit <policy> {escape(benchmark)} --name NAME"
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
@benchmark_app.command()
|
|
356
|
+
def spec(
|
|
357
|
+
benchmark: Annotated[
|
|
358
|
+
str,
|
|
359
|
+
typer.Argument(
|
|
360
|
+
help="Benchmark version, as [<org>/]<identifier>:<version>."
|
|
361
|
+
),
|
|
362
|
+
],
|
|
363
|
+
) -> None:
|
|
364
|
+
"""Print a benchmark version's spec as JSON.
|
|
365
|
+
|
|
366
|
+
The spec lists the observations, actions and embodiment the benchmark
|
|
367
|
+
sends to a policy. It was read from the image when the version was
|
|
368
|
+
registered. Save the output to pair a policy with the benchmark.
|
|
369
|
+
"""
|
|
370
|
+
try:
|
|
371
|
+
owner, slug, version = split_versioned_ref(benchmark)
|
|
372
|
+
except InvalidReferenceError as exc:
|
|
373
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
374
|
+
raise typer.Exit(1) from exc
|
|
375
|
+
if version is None:
|
|
376
|
+
console.print(
|
|
377
|
+
f"[red]'{escape(benchmark)}' names no version; "
|
|
378
|
+
"write <identifier>:<version>.[/red]"
|
|
379
|
+
)
|
|
380
|
+
raise typer.Exit(1)
|
|
381
|
+
or_public = owner is None
|
|
382
|
+
try:
|
|
383
|
+
if owner is None:
|
|
384
|
+
owner = resolve_owner(console, deps.organization_service)
|
|
385
|
+
found = deps.benchmark_service.version_spec(
|
|
386
|
+
slug, owner, version, or_public=or_public
|
|
387
|
+
)
|
|
388
|
+
except BenchmarkNotFoundError as exc:
|
|
389
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
390
|
+
raise typer.Exit(1) from exc
|
|
391
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
392
|
+
raise exit_for_call_error(console, exc) from exc
|
|
393
|
+
if found is None:
|
|
394
|
+
console.print(
|
|
395
|
+
f"[red]'{escape(benchmark)}' was registered without a spec.[/red]"
|
|
396
|
+
)
|
|
397
|
+
raise typer.Exit(1)
|
|
398
|
+
typer.echo(json.dumps(found, indent=2))
|
|
399
|
+
|
|
400
|
+
@benchmark_app.command()
|
|
401
|
+
def delete(
|
|
402
|
+
benchmark: Annotated[
|
|
403
|
+
str,
|
|
404
|
+
typer.Argument(
|
|
405
|
+
help="Benchmark to delete, as [<org>/]<identifier>[:<version>], for "
|
|
406
|
+
"example libero-spatial:0.0.1. With a version, only that "
|
|
407
|
+
"version is deleted."
|
|
408
|
+
),
|
|
409
|
+
],
|
|
410
|
+
) -> None:
|
|
411
|
+
"""Delete a benchmark, or one version of it.
|
|
412
|
+
|
|
413
|
+
A deleted benchmark or version is left out of listings and cannot be used
|
|
414
|
+
in new runs. `run get` still shows it on the runs that used it.
|
|
415
|
+
Registering it again restores it. A restored benchmark takes the display
|
|
416
|
+
name and visibility of that registration, not its earlier ones.
|
|
417
|
+
"""
|
|
418
|
+
try:
|
|
419
|
+
owner, slug, version = split_versioned_ref(benchmark)
|
|
420
|
+
except InvalidReferenceError as exc:
|
|
421
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
422
|
+
raise typer.Exit(1) from exc
|
|
423
|
+
try:
|
|
424
|
+
if owner is None:
|
|
425
|
+
owner = resolve_owner(console, deps.organization_service)
|
|
426
|
+
deps.benchmark_service.delete(slug, owner, version)
|
|
427
|
+
except BenchmarkNotFoundError as exc:
|
|
428
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
429
|
+
raise typer.Exit(1) from exc
|
|
430
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
431
|
+
raise exit_for_call_error(console, exc) from exc
|
|
432
|
+
deleted = (
|
|
433
|
+
"Benchmark deleted" if version is None else "Benchmark version deleted"
|
|
434
|
+
)
|
|
435
|
+
console.print(
|
|
436
|
+
f"[green]✓ {deleted}[/green] "
|
|
437
|
+
f"[bold]{escape(join_versioned_ref(owner, slug, version))}[/bold]"
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
@benchmark_app.command(hidden=True)
|
|
441
|
+
def run(
|
|
442
|
+
target: Annotated[
|
|
443
|
+
str,
|
|
444
|
+
typer.Argument(help="Policy target, e.g. tcp:127.0.0.1:9000 or iroh:ID."),
|
|
445
|
+
],
|
|
446
|
+
use_ping: Annotated[
|
|
447
|
+
bool,
|
|
448
|
+
typer.Option(
|
|
449
|
+
"--ping", help="Run a built-in ping benchmark (round-trips a payload)."
|
|
450
|
+
),
|
|
451
|
+
] = False,
|
|
452
|
+
size: Annotated[
|
|
453
|
+
int,
|
|
454
|
+
typer.Option("--size", help="Ping payload size in bytes."),
|
|
455
|
+
] = DEFAULT_PING_BYTES,
|
|
456
|
+
timeout: Annotated[
|
|
457
|
+
float,
|
|
458
|
+
typer.Option(
|
|
459
|
+
"--timeout",
|
|
460
|
+
help="Abort the ping if it has not completed after this many seconds.",
|
|
461
|
+
),
|
|
462
|
+
] = DEFAULT_PING_TIMEOUT_SECONDS,
|
|
463
|
+
) -> None:
|
|
464
|
+
"""Run a benchmark against the policy at the given transport target."""
|
|
465
|
+
if not use_ping:
|
|
466
|
+
console.print(
|
|
467
|
+
"[red]Real benchmarks are not available yet.[/red] "
|
|
468
|
+
"Pass --ping to run the built-in ping benchmark."
|
|
469
|
+
)
|
|
470
|
+
raise typer.Exit(1)
|
|
471
|
+
payload = os.urandom(size)
|
|
472
|
+
|
|
473
|
+
async def run_ping() -> PingResult:
|
|
474
|
+
async with asyncio.timeout(timeout):
|
|
475
|
+
return await ping(connector, payload)
|
|
476
|
+
|
|
477
|
+
try:
|
|
478
|
+
transport, rest = DEFAULT_TRANSPORTS.resolve(target)
|
|
479
|
+
connector = transport.build_connector(rest)
|
|
480
|
+
result = asyncio.run(run_ping())
|
|
481
|
+
except TimeoutError as exc:
|
|
482
|
+
console.print(f"[red]Ping timed out[/red] after {timeout:g} s.")
|
|
483
|
+
raise typer.Exit(1) from exc
|
|
484
|
+
except (TransportError, OSError) as exc:
|
|
485
|
+
console.print(f"[red]Ping failed:[/red] {escape(str(exc))}")
|
|
486
|
+
raise typer.Exit(1) from exc
|
|
487
|
+
|
|
488
|
+
if not result.matched:
|
|
489
|
+
console.print(
|
|
490
|
+
f"[red]Mismatch:[/red] sent {result.size} bytes, echo differed."
|
|
491
|
+
)
|
|
492
|
+
raise typer.Exit(1)
|
|
493
|
+
|
|
494
|
+
console.print(
|
|
495
|
+
f"[green]OK[/green] round-tripped {result.size} bytes via "
|
|
496
|
+
f"[bold]{escape(target)}[/bold] in [bold]{result.elapsed_ms:.2f} ms[/bold]."
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
@benchmark_app.command(hidden=True)
|
|
500
|
+
def bridge(
|
|
501
|
+
target: Annotated[
|
|
502
|
+
str,
|
|
503
|
+
typer.Argument(
|
|
504
|
+
help="Policy-bridge target for our transport, e.g. "
|
|
505
|
+
"tcp:100.101.132.68:9500 or iroh:ENDPOINT_ID."
|
|
506
|
+
),
|
|
507
|
+
],
|
|
508
|
+
listen: Annotated[
|
|
509
|
+
str,
|
|
510
|
+
typer.Option(
|
|
511
|
+
"--listen", help="Local address to accept the SDK benchmark, HOST:PORT."
|
|
512
|
+
),
|
|
513
|
+
] = DEFAULT_BRIDGE_LISTEN,
|
|
514
|
+
) -> None:
|
|
515
|
+
"""Expose a local TCP socket forwarding to a policy bridge over transport."""
|
|
516
|
+
|
|
517
|
+
async def run() -> None:
|
|
518
|
+
transport, rest = DEFAULT_TRANSPORTS.resolve(target)
|
|
519
|
+
connector = transport.build_connector(rest)
|
|
520
|
+
listener = await TcpTransport().open_listener(listen)
|
|
521
|
+
console.print(
|
|
522
|
+
"[green]Benchmark bridge up.[/green] Point the SDK benchmark's "
|
|
523
|
+
f"--server at: [bold]{escape(listener.target)}[/bold]"
|
|
524
|
+
)
|
|
525
|
+
console.print(
|
|
526
|
+
f"Forwarding to policy bridge at [bold]{escape(target)}[/bold]."
|
|
527
|
+
)
|
|
528
|
+
console.print("Bridging... (press Ctrl-C to stop)")
|
|
529
|
+
await run_bridge(listener, connector)
|
|
530
|
+
|
|
531
|
+
try:
|
|
532
|
+
asyncio.run(run())
|
|
533
|
+
except KeyboardInterrupt:
|
|
534
|
+
console.print("\n[yellow]Benchmark bridge stopped.[/yellow]")
|
|
535
|
+
except (TransportError, OSError) as exc:
|
|
536
|
+
console.print(f"[red]Bridge failed:[/red] {escape(str(exc))}")
|
|
537
|
+
raise typer.Exit(1) from exc
|
|
538
|
+
|
|
539
|
+
app.add_typer(benchmark_app, name="benchmark")
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from http import HTTPStatus
|
|
2
|
+
from json import JSONDecodeError
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
import typer
|
|
6
|
+
from pydantic import ValidationError
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.markup import escape
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from manifold_cli.commands.table import print_table
|
|
13
|
+
from manifold_cli.dependencies import Dependencies
|
|
14
|
+
from manifold_cli.models.errors import (
|
|
15
|
+
ManifoldApiError,
|
|
16
|
+
NotLoggedInError,
|
|
17
|
+
SessionExpiredError,
|
|
18
|
+
)
|
|
19
|
+
from manifold_cli.services.organization import OrganizationService
|
|
20
|
+
|
|
21
|
+
NO_ORGANIZATION_MESSAGE = (
|
|
22
|
+
"You do not have an organization yet. Reach out to Bifrost to get set up."
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Failures an organization API call can raise: no/expired credentials, a
|
|
26
|
+
# transport or HTTP-status error, or a 2xx body that isn't the JSON shape we
|
|
27
|
+
# expect. The login epilogue in commands.auth reuses this to stay in lockstep.
|
|
28
|
+
ORGANIZATION_CALL_ERRORS = (
|
|
29
|
+
NotLoggedInError,
|
|
30
|
+
SessionExpiredError,
|
|
31
|
+
ManifoldApiError,
|
|
32
|
+
httpx.HTTPError,
|
|
33
|
+
JSONDecodeError, # a 2xx body that isn't JSON
|
|
34
|
+
ValidationError, # a 2xx body that doesn't match our schema
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve_organization(
|
|
39
|
+
console: Console,
|
|
40
|
+
service: OrganizationService,
|
|
41
|
+
organization: str | None,
|
|
42
|
+
) -> str:
|
|
43
|
+
"""Settle which organization a --org command acts as.
|
|
44
|
+
|
|
45
|
+
Every policy, benchmark and run call is made as one organization. Anyone
|
|
46
|
+
who belongs to multiple organizations must specify which to use.
|
|
47
|
+
"""
|
|
48
|
+
if organization is not None:
|
|
49
|
+
return organization.strip().lower()
|
|
50
|
+
organizations = service.list()
|
|
51
|
+
if len(organizations) == 1:
|
|
52
|
+
return organizations[0].slug
|
|
53
|
+
if len(organizations) == 0:
|
|
54
|
+
console.print(NO_ORGANIZATION_MESSAGE)
|
|
55
|
+
raise typer.Exit(1)
|
|
56
|
+
slugs = ", ".join(o.slug for o in organizations)
|
|
57
|
+
console.print(
|
|
58
|
+
f"You belong to several organizations ({slugs}); pick one with --org."
|
|
59
|
+
)
|
|
60
|
+
raise typer.Exit(1)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def resolve_owner(console: Console, service: OrganizationService) -> str:
|
|
64
|
+
"""Settle who owns the entity a bare `<slug>` reference names.
|
|
65
|
+
|
|
66
|
+
A qualified `<org>/<slug>` names its owner already, so this is the bare
|
|
67
|
+
slug's fallback: the caller's own organization, or the qualified form when
|
|
68
|
+
they belong to several.
|
|
69
|
+
"""
|
|
70
|
+
organizations = service.list()
|
|
71
|
+
if len(organizations) == 1:
|
|
72
|
+
return organizations[0].slug
|
|
73
|
+
if len(organizations) == 0:
|
|
74
|
+
console.print(NO_ORGANIZATION_MESSAGE)
|
|
75
|
+
raise typer.Exit(1)
|
|
76
|
+
slugs = ", ".join(o.slug for o in organizations)
|
|
77
|
+
console.print(
|
|
78
|
+
f"You belong to several organizations ({slugs}); "
|
|
79
|
+
"specify the owner as <org>/<identifier>."
|
|
80
|
+
)
|
|
81
|
+
raise typer.Exit(1)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def exit_for_call_error(console: Console, exc: Exception) -> typer.Exit:
|
|
85
|
+
"""Report a failed API call, and return the Exit the command raises.
|
|
86
|
+
|
|
87
|
+
Every command group shares this so each failure names its own cause: a
|
|
88
|
+
missing or lapsed session names the command that fixes it, an HTTP error
|
|
89
|
+
status is reported with what the server said, and only a transport failure
|
|
90
|
+
is reported as Manifold being unreachable.
|
|
91
|
+
"""
|
|
92
|
+
match exc:
|
|
93
|
+
case NotLoggedInError():
|
|
94
|
+
console.print("Not logged in. Run `manifold auth login` to authenticate.")
|
|
95
|
+
case SessionExpiredError():
|
|
96
|
+
console.print(
|
|
97
|
+
"[yellow]Session expired.[/yellow] "
|
|
98
|
+
"Run `manifold auth login` to re-authenticate."
|
|
99
|
+
)
|
|
100
|
+
case ManifoldApiError():
|
|
101
|
+
console.print(_api_error_message(exc))
|
|
102
|
+
case JSONDecodeError() | ValidationError():
|
|
103
|
+
console.print(
|
|
104
|
+
f"[red]Manifold returned an unexpected response:[/red] "
|
|
105
|
+
f"{escape(str(exc))}"
|
|
106
|
+
)
|
|
107
|
+
case _:
|
|
108
|
+
console.print(f"[red]Could not reach Manifold:[/red] {escape(str(exc))}")
|
|
109
|
+
return typer.Exit(1)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _api_error_message(exc: ManifoldApiError) -> str:
|
|
113
|
+
"""The line for an HTTP error status no domain exception claimed.
|
|
114
|
+
|
|
115
|
+
The server's `detail` is the message where it sent one, and an excerpt of
|
|
116
|
+
the raw body is the fallback, clipped to the 200 characters `str(exc)`
|
|
117
|
+
shows.
|
|
118
|
+
"""
|
|
119
|
+
said = escape(exc.detail or exc.body[:200]) or "no detail given"
|
|
120
|
+
if exc.status_code == HTTPStatus.FORBIDDEN:
|
|
121
|
+
return f"[red]You do not have permission for this:[/red] {said}"
|
|
122
|
+
if exc.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR:
|
|
123
|
+
return f"[red]Manifold failed (HTTP {exc.status_code}):[/red] {said}"
|
|
124
|
+
return f"[red]Manifold rejected the request (HTTP {exc.status_code}):[/red] {said}"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def register(app: typer.Typer, deps: Dependencies) -> None:
|
|
128
|
+
console = Console()
|
|
129
|
+
org_app = typer.Typer(
|
|
130
|
+
name="org",
|
|
131
|
+
help="Manage organizations.",
|
|
132
|
+
no_args_is_help=True,
|
|
133
|
+
)
|
|
134
|
+
app.add_typer(org_app)
|
|
135
|
+
|
|
136
|
+
@org_app.command("list")
|
|
137
|
+
def list_organizations() -> None:
|
|
138
|
+
"""List the organizations you belong to."""
|
|
139
|
+
try:
|
|
140
|
+
organizations = deps.organization_service.list()
|
|
141
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
142
|
+
raise exit_for_call_error(console, exc) from exc
|
|
143
|
+
if not organizations:
|
|
144
|
+
console.print(NO_ORGANIZATION_MESSAGE)
|
|
145
|
+
return
|
|
146
|
+
table = Table(box=None, pad_edge=False)
|
|
147
|
+
table.add_column("Identifier")
|
|
148
|
+
table.add_column("Name")
|
|
149
|
+
for organization in organizations:
|
|
150
|
+
table.add_row(
|
|
151
|
+
Text(organization.slug, style="bold"),
|
|
152
|
+
Text(organization.display_name),
|
|
153
|
+
)
|
|
154
|
+
print_table(console, table)
|