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,101 @@
|
|
|
1
|
+
"""Headed tables for the listing commands."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from rich.cells import cell_len
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.measure import Measurement
|
|
9
|
+
from rich.segment import Segment, Segments
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from manifold_cli.models.catalog import CatalogEntry
|
|
14
|
+
|
|
15
|
+
# Wider than any terminal, so a table measured against it takes its natural width.
|
|
16
|
+
UNBOUNDED_WIDTH = 1 << 20
|
|
17
|
+
|
|
18
|
+
# The least of a display name worth showing before the row outgrows the terminal.
|
|
19
|
+
NAME_MIN_WIDTH = 12
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def relative_time(instant: datetime | None, now: datetime) -> str:
|
|
23
|
+
"""`3d ago`, `in 2h`, or `never`: the instant in its largest whole unit."""
|
|
24
|
+
if instant is None:
|
|
25
|
+
return "never"
|
|
26
|
+
seconds = int((instant - now).total_seconds())
|
|
27
|
+
magnitude = abs(seconds)
|
|
28
|
+
phrase = f"{magnitude}s"
|
|
29
|
+
for unit_seconds, suffix in ((86_400, "d"), (3_600, "h"), (60, "m")):
|
|
30
|
+
if magnitude >= unit_seconds:
|
|
31
|
+
phrase = f"{magnitude // unit_seconds}{suffix}"
|
|
32
|
+
break
|
|
33
|
+
return f"in {phrase}" if seconds > 0 else f"{phrase} ago"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def terminal_width(console: Console) -> int | None:
|
|
37
|
+
"""The console's width in a terminal, else None.
|
|
38
|
+
|
|
39
|
+
Rich reports 80 columns for a pipe, and a table cut to fit that would drop
|
|
40
|
+
text a script wanted whole.
|
|
41
|
+
"""
|
|
42
|
+
return console.width if console.is_terminal else None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def catalog_table(entries: Iterable[CatalogEntry], width: int | None) -> Table:
|
|
46
|
+
"""One row per policy or benchmark: its owner, slug, and display name.
|
|
47
|
+
|
|
48
|
+
The owner and slug are what a reference is typed from, so they are shown
|
|
49
|
+
whole. The name is prose that can run long: given a terminal `width`, it
|
|
50
|
+
gets what the other two leave and is cut at the edge with an ellipsis
|
|
51
|
+
rather than wrapped, so a row stays one line and the columns aligned.
|
|
52
|
+
Given None, every name is shown whole.
|
|
53
|
+
"""
|
|
54
|
+
entries = list(entries)
|
|
55
|
+
table = Table(box=None, pad_edge=False)
|
|
56
|
+
table.add_column("Owner", no_wrap=True)
|
|
57
|
+
table.add_column("Identifier", no_wrap=True)
|
|
58
|
+
name_width = None
|
|
59
|
+
if width is not None:
|
|
60
|
+
_, right, _, left = table.padding
|
|
61
|
+
owner_width = max(
|
|
62
|
+
cell_len("Owner"), *(cell_len(e.owner_org_slug) for e in entries)
|
|
63
|
+
)
|
|
64
|
+
slug_width = max(cell_len("Identifier"), *(cell_len(e.slug) for e in entries))
|
|
65
|
+
name_width = max(
|
|
66
|
+
width - owner_width - slug_width - 2 * (left + right), NAME_MIN_WIDTH
|
|
67
|
+
)
|
|
68
|
+
table.add_column("Name", no_wrap=True, overflow="ellipsis", max_width=name_width)
|
|
69
|
+
for entry in entries:
|
|
70
|
+
table.add_row(
|
|
71
|
+
Text(entry.owner_org_slug),
|
|
72
|
+
Text(entry.slug, style="bold"),
|
|
73
|
+
Text(entry.display_name),
|
|
74
|
+
)
|
|
75
|
+
return table
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def print_table(console: Console, table: Table) -> None:
|
|
79
|
+
"""Print a table at its own width, one line per row.
|
|
80
|
+
|
|
81
|
+
Rich fits a table to the console by wrapping cells and truncating what
|
|
82
|
+
still overflows, which turns a row into several lines and drops the tail
|
|
83
|
+
of a cell. Rendering at the table's own width leaves any overflow to the
|
|
84
|
+
terminal, and a pipe gets one whole row per line. Rich also pads every
|
|
85
|
+
cell to its column, so a row whose last cell runs short would end in
|
|
86
|
+
spaces; those are stripped.
|
|
87
|
+
"""
|
|
88
|
+
width = Measurement.get(
|
|
89
|
+
console, console.options.update_width(UNBOUNDED_WIDTH), table
|
|
90
|
+
).maximum
|
|
91
|
+
lines = console.render_lines(table, console.options.update_width(width), pad=False)
|
|
92
|
+
for line in lines:
|
|
93
|
+
while line and not line[-1].text.strip():
|
|
94
|
+
line.pop()
|
|
95
|
+
if line:
|
|
96
|
+
last = line[-1]
|
|
97
|
+
line[-1] = Segment(last.text.rstrip(), last.style, last.control)
|
|
98
|
+
console.print(
|
|
99
|
+
Segments(segment for line in lines for segment in (*line, Segment.line())),
|
|
100
|
+
crop=False,
|
|
101
|
+
)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Create and manage access tokens."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import unicodedata
|
|
6
|
+
from datetime import UTC, datetime, timedelta
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.markup import escape
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from manifold_cli.commands.organization import (
|
|
16
|
+
ORGANIZATION_CALL_ERRORS,
|
|
17
|
+
exit_for_call_error,
|
|
18
|
+
)
|
|
19
|
+
from manifold_cli.commands.table import print_table, relative_time
|
|
20
|
+
from manifold_cli.dependencies import Dependencies
|
|
21
|
+
from manifold_cli.models.token import AccessTokenNotFoundError, Scope
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def register(app: typer.Typer, deps: Dependencies) -> None:
|
|
25
|
+
console = Console()
|
|
26
|
+
token_app = typer.Typer(
|
|
27
|
+
name="token",
|
|
28
|
+
help="Manage access tokens.",
|
|
29
|
+
no_args_is_help=True,
|
|
30
|
+
hidden=True,
|
|
31
|
+
)
|
|
32
|
+
app.add_typer(token_app)
|
|
33
|
+
|
|
34
|
+
@token_app.command()
|
|
35
|
+
def create(
|
|
36
|
+
name: Annotated[str, typer.Argument(help="Name for the access token.")],
|
|
37
|
+
scopes: Annotated[
|
|
38
|
+
list[Scope],
|
|
39
|
+
typer.Option(
|
|
40
|
+
"--scope",
|
|
41
|
+
help="Operation the token may perform. Repeat for more than one.",
|
|
42
|
+
),
|
|
43
|
+
],
|
|
44
|
+
expires_in: Annotated[
|
|
45
|
+
str | None,
|
|
46
|
+
typer.Option(
|
|
47
|
+
"--expires-in",
|
|
48
|
+
help=(
|
|
49
|
+
"Time until expiry, from 1d to 90d. "
|
|
50
|
+
"Omit if the token should never expire."
|
|
51
|
+
),
|
|
52
|
+
),
|
|
53
|
+
] = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Create an access token and print its plaintext once."""
|
|
56
|
+
expires_at = _parse_expiry(expires_in)
|
|
57
|
+
try:
|
|
58
|
+
created = deps.token_service.create(name, scopes, expires_at=expires_at)
|
|
59
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
60
|
+
raise exit_for_call_error(console, exc) from exc
|
|
61
|
+
console.print(
|
|
62
|
+
f"[green]✓ Access token created[/green] "
|
|
63
|
+
f"[bold]{created.access_token.id}[/bold] "
|
|
64
|
+
f"{escape(_escape_control_characters(created.access_token.name))}",
|
|
65
|
+
soft_wrap=True,
|
|
66
|
+
)
|
|
67
|
+
console.print(created.token, markup=False, soft_wrap=True)
|
|
68
|
+
console.print("Store this token now. It will not be shown again.")
|
|
69
|
+
|
|
70
|
+
@token_app.command("list")
|
|
71
|
+
def list_access_tokens() -> None:
|
|
72
|
+
"""List access tokens without their plaintext."""
|
|
73
|
+
try:
|
|
74
|
+
access_tokens = deps.token_service.list()
|
|
75
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
76
|
+
raise exit_for_call_error(console, exc) from exc
|
|
77
|
+
now = datetime.now(UTC)
|
|
78
|
+
active_access_tokens = [
|
|
79
|
+
access_token
|
|
80
|
+
for access_token in access_tokens
|
|
81
|
+
if access_token.revoked_at is None
|
|
82
|
+
and (access_token.expires_at is None or access_token.expires_at > now)
|
|
83
|
+
]
|
|
84
|
+
if len(active_access_tokens) == 0:
|
|
85
|
+
console.print(
|
|
86
|
+
"You have no active access tokens. Create one with: "
|
|
87
|
+
"manifold token create NAME --scope runners:create",
|
|
88
|
+
soft_wrap=True,
|
|
89
|
+
)
|
|
90
|
+
return
|
|
91
|
+
table = Table(box=None, pad_edge=False)
|
|
92
|
+
for heading in ("Id", "Name", "Created", "Expires", "Last used"):
|
|
93
|
+
table.add_column(heading, no_wrap=True)
|
|
94
|
+
for access_token in active_access_tokens:
|
|
95
|
+
table.add_row(
|
|
96
|
+
f"[bold]{access_token.id}[/bold]",
|
|
97
|
+
escape(_escape_control_characters(access_token.name)),
|
|
98
|
+
relative_time(access_token.created_at, now),
|
|
99
|
+
relative_time(access_token.expires_at, now),
|
|
100
|
+
relative_time(access_token.last_used_at, now),
|
|
101
|
+
)
|
|
102
|
+
print_table(console, table)
|
|
103
|
+
|
|
104
|
+
@token_app.command()
|
|
105
|
+
def revoke(
|
|
106
|
+
token_id: Annotated[UUID, typer.Argument(help="Access token id.")],
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Revoke an access token."""
|
|
109
|
+
try:
|
|
110
|
+
access_token = deps.token_service.revoke(token_id)
|
|
111
|
+
except AccessTokenNotFoundError as exc:
|
|
112
|
+
console.print(f"[red]{escape(str(exc))}[/red]")
|
|
113
|
+
raise typer.Exit(1) from exc
|
|
114
|
+
except ORGANIZATION_CALL_ERRORS as exc:
|
|
115
|
+
raise exit_for_call_error(console, exc) from exc
|
|
116
|
+
console.print(
|
|
117
|
+
f"[green]✓ Access token revoked[/green] "
|
|
118
|
+
f"[bold]{access_token.id}[/bold] "
|
|
119
|
+
f"{escape(_escape_control_characters(access_token.name))}",
|
|
120
|
+
soft_wrap=True,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _parse_expiry(value: str | None) -> datetime | None:
|
|
125
|
+
if value is None:
|
|
126
|
+
return None
|
|
127
|
+
match = re.fullmatch(r"([1-9][0-9]?)d", value)
|
|
128
|
+
if match is not None:
|
|
129
|
+
days = int(match.group(1))
|
|
130
|
+
if days <= 90:
|
|
131
|
+
return datetime.now(UTC) + timedelta(days=days)
|
|
132
|
+
raise typer.BadParameter(
|
|
133
|
+
"must be a whole number of days between 1d and 90d",
|
|
134
|
+
param_hint="'--expires-in'",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _escape_control_characters(value: str) -> str:
|
|
139
|
+
return "".join(
|
|
140
|
+
json.dumps(character)[1:-1]
|
|
141
|
+
if unicodedata.category(character) == "Cc"
|
|
142
|
+
else character
|
|
143
|
+
for character in value
|
|
144
|
+
)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from manifold_cli.clients.auth_flow import (
|
|
4
|
+
InMemoryRunnerTokenAuth,
|
|
5
|
+
RunnerAuth,
|
|
6
|
+
StaticTokenAuth,
|
|
7
|
+
WorkOSAuth,
|
|
8
|
+
)
|
|
9
|
+
from manifold_cli.clients.credentials import CredentialsClient
|
|
10
|
+
from manifold_cli.clients.image import DockerImageClient
|
|
11
|
+
from manifold_cli.clients.manifold import ManifoldApiClient
|
|
12
|
+
from manifold_cli.clients.runner_home import RunnerHomeClient, RunnerHomes
|
|
13
|
+
from manifold_cli.clients.workos import WorkOSDeviceClient
|
|
14
|
+
from manifold_cli.models.errors import (
|
|
15
|
+
NoRunnerCredentialError,
|
|
16
|
+
RunnerDeploymentMismatchError,
|
|
17
|
+
)
|
|
18
|
+
from manifold_cli.services.auth import AuthService
|
|
19
|
+
from manifold_cli.services.benchmark import BenchmarkService
|
|
20
|
+
from manifold_cli.services.organization import OrganizationService
|
|
21
|
+
from manifold_cli.services.policy import PolicyService
|
|
22
|
+
from manifold_cli.services.run import RunService
|
|
23
|
+
from manifold_cli.services.runner import RunnerService
|
|
24
|
+
from manifold_cli.services.runner_daemon import (
|
|
25
|
+
RunnerDaemonManager,
|
|
26
|
+
RunnerDaemonService,
|
|
27
|
+
)
|
|
28
|
+
from manifold_cli.services.token import TokenService
|
|
29
|
+
from manifold_cli.settings import Settings
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class Dependencies:
|
|
34
|
+
auth_service: AuthService
|
|
35
|
+
organization_service: OrganizationService
|
|
36
|
+
policy_service: PolicyService
|
|
37
|
+
benchmark_service: BenchmarkService
|
|
38
|
+
run_service: RunService
|
|
39
|
+
runner_service: RunnerService
|
|
40
|
+
token_service: TokenService
|
|
41
|
+
runner_daemon_service: RunnerDaemonManager
|
|
42
|
+
# Where `runner register` puts the homes of the runners on this box. The
|
|
43
|
+
# runner's own client is built from one separately; nothing a user runs
|
|
44
|
+
# authenticates as a runner.
|
|
45
|
+
runner_homes: RunnerHomes
|
|
46
|
+
manifold_token: str | None
|
|
47
|
+
manifold_api_url: str
|
|
48
|
+
manifold_webapp_url: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_dependencies() -> Dependencies:
|
|
52
|
+
settings = Settings()
|
|
53
|
+
credentials_client = CredentialsClient(settings.CREDENTIALS_PATH)
|
|
54
|
+
runner_homes = RunnerHomes(settings.MANIFOLD_RUNNERS_DIR)
|
|
55
|
+
|
|
56
|
+
# The client authenticates via a flow that refreshes tokens, and refreshing
|
|
57
|
+
# needs the CLI config that only the client can fetch. The cycle is broken
|
|
58
|
+
# lazily: get_cli_config is deferred until login/refresh, by which point
|
|
59
|
+
# manifold_client below is bound.
|
|
60
|
+
workos_client = WorkOSDeviceClient(
|
|
61
|
+
config_provider=lambda: manifold_client.get_cli_config()
|
|
62
|
+
)
|
|
63
|
+
auth = WorkOSAuth(credentials=credentials_client, refresher=workos_client)
|
|
64
|
+
manifold_client = ManifoldApiClient(settings.MANIFOLD_API_URL, auth=auth)
|
|
65
|
+
|
|
66
|
+
auth_service = AuthService(
|
|
67
|
+
manifold_client=manifold_client,
|
|
68
|
+
workos_client=workos_client,
|
|
69
|
+
credentials_client=credentials_client,
|
|
70
|
+
manifold_api_url=settings.MANIFOLD_API_URL,
|
|
71
|
+
)
|
|
72
|
+
return Dependencies(
|
|
73
|
+
auth_service=auth_service,
|
|
74
|
+
organization_service=OrganizationService(manifold_client=manifold_client),
|
|
75
|
+
policy_service=PolicyService(manifold_client=manifold_client),
|
|
76
|
+
benchmark_service=BenchmarkService(
|
|
77
|
+
manifold_client=manifold_client, image_client=DockerImageClient()
|
|
78
|
+
),
|
|
79
|
+
run_service=RunService(manifold_client=manifold_client),
|
|
80
|
+
runner_service=RunnerService(manifold_client=manifold_client),
|
|
81
|
+
token_service=TokenService(manifold_client=manifold_client),
|
|
82
|
+
runner_daemon_service=RunnerDaemonService.for_current_process(),
|
|
83
|
+
runner_homes=runner_homes,
|
|
84
|
+
manifold_token=settings.MANIFOLD_TOKEN,
|
|
85
|
+
manifold_api_url=settings.MANIFOLD_API_URL,
|
|
86
|
+
manifold_webapp_url=settings.MANIFOLD_WEBAPP_URL,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def make_runner_client(home: RunnerHomeClient, api_url: str) -> ManifoldApiClient:
|
|
91
|
+
"""A client presenting one home's runner token instead of a user session.
|
|
92
|
+
|
|
93
|
+
Built where it is needed rather than in `build_dependencies`, because
|
|
94
|
+
building one reads the home and a box with none still runs every other
|
|
95
|
+
command.
|
|
96
|
+
|
|
97
|
+
Refuses a credential minted against another deployment, which is what
|
|
98
|
+
`RunnerCredential.api_url` is stored for. Left to `RunnerAuth`, the token
|
|
99
|
+
would reach the configured host and come back a bare 401.
|
|
100
|
+
"""
|
|
101
|
+
stored = home.load()
|
|
102
|
+
if stored is None:
|
|
103
|
+
raise NoRunnerCredentialError
|
|
104
|
+
if stored.credential.api_url.rstrip("/") != api_url.rstrip("/"):
|
|
105
|
+
raise RunnerDeploymentMismatchError(stored.credential.api_url, api_url)
|
|
106
|
+
return ManifoldApiClient(api_url, auth=RunnerAuth(home))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def make_in_memory_runner_client(token: str, api_url: str) -> ManifoldApiClient:
|
|
110
|
+
"""Build a client that authenticates with an in-memory runner token."""
|
|
111
|
+
return ManifoldApiClient(api_url, auth=InMemoryRunnerTokenAuth(token))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def make_runner_registration_client(token: str, api_url: str) -> ManifoldApiClient:
|
|
115
|
+
"""Build the command-local client that presents an exported access token."""
|
|
116
|
+
return ManifoldApiClient(api_url, auth=StaticTokenAuth(token))
|
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from enum import Enum, StrEnum
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CliConfig(BaseModel):
|
|
10
|
+
workos_client_id: str
|
|
11
|
+
authorization_endpoint: str
|
|
12
|
+
token_endpoint: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class User(BaseModel):
|
|
16
|
+
id: UUID
|
|
17
|
+
email: str
|
|
18
|
+
last_login_at: datetime | None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DeviceAuthorization(BaseModel):
|
|
22
|
+
device_code: str
|
|
23
|
+
user_code: str
|
|
24
|
+
verification_uri: str
|
|
25
|
+
verification_uri_complete: str
|
|
26
|
+
expires_in: int
|
|
27
|
+
interval: int
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TokenResponse(BaseModel):
|
|
31
|
+
access_token: str
|
|
32
|
+
refresh_token: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PollPending(Enum):
|
|
36
|
+
PENDING = "authorization_pending"
|
|
37
|
+
SLOW_DOWN = "slow_down"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AuthState(StrEnum):
|
|
41
|
+
LOGGED_OUT = "logged_out"
|
|
42
|
+
SESSION_EXPIRED = "session_expired"
|
|
43
|
+
LOGGED_IN = "logged_in"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class AuthStatus:
|
|
48
|
+
state: AuthState
|
|
49
|
+
user: User | None = None
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Any
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from manifold_cli.models.catalog import Adapter, Visibility
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Benchmark(BaseModel):
|
|
11
|
+
"""One benchmark suite — the unit a run executes against.
|
|
12
|
+
|
|
13
|
+
A suite is a benchmark rather than a field on one, so LIBERO's spatial and
|
|
14
|
+
object suites are two entries here, each with its own slug and versions.
|
|
15
|
+
|
|
16
|
+
As on `Policy`, the slug is unique only within the owning organization.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
id: UUID
|
|
20
|
+
owner_user_id: UUID
|
|
21
|
+
owner_org_id: UUID
|
|
22
|
+
owner_org_slug: str
|
|
23
|
+
slug: str
|
|
24
|
+
display_name: str
|
|
25
|
+
visibility: Visibility
|
|
26
|
+
created_at: datetime
|
|
27
|
+
updated_at: datetime
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BenchmarkVersion(BaseModel):
|
|
31
|
+
"""A runnable build of a benchmark — the environment a run executes against.
|
|
32
|
+
|
|
33
|
+
`episodes` is fixed by the version rather than asked for at submit: every
|
|
34
|
+
run of it executes that many. `shards` is how many parallel partitions it
|
|
35
|
+
divides those episodes across, declared the same way but only as a default:
|
|
36
|
+
`run submit --shards` overrides it for one run. One shard means the build
|
|
37
|
+
does not partition. Registration clamps a count above `episodes` down to
|
|
38
|
+
it, so only a version registered before that rule can carry more.
|
|
39
|
+
`shards_per_gpu`, when set, replaces `shards` with that many shards per GPU
|
|
40
|
+
of each benchmark runner. A backend that predates `shards_per_gpu` omits
|
|
41
|
+
the field. The driver requirement also belongs to the version because it
|
|
42
|
+
describes which runners may execute that build.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
id: UUID
|
|
46
|
+
benchmark_id: UUID
|
|
47
|
+
version: str
|
|
48
|
+
adapter: Adapter
|
|
49
|
+
config: dict[str, Any]
|
|
50
|
+
minimum_gpu_memory_gb: int
|
|
51
|
+
minimum_driver_major: int | None
|
|
52
|
+
maximum_driver_major: int | None
|
|
53
|
+
minimum_gpu_count: int | None = None
|
|
54
|
+
episodes: int
|
|
55
|
+
shards: int
|
|
56
|
+
shards_per_gpu: int | None = None
|
|
57
|
+
created_at: datetime
|
|
58
|
+
updated_at: datetime
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class RubricContent(BaseModel):
|
|
62
|
+
groups: list[Any]
|
|
63
|
+
tasks: list[Any]
|
|
64
|
+
grading: dict[str, Any] = {}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class BenchmarkNotFoundError(Exception):
|
|
68
|
+
"""No benchmark visible to the caller matches this reference.
|
|
69
|
+
|
|
70
|
+
Carries the server's message, which contains the reference that failed to
|
|
71
|
+
resolve.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class InvalidImageSpecError(Exception):
|
|
76
|
+
"""The spec file in an image is not a JSON object.
|
|
77
|
+
|
|
78
|
+
The message names the image. A missing spec file is not an error.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class InvalidImageRubricError(Exception):
|
|
83
|
+
"""An image carries a rubric that cannot be published.
|
|
84
|
+
|
|
85
|
+
Carries a message naming the image. An image carrying no rubric at all is
|
|
86
|
+
not this: that is the ordinary case and reads as None.
|
|
87
|
+
"""
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Vocabulary shared by the policy and benchmark entities, as on the backend.
|
|
2
|
+
|
|
3
|
+
Runs use `Visibility` too; the rest is policy and benchmark only.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Visibility(StrEnum):
|
|
13
|
+
"""Read scope of a policy or benchmark."""
|
|
14
|
+
|
|
15
|
+
OWNER = "owner"
|
|
16
|
+
ORG = "org"
|
|
17
|
+
PUBLIC = "public"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Adapter(StrEnum):
|
|
21
|
+
"""How a policy or benchmark version is reached at run time."""
|
|
22
|
+
|
|
23
|
+
CONTAINER = "container"
|
|
24
|
+
SUBPROCESS = "subprocess"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CatalogEntry(Protocol):
|
|
28
|
+
"""A policy or benchmark: a slug under the organization owning it."""
|
|
29
|
+
|
|
30
|
+
owner_org_slug: str
|
|
31
|
+
slug: str
|
|
32
|
+
display_name: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CatalogVersion(Protocol):
|
|
36
|
+
"""One registered version of a policy or benchmark."""
|
|
37
|
+
|
|
38
|
+
created_at: datetime
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class InvalidReferenceError(Exception):
|
|
42
|
+
"""A reference is neither `<slug>` nor `<org>/<slug>`.
|
|
43
|
+
|
|
44
|
+
Carries a message quoting what was typed.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class InvalidImageError(Exception):
|
|
49
|
+
"""A container image reference does not name the version it would register.
|
|
50
|
+
|
|
51
|
+
Carries a message quoting what was typed.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class CatalogRegisterError(Exception):
|
|
56
|
+
"""The server refused to register a policy or benchmark.
|
|
57
|
+
|
|
58
|
+
This is typically a permissions error.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# A valid tag, as defined by the OCI distribution spec.
|
|
63
|
+
TAG_PATTERN = re.compile(r"[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}\Z")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def split_ref(ref: str) -> tuple[str | None, str]:
|
|
67
|
+
"""Split a policy or benchmark reference into its owning organization and slug.
|
|
68
|
+
|
|
69
|
+
A reference is `<slug>` or `<org>/<slug>`; an unqualified one names no owner
|
|
70
|
+
(None comes back), and resolving it is the caller's job: the server looks in
|
|
71
|
+
the submitter's own organization first and then at public entries, and the
|
|
72
|
+
listing commands follow suit.
|
|
73
|
+
|
|
74
|
+
Raises InvalidReferenceError for a malformed reference, as validate_ref does.
|
|
75
|
+
"""
|
|
76
|
+
validate_ref(ref)
|
|
77
|
+
owner, separator, slug = ref.partition("/")
|
|
78
|
+
if not separator:
|
|
79
|
+
return None, ref
|
|
80
|
+
return owner, slug
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def split_versioned_ref(ref: str) -> tuple[str | None, str, str | None]:
|
|
84
|
+
"""Split `[<org>/]<slug>[:<version>]` into the owner, the slug and the version.
|
|
85
|
+
|
|
86
|
+
`run submit`, `policy serve` and the delete commands take this form, for
|
|
87
|
+
example `my-policy:0.0.1`. The owner and the version are None when the reference
|
|
88
|
+
leaves them out.
|
|
89
|
+
|
|
90
|
+
Raises InvalidReferenceError for a malformed reference, for an empty
|
|
91
|
+
version, for a version that contains `:`, and for a version of `.` or
|
|
92
|
+
`..`. httpx removes a `.` or `..` path segment, so the URL for such a
|
|
93
|
+
version would be the URL of the policy or benchmark itself.
|
|
94
|
+
"""
|
|
95
|
+
name, separator, version = ref.partition(":")
|
|
96
|
+
if not separator:
|
|
97
|
+
owner, slug = split_ref(ref)
|
|
98
|
+
return owner, slug, None
|
|
99
|
+
if ":" in version:
|
|
100
|
+
raise InvalidReferenceError(
|
|
101
|
+
"A version cannot contain ':'. For example: my-policy:0.0.1."
|
|
102
|
+
)
|
|
103
|
+
if not version:
|
|
104
|
+
raise InvalidReferenceError(
|
|
105
|
+
f"'{ref}' has no version after ':'. For example: my-policy:0.0.1."
|
|
106
|
+
)
|
|
107
|
+
if version in (".", ".."):
|
|
108
|
+
raise InvalidReferenceError(
|
|
109
|
+
f"'{version}' is not a version. For example: my-policy:0.0.1."
|
|
110
|
+
)
|
|
111
|
+
owner, slug = split_ref(name)
|
|
112
|
+
return owner, slug, version
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def join_versioned_ref(owner: str | None, slug: str, version: str | None) -> str:
|
|
116
|
+
"""Write a reference in the form that `split_versioned_ref` reads."""
|
|
117
|
+
ref = slug if owner is None else f"{owner}/{slug}"
|
|
118
|
+
return ref if version is None else f"{ref}:{version}"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def validate_ref(ref: str) -> None:
|
|
122
|
+
"""Raise InvalidReferenceError unless `ref` is `<slug>` or `<org>/<slug>`.
|
|
123
|
+
|
|
124
|
+
A slug is one path segment, so a second separator or an empty half is
|
|
125
|
+
rejected. An unchecked remainder rides into a URL path, or into a submit body
|
|
126
|
+
that the server splits at the first separator of its own accord. The error
|
|
127
|
+
that comes back then quotes neither what was typed nor the form the server
|
|
128
|
+
wanted.
|
|
129
|
+
|
|
130
|
+
`run submit` validates without splitting: it sends a reference whole and the
|
|
131
|
+
server resolves it.
|
|
132
|
+
"""
|
|
133
|
+
owner, separator, slug = ref.partition("/")
|
|
134
|
+
if not separator:
|
|
135
|
+
if not ref:
|
|
136
|
+
raise InvalidReferenceError("A reference cannot be empty.")
|
|
137
|
+
return
|
|
138
|
+
if not owner or not slug or "/" in slug:
|
|
139
|
+
raise InvalidReferenceError(
|
|
140
|
+
f"'{ref}' is not a reference; write <identifier> or <org>/<identifier>."
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def version_from_image(image: str) -> str:
|
|
145
|
+
"""The version a container image registers as, which is its tag for now.
|
|
146
|
+
|
|
147
|
+
Raises InvalidImageError for a reference that names no version: one with no
|
|
148
|
+
tag, one pinned to a digest, one tagged `latest`, or one whose tag is not a
|
|
149
|
+
valid OCI tag.
|
|
150
|
+
"""
|
|
151
|
+
if not image:
|
|
152
|
+
raise InvalidImageError("An image reference cannot be empty.")
|
|
153
|
+
if "@" in image:
|
|
154
|
+
raise InvalidImageError(
|
|
155
|
+
f"'{image}' is pinned to a digest, which names no version; "
|
|
156
|
+
"write <image>:<tag>."
|
|
157
|
+
)
|
|
158
|
+
repository, separator, tag = image.rpartition(":")
|
|
159
|
+
if not separator or not tag or "/" in tag:
|
|
160
|
+
raise InvalidImageError(
|
|
161
|
+
f"'{image}' has no tag; write <image>:<tag>, and the tag is the "
|
|
162
|
+
"version it registers as."
|
|
163
|
+
)
|
|
164
|
+
if not repository:
|
|
165
|
+
raise InvalidImageError(f"'{image}' names no image before its tag.")
|
|
166
|
+
if TAG_PATTERN.match(tag) is None:
|
|
167
|
+
raise InvalidImageError(
|
|
168
|
+
f"'{tag}' is not a tag; a tag starts with a letter, digit or '_' "
|
|
169
|
+
"and continues with those, '.' or '-'."
|
|
170
|
+
)
|
|
171
|
+
if tag == "latest":
|
|
172
|
+
raise InvalidImageError(
|
|
173
|
+
"'latest' moves between images, so it names no version; tag the "
|
|
174
|
+
"image with a version string."
|
|
175
|
+
)
|
|
176
|
+
return tag
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def in_owner_order[T: CatalogEntry](entries: list[T]) -> list[T]:
|
|
180
|
+
"""Entries by owner, then by slug, so one owner's entries read together."""
|
|
181
|
+
return sorted(entries, key=lambda entry: (entry.owner_org_slug, entry.slug))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def newest_first[T: CatalogVersion](versions: list[T]) -> list[T]:
|
|
185
|
+
"""Versions newest first.
|
|
186
|
+
|
|
187
|
+
Version strings are free text, so versions sort by creation time.
|
|
188
|
+
"""
|
|
189
|
+
return sorted(versions, key=lambda version: version.created_at, reverse=True)
|