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,19 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Credentials(BaseModel):
|
|
5
|
+
api_url: str
|
|
6
|
+
workos_client_id: str
|
|
7
|
+
access_token: str
|
|
8
|
+
refresh_token: str
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RunnerCredential(BaseModel):
|
|
12
|
+
"""A minted runner token, as the runner box stores it.
|
|
13
|
+
|
|
14
|
+
`api_url` is recorded with it so a token pasted against the wrong
|
|
15
|
+
deployment fails on the mismatch rather than as an opaque 401.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
api_url: str
|
|
19
|
+
token: str
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ManifoldApiError(Exception):
|
|
5
|
+
def __init__(self, status_code: int, body: str):
|
|
6
|
+
self.status_code = status_code
|
|
7
|
+
self.body = body
|
|
8
|
+
super().__init__(f"manifold API error {status_code}: {body[:200]}")
|
|
9
|
+
|
|
10
|
+
@property
|
|
11
|
+
def detail(self) -> str | None:
|
|
12
|
+
"""The human-readable ``detail`` from the error body."""
|
|
13
|
+
return error_detail(self.body)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class WorkOSAuthError(Exception):
|
|
17
|
+
"""Terminal device-flow or refresh failure."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, error: str, description: str | None = None):
|
|
20
|
+
self.error = error
|
|
21
|
+
self.description = description
|
|
22
|
+
super().__init__(description or error)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NotLoggedInError(Exception):
|
|
26
|
+
"""No stored credentials."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, message: str = "Not logged in."):
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SessionExpiredError(Exception):
|
|
33
|
+
"""Stored credentials are invalid and could not be refreshed."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, message: str = "Session expired."):
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class NoRunnerCredentialError(Exception):
|
|
40
|
+
"""No stored runner credential."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, message: str = "No runner credential stored."):
|
|
43
|
+
super().__init__(message)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AccessTokenRejectedError(Exception):
|
|
47
|
+
"""The access token exported for registration was refused."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, message: str = "Access token rejected."):
|
|
50
|
+
super().__init__(message)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class InvalidAccessTokenError(Exception):
|
|
54
|
+
"""The exported access token cannot be placed in an HTTP header."""
|
|
55
|
+
|
|
56
|
+
def __init__(self):
|
|
57
|
+
super().__init__("MANIFOLD_TOKEN is not a valid credential.")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class NoRunnerHomeError(Exception):
|
|
61
|
+
"""No runner home to act on, either named or on the box at all."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, message: str = "This box has no runner home."):
|
|
64
|
+
super().__init__(message)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AmbiguousRunnerHomeError(Exception):
|
|
68
|
+
"""More than one runner home on the box, and the caller named none."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, handles: list[str]):
|
|
71
|
+
self.handles = handles
|
|
72
|
+
super().__init__(
|
|
73
|
+
f"This box has {len(handles)} runner homes ({', '.join(handles)}). "
|
|
74
|
+
f"Name one with --runner."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class RunnerCredentialRejectedError(Exception):
|
|
79
|
+
"""The stored runner credential was refused.
|
|
80
|
+
|
|
81
|
+
A runner token is revoked rather than rotated, so a 401 is terminal: it was
|
|
82
|
+
revoked, or it belongs to another deployment.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, message: str = "Runner credential rejected."):
|
|
86
|
+
super().__init__(message)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class RunnerDeploymentMismatchError(Exception):
|
|
90
|
+
"""The stored runner credential was minted against another deployment."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, stored: str, configured: str):
|
|
93
|
+
self.stored = stored
|
|
94
|
+
self.configured = configured
|
|
95
|
+
super().__init__(
|
|
96
|
+
f"Runner credential is for {stored}, "
|
|
97
|
+
f"but this box is configured for {configured}."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def error_detail(body: str) -> str | None:
|
|
102
|
+
"""The human-readable ``detail`` from a JSON error body.
|
|
103
|
+
|
|
104
|
+
Returns None when the body is not JSON or ``detail`` is not a string
|
|
105
|
+
(e.g. FastAPI's request-validation errors, whose detail is a list), so
|
|
106
|
+
callers can fall back to their own message.
|
|
107
|
+
"""
|
|
108
|
+
detail = raw_error_detail(body)
|
|
109
|
+
return detail if isinstance(detail, str) else None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def raw_error_detail(body: str) -> object:
|
|
113
|
+
"""The ``detail`` of a JSON error body as sent.
|
|
114
|
+
|
|
115
|
+
None where the body is not a JSON object with ``detail``.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
payload = json.loads(body)
|
|
119
|
+
except ValueError:
|
|
120
|
+
return None
|
|
121
|
+
return payload.get("detail") if isinstance(payload, dict) else None
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""A run's captured output: what a runner reports, and what a reader reads back."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field, field_validator
|
|
8
|
+
|
|
9
|
+
from manifold_cli.models.run import AssignmentReason, AssignmentStatus, RunSide
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LogSource(StrEnum):
|
|
13
|
+
"""Which channel of a process's output a line came from."""
|
|
14
|
+
|
|
15
|
+
STDOUT = "stdout"
|
|
16
|
+
STDERR = "stderr"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LogProducer(StrEnum):
|
|
20
|
+
"""Which process produced a captured stream."""
|
|
21
|
+
|
|
22
|
+
RUNNER = "runner"
|
|
23
|
+
WORKER = "worker"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ReportedStream(BaseModel):
|
|
27
|
+
"""One worker's output on one source, as a batch declares it.
|
|
28
|
+
|
|
29
|
+
Declared on every batch rather than once, so a post lost in transit leaves a
|
|
30
|
+
reader's stream list short only until the next one repairs it. `ended_at` and
|
|
31
|
+
`exit_code` are what a close reports, and stay None while the worker runs.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
producer: LogProducer
|
|
35
|
+
worker_index: int = Field(ge=0)
|
|
36
|
+
source: LogSource
|
|
37
|
+
ended_at: datetime | None = None
|
|
38
|
+
exit_code: int | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ReportedLine(BaseModel):
|
|
42
|
+
"""One captured line, at its position in the stream.
|
|
43
|
+
|
|
44
|
+
`ts` is when the runner read the line rather than when the batch carrying it
|
|
45
|
+
was sent, so the interval a batch is held for does not collapse a worker's
|
|
46
|
+
lines onto one instant.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
producer: LogProducer
|
|
50
|
+
worker_index: int = Field(ge=0)
|
|
51
|
+
source: LogSource
|
|
52
|
+
seq: int = Field(ge=0)
|
|
53
|
+
ts: datetime
|
|
54
|
+
text: str
|
|
55
|
+
|
|
56
|
+
@field_validator("text")
|
|
57
|
+
@classmethod
|
|
58
|
+
def _without_nul(cls, text: str) -> str:
|
|
59
|
+
"""Drop the one character the store behind this cannot hold.
|
|
60
|
+
|
|
61
|
+
A worker writing bytes to a pipe produces a NUL, which is valid UTF-8 and
|
|
62
|
+
so survives the decode intact. The column it lands in refuses one, and
|
|
63
|
+
the refusal costs the whole batch the line rode in rather than the line.
|
|
64
|
+
"""
|
|
65
|
+
return text.replace("\x00", "")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class LogBatch(BaseModel):
|
|
69
|
+
"""One post: every stream the side has, and the lines since the last one.
|
|
70
|
+
|
|
71
|
+
A batch carrying no lines is not a no-op. It is what puts a worker in a
|
|
72
|
+
reader's list while its container is still pulling an image, and what reports
|
|
73
|
+
a worker gone once it has exited.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
streams: list[ReportedStream]
|
|
77
|
+
lines: list[ReportedLine] = []
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class RunLogStream(BaseModel):
|
|
81
|
+
"""One stream of one assignment of a run, with its size and its process's exit.
|
|
82
|
+
|
|
83
|
+
`assignment_id`, `producer`, `worker_index` and `source` identify the
|
|
84
|
+
stream to read.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
assignment_id: UUID
|
|
88
|
+
side: RunSide
|
|
89
|
+
status: AssignmentStatus
|
|
90
|
+
reason: AssignmentReason | None
|
|
91
|
+
producer: LogProducer
|
|
92
|
+
worker_index: int
|
|
93
|
+
source: LogSource
|
|
94
|
+
line_count: int
|
|
95
|
+
exit_code: int | None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class LogLine(BaseModel):
|
|
99
|
+
"""One stored line of a stream, at its `seq`."""
|
|
100
|
+
|
|
101
|
+
seq: int
|
|
102
|
+
ts: datetime
|
|
103
|
+
text: str
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class LogMatch(BaseModel):
|
|
107
|
+
"""One line that matched a search, with the lines around it in its stream."""
|
|
108
|
+
|
|
109
|
+
assignment_id: UUID
|
|
110
|
+
producer: LogProducer
|
|
111
|
+
worker_index: int
|
|
112
|
+
source: LogSource
|
|
113
|
+
seq: int
|
|
114
|
+
lines: list[LogLine]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""What a runner reports about its box's resource usage and the policy's steps.
|
|
2
|
+
|
|
3
|
+
The server exports each report to Honeycomb, and stores only the resource usage
|
|
4
|
+
samples.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
from pydantic import AwareDatetime, BaseModel
|
|
10
|
+
|
|
11
|
+
from manifold_cli.models.run import AssignmentDispatch, RunSide
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GpuUsage(BaseModel):
|
|
15
|
+
index: int
|
|
16
|
+
name: str
|
|
17
|
+
utilization_percent: float
|
|
18
|
+
memory_used_bytes: int
|
|
19
|
+
memory_total_bytes: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AssignmentAttributes(BaseModel):
|
|
23
|
+
"""The run side assigned to the runner at a sample's `sampled_at` or a policy
|
|
24
|
+
step's `ended_at`."""
|
|
25
|
+
|
|
26
|
+
assignment_id: UUID
|
|
27
|
+
run_id: UUID
|
|
28
|
+
side: RunSide
|
|
29
|
+
policy_slug: str
|
|
30
|
+
policy_version: str
|
|
31
|
+
benchmark_slug: str
|
|
32
|
+
benchmark_version: str
|
|
33
|
+
shards: int
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ResourceUsageSample(BaseModel):
|
|
37
|
+
"""The box's resource usage over the interval between two host readings.
|
|
38
|
+
|
|
39
|
+
The disk and network byte counts cover the interval since the previous
|
|
40
|
+
reading. `assignment` is None while the runner does not have an assignment.
|
|
41
|
+
The Docker fields are None when the runner could not read the Docker daemon.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
gpus: list[GpuUsage]
|
|
45
|
+
assignment: AssignmentAttributes | None
|
|
46
|
+
sampled_at: AwareDatetime
|
|
47
|
+
# cpu
|
|
48
|
+
cpu_utilization_percent: float
|
|
49
|
+
load_average_1m: float
|
|
50
|
+
# memory
|
|
51
|
+
memory_used_bytes: int
|
|
52
|
+
# disk
|
|
53
|
+
io_wait_percent: float
|
|
54
|
+
disk_read_bytes: int
|
|
55
|
+
disk_write_bytes: int
|
|
56
|
+
docker_root_free_bytes: int | None
|
|
57
|
+
tmp_free_bytes: int
|
|
58
|
+
# network
|
|
59
|
+
network_received_bytes: int
|
|
60
|
+
network_sent_bytes: int
|
|
61
|
+
# docker
|
|
62
|
+
docker_image_count: int | None
|
|
63
|
+
docker_image_bytes: int | None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ReportResourceUsageRequest(BaseModel):
|
|
67
|
+
samples: list[ResourceUsageSample]
|
|
68
|
+
# Sent once per request instead of per sample because they do not change
|
|
69
|
+
# between samples.
|
|
70
|
+
cpu_count: int
|
|
71
|
+
memory_total_bytes: int
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class PolicyStep(BaseModel):
|
|
75
|
+
"""One policy step that the policy side's bridge timed.
|
|
76
|
+
|
|
77
|
+
A step runs from the runner writing the last byte of an OBSERVATION frame to
|
|
78
|
+
the policy until the runner reads the start of the ACTION frame that answers
|
|
79
|
+
it. The runner times each connection's steps separately, so a step includes
|
|
80
|
+
the time the policy spends serving other connections.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
ended_at: AwareDatetime
|
|
84
|
+
duration_sec: float
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ReportPolicyMetricsRequest(BaseModel):
|
|
88
|
+
assignment: AssignmentAttributes
|
|
89
|
+
steps: list[PolicyStep]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class BenchmarkStep(BaseModel):
|
|
93
|
+
"""One step that the benchmark side's bridge timed.
|
|
94
|
+
|
|
95
|
+
The round trip runs from the runner writing the last byte of an OBSERVATION
|
|
96
|
+
frame to the policy side until the runner reads the start of the ACTION
|
|
97
|
+
frame that answers it. `policy_step_sec` is the duration of the same step
|
|
98
|
+
that the policy side's bridge timed and sent after the ACTION frame.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
ended_at: AwareDatetime
|
|
102
|
+
round_trip_sec: float
|
|
103
|
+
policy_step_sec: float
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ReportBenchmarkMetricsRequest(BaseModel):
|
|
107
|
+
assignment: AssignmentAttributes
|
|
108
|
+
steps: list[BenchmarkStep]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def create_assignment_attributes(dispatch: AssignmentDispatch) -> AssignmentAttributes:
|
|
112
|
+
return AssignmentAttributes(
|
|
113
|
+
assignment_id=dispatch.assignment.id,
|
|
114
|
+
run_id=dispatch.run.id,
|
|
115
|
+
side=dispatch.assignment.side,
|
|
116
|
+
policy_slug=dispatch.run.policy_slug,
|
|
117
|
+
policy_version=dispatch.run.policy_version,
|
|
118
|
+
benchmark_slug=dispatch.run.benchmark_slug,
|
|
119
|
+
benchmark_version=dispatch.run.benchmark_version,
|
|
120
|
+
shards=dispatch.shards,
|
|
121
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Organization(BaseModel):
|
|
8
|
+
id: UUID
|
|
9
|
+
slug: str
|
|
10
|
+
display_name: str
|
|
11
|
+
created_at: datetime
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OrganizationNotFoundError(Exception):
|
|
15
|
+
"""The caller named an organization the server will not show them.
|
|
16
|
+
|
|
17
|
+
The server answers alike for one that does not exist and one the caller is
|
|
18
|
+
not a member of, so this covers both. Carries the server's message.
|
|
19
|
+
"""
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from enum import StrEnum
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
# The largest page the API will serve; it rejects a request for more. However
|
|
7
|
+
# many rows a caller asks for in total, the collector requests at most this many
|
|
8
|
+
# per request.
|
|
9
|
+
MAX_PAGE_LIMIT = 200
|
|
10
|
+
|
|
11
|
+
# Guards against a server that keeps handing back a cursor: the collector stops
|
|
12
|
+
# after this many pages. At the maximum page size that is still far more rows
|
|
13
|
+
# than any command means to print.
|
|
14
|
+
MAX_PAGES = 100
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SortOrder(StrEnum):
|
|
18
|
+
"""The end of a time-ordered list where the first page starts."""
|
|
19
|
+
|
|
20
|
+
ASC = "asc"
|
|
21
|
+
DESC = "desc"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Page[T](BaseModel):
|
|
25
|
+
"""One page of a keyset-paginated list endpoint.
|
|
26
|
+
|
|
27
|
+
`total` counts every row matching the query, ignoring the page limit, so a
|
|
28
|
+
command can report how much it is showing. `next_cursor` is opaque: it is
|
|
29
|
+
echoed back to fetch the following page and never parsed here.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
items: list[T]
|
|
33
|
+
total: int
|
|
34
|
+
next_cursor: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def collect[T](
|
|
38
|
+
fetch: Callable[[int, str | None], Page[T]], *, limit: int | None = None
|
|
39
|
+
) -> tuple[list[T], int]:
|
|
40
|
+
"""Read a paginated list by following its cursor, and report the total.
|
|
41
|
+
|
|
42
|
+
Stops once `limit` rows are in hand, or at the end of the list when no
|
|
43
|
+
limit is given. The total comes back alongside the rows because it counts
|
|
44
|
+
every match, so a truncated read can say what it left out.
|
|
45
|
+
"""
|
|
46
|
+
items: list[T] = []
|
|
47
|
+
total = 0
|
|
48
|
+
cursor: str | None = None
|
|
49
|
+
for _ in range(MAX_PAGES):
|
|
50
|
+
remaining = MAX_PAGE_LIMIT if limit is None else limit - len(items)
|
|
51
|
+
page = fetch(min(remaining, MAX_PAGE_LIMIT), cursor)
|
|
52
|
+
items.extend(page.items)
|
|
53
|
+
total = page.total
|
|
54
|
+
if page.next_cursor is None or len(page.items) == 0:
|
|
55
|
+
break
|
|
56
|
+
if limit is not None and len(items) >= limit:
|
|
57
|
+
break
|
|
58
|
+
cursor = page.next_cursor
|
|
59
|
+
return (items if limit is None else items[:limit]), total
|
|
@@ -0,0 +1,49 @@
|
|
|
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 Policy(BaseModel):
|
|
11
|
+
"""A policy, owned by one organization.
|
|
12
|
+
|
|
13
|
+
`slug` is unique only within `owner_org_slug`, so the two together are what
|
|
14
|
+
names a policy, written `<org>/<slug>` where a command takes one.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
id: UUID
|
|
18
|
+
owner_user_id: UUID
|
|
19
|
+
owner_org_id: UUID
|
|
20
|
+
owner_org_slug: str
|
|
21
|
+
slug: str
|
|
22
|
+
display_name: str
|
|
23
|
+
visibility: Visibility
|
|
24
|
+
created_at: datetime
|
|
25
|
+
updated_at: datetime
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PolicyVersion(BaseModel):
|
|
29
|
+
"""A runnable artifact under a policy — what a run actually executes."""
|
|
30
|
+
|
|
31
|
+
id: UUID
|
|
32
|
+
policy_id: UUID
|
|
33
|
+
version: str
|
|
34
|
+
adapter: Adapter
|
|
35
|
+
config: dict[str, Any]
|
|
36
|
+
minimum_gpu_memory_gb: int
|
|
37
|
+
minimum_driver_major: int | None
|
|
38
|
+
maximum_driver_major: int | None
|
|
39
|
+
minimum_gpu_count: int | None = None
|
|
40
|
+
created_at: datetime
|
|
41
|
+
updated_at: datetime
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class PolicyNotFoundError(Exception):
|
|
45
|
+
"""No policy visible to the caller matches this reference.
|
|
46
|
+
|
|
47
|
+
Carries the server's message, which contains the reference that failed to
|
|
48
|
+
resolve.
|
|
49
|
+
"""
|