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.
Files changed (102) hide show
  1. manifold_cli/__init__.py +3 -0
  2. manifold_cli/__main__.py +4 -0
  3. manifold_cli/bridge.py +70 -0
  4. manifold_cli/cli.py +54 -0
  5. manifold_cli/clients/__init__.py +0 -0
  6. manifold_cli/clients/auth_flow.py +169 -0
  7. manifold_cli/clients/credentials.py +53 -0
  8. manifold_cli/clients/docker_engine.py +43 -0
  9. manifold_cli/clients/image.py +130 -0
  10. manifold_cli/clients/manifold.py +1288 -0
  11. manifold_cli/clients/runner_home.py +165 -0
  12. manifold_cli/clients/workos.py +99 -0
  13. manifold_cli/commands/__init__.py +0 -0
  14. manifold_cli/commands/auth.py +101 -0
  15. manifold_cli/commands/benchmark.py +539 -0
  16. manifold_cli/commands/organization.py +154 -0
  17. manifold_cli/commands/policy.py +749 -0
  18. manifold_cli/commands/run.py +337 -0
  19. manifold_cli/commands/run_log.py +291 -0
  20. manifold_cli/commands/runner.py +852 -0
  21. manifold_cli/commands/table.py +101 -0
  22. manifold_cli/commands/token.py +144 -0
  23. manifold_cli/dependencies.py +116 -0
  24. manifold_cli/models/__init__.py +0 -0
  25. manifold_cli/models/auth.py +49 -0
  26. manifold_cli/models/benchmark.py +87 -0
  27. manifold_cli/models/catalog.py +189 -0
  28. manifold_cli/models/credentials.py +19 -0
  29. manifold_cli/models/errors.py +121 -0
  30. manifold_cli/models/log.py +114 -0
  31. manifold_cli/models/observability.py +121 -0
  32. manifold_cli/models/organization.py +19 -0
  33. manifold_cli/models/pagination.py +59 -0
  34. manifold_cli/models/policy.py +49 -0
  35. manifold_cli/models/run.py +381 -0
  36. manifold_cli/models/runner.py +186 -0
  37. manifold_cli/models/runner_home.py +69 -0
  38. manifold_cli/models/task_queue.py +40 -0
  39. manifold_cli/models/token.py +39 -0
  40. manifold_cli/replay/__init__.py +68 -0
  41. manifold_cli/replay/log.py +814 -0
  42. manifold_cli/runner/__init__.py +72 -0
  43. manifold_cli/runner/api_retry.py +36 -0
  44. manifold_cli/runner/benchmark/__init__.py +800 -0
  45. manifold_cli/runner/benchmark/live_view.py +248 -0
  46. manifold_cli/runner/benchmark/preassigned_scheduling.py +325 -0
  47. manifold_cli/runner/benchmark/recording.py +1208 -0
  48. manifold_cli/runner/benchmark/scheduling.py +256 -0
  49. manifold_cli/runner/benchmark/task_queue_scheduling.py +717 -0
  50. manifold_cli/runner/benchmark/task_server.py +123 -0
  51. manifold_cli/runner/benchmark/video.py +124 -0
  52. manifold_cli/runner/benchmark_steps.py +178 -0
  53. manifold_cli/runner/cancellation.py +22 -0
  54. manifold_cli/runner/capture.py +282 -0
  55. manifold_cli/runner/container.py +370 -0
  56. manifold_cli/runner/current_dispatch.py +25 -0
  57. manifold_cli/runner/daemon.py +520 -0
  58. manifold_cli/runner/dispatch.py +38 -0
  59. manifold_cli/runner/gpu.py +182 -0
  60. manifold_cli/runner/heartbeat.py +202 -0
  61. manifold_cli/runner/loopback.py +102 -0
  62. manifold_cli/runner/loopback_bench.py +183 -0
  63. manifold_cli/runner/loopback_policy.py +39 -0
  64. manifold_cli/runner/output.py +218 -0
  65. manifold_cli/runner/pairing_acceptance.py +57 -0
  66. manifold_cli/runner/plan.py +369 -0
  67. manifold_cli/runner/policy.py +350 -0
  68. manifold_cli/runner/policy_steps.py +164 -0
  69. manifold_cli/runner/preparation.py +47 -0
  70. manifold_cli/runner/process.py +219 -0
  71. manifold_cli/runner/ram.py +17 -0
  72. manifold_cli/runner/resource_usage.py +265 -0
  73. manifold_cli/runner/served.py +218 -0
  74. manifold_cli/runner/service.py +120 -0
  75. manifold_cli/runner/side_failure.py +68 -0
  76. manifold_cli/runner/signature.py +98 -0
  77. manifold_cli/runner/source.py +66 -0
  78. manifold_cli/runner/stages.py +241 -0
  79. manifold_cli/runner/step_timing.py +229 -0
  80. manifold_cli/runner/worker.py +534 -0
  81. manifold_cli/runner/workload.py +401 -0
  82. manifold_cli/services/__init__.py +0 -0
  83. manifold_cli/services/auth.py +89 -0
  84. manifold_cli/services/benchmark.py +357 -0
  85. manifold_cli/services/organization.py +22 -0
  86. manifold_cli/services/policy.py +234 -0
  87. manifold_cli/services/run.py +304 -0
  88. manifold_cli/services/runner.py +207 -0
  89. manifold_cli/services/runner_daemon.py +146 -0
  90. manifold_cli/services/token.py +52 -0
  91. manifold_cli/settings.py +30 -0
  92. manifold_cli/transport/__init__.py +31 -0
  93. manifold_cli/transport/diagnostics.py +72 -0
  94. manifold_cli/transport/interfaces.py +69 -0
  95. manifold_cli/transport/iroh.py +317 -0
  96. manifold_cli/transport/registry.py +34 -0
  97. manifold_cli/transport/tcp.py +146 -0
  98. manifold_cli-0.0.39.dist-info/METADATA +406 -0
  99. manifold_cli-0.0.39.dist-info/RECORD +102 -0
  100. manifold_cli-0.0.39.dist-info/WHEEL +4 -0
  101. manifold_cli-0.0.39.dist-info/entry_points.txt +2 -0
  102. 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)