twisk 0.1.0b0__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.
twisk/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """twisk — a k9s-style terminal UI for SkyPilot."""
2
+
3
+ __version__ = "0.1.0b0"
@@ -0,0 +1,39 @@
1
+ from twisk.backend.base import (
2
+ Backend,
3
+ BackendError,
4
+ ClusterJobRow,
5
+ ClusterRow,
6
+ ClusterActivity,
7
+ InfraRow,
8
+ ManagedJobRow,
9
+ PoolRow,
10
+ ReplicaRow,
11
+ RequestRow,
12
+ ServerInfo,
13
+ ServerUnavailableError,
14
+ ServiceRow,
15
+ StorageRow,
16
+ UsageRow,
17
+ VolumeRow,
18
+ WorkspaceRow,
19
+ )
20
+
21
+ __all__ = [
22
+ "Backend",
23
+ "BackendError",
24
+ "ClusterJobRow",
25
+ "ClusterRow",
26
+ "ClusterActivity",
27
+ "InfraRow",
28
+ "ManagedJobRow",
29
+ "PoolRow",
30
+ "ReplicaRow",
31
+ "RequestRow",
32
+ "ServerInfo",
33
+ "ServerUnavailableError",
34
+ "ServiceRow",
35
+ "StorageRow",
36
+ "UsageRow",
37
+ "VolumeRow",
38
+ "WorkspaceRow",
39
+ ]
twisk/backend/base.py ADDED
@@ -0,0 +1,284 @@
1
+ """Backend protocol and the plain row types the TUI renders.
2
+
3
+ The TUI only talks to a :class:`Backend`. :mod:`twisk.backend.skypilot`
4
+ implements it against a SkyPilot API server; :mod:`twisk.backend.fake` is an
5
+ in-memory simulation used by ``twisk --demo`` and the tests.
6
+
7
+ Long-running operations (launch, down, ...) return a SkyPilot request id
8
+ immediately; progress is followed with :meth:`Backend.stream_request_logs`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import threading
14
+ from dataclasses import dataclass, field
15
+ from typing import Any, Callable, Protocol
16
+
17
+ from twisk.specs.models import ClusterSpec, JobSpec, PoolSpec, ServiceSpec, VolumeSpec
18
+
19
+ LogSink = Callable[[str], None]
20
+
21
+
22
+ class BackendError(Exception):
23
+ """Anything that went wrong talking to SkyPilot, with a user-facing message."""
24
+
25
+
26
+ class ServerUnavailableError(BackendError):
27
+ pass
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class ServerInfo:
32
+ endpoint: str
33
+ healthy: bool
34
+ version: str | None = None
35
+ api_version: str | None = None
36
+ user: str | None = None
37
+ error: str | None = None
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class ClusterRow:
42
+ name: str
43
+ status: str
44
+ infra: str
45
+ resources: str
46
+ launched_at: float | None
47
+ autostop: int # minutes; -1 = off
48
+ to_down: bool
49
+ user: str
50
+ workspace: str = "default"
51
+ last_event: str | None = None
52
+ creation_yaml: str | None = None
53
+ details: dict[str, Any] = field(default_factory=dict, compare=False)
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class ClusterJobRow:
58
+ cluster: str
59
+ job_id: int
60
+ name: str
61
+ status: str
62
+ submitted_at: float | None
63
+ start_at: float | None
64
+ end_at: float | None
65
+ resources: str
66
+ user: str
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class ManagedJobRow:
71
+ job_id: int
72
+ name: str
73
+ status: str
74
+ resources: str
75
+ infra: str
76
+ submitted_at: float | None
77
+ duration: float | None
78
+ recoveries: int
79
+ user: str
80
+ details: str | None = None
81
+ user_yaml: str | None = None
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class ReplicaRow:
86
+ service: str
87
+ replica_id: int
88
+ status: str
89
+ version: int | None
90
+ infra: str
91
+ resources: str
92
+ endpoint: str | None
93
+ launched_at: float | None
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class ServiceRow:
98
+ name: str
99
+ status: str
100
+ endpoint: str | None
101
+ ready_replicas: int
102
+ total_replicas: int
103
+ versions: str
104
+ uptime: float | None # unix time the service became ready
105
+ replicas: tuple[ReplicaRow, ...] = ()
106
+ yaml: str | None = None
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class VolumeRow:
111
+ name: str
112
+ type: str
113
+ infra: str
114
+ size: str
115
+ status: str
116
+ used_by: tuple[str, ...]
117
+ launched_at: float | None
118
+ user: str
119
+ creation_yaml: str | None = None
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class RequestRow:
124
+ request_id: str
125
+ name: str
126
+ status: str
127
+ created_at: float | None
128
+ finished_at: float | None
129
+ cluster: str | None
130
+ user: str
131
+ status_msg: str | None = None
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class UsageRow:
136
+ """GPU usage of one cluster (live or terminated) within a time window."""
137
+
138
+ name: str
139
+ status: str # live status, or "TERMINATED"
140
+ infra: str
141
+ resources: str
142
+ num_nodes: int
143
+ gpus: float # accelerators across all nodes
144
+ launched_at: float | None
145
+ duration: float # seconds the cluster was up within the window
146
+ user: str = "-"
147
+ workspace: str = "default"
148
+
149
+ @property
150
+ def gpu_hours(self) -> float:
151
+ return self.gpus * self.duration / 3600.0
152
+
153
+
154
+ # Cluster-job statuses that mean the cluster is doing work.
155
+ ACTIVE_JOB_STATUSES = frozenset({"INIT", "PENDING", "SETTING_UP", "RUNNING", "STARTING"})
156
+
157
+
158
+ @dataclass(frozen=True)
159
+ class ClusterActivity:
160
+ """Whether an UP cluster is working, and if not, since when it's idle.
161
+
162
+ Mirrors SkyPilot's autostop notion of idleness: a cluster is busy while
163
+ any job is pending/running; otherwise it has been idle since its last
164
+ job ended (or since it was launched, if it never ran one). Interactive
165
+ SSH sessions are not visible to twisk and don't count.
166
+ """
167
+
168
+ busy: bool
169
+ running_jobs: int
170
+ idle_since: float | None
171
+
172
+
173
+ def activity_from_jobs(launched_at: float | None, jobs: list["ClusterJobRow"]) -> ClusterActivity:
174
+ running = [j for j in jobs if j.status.upper() in ACTIVE_JOB_STATUSES]
175
+ if running:
176
+ return ClusterActivity(True, len(running), None)
177
+ ends = [j.end_at for j in jobs if j.end_at]
178
+ candidates = [t for t in (*ends, launched_at) if t]
179
+ return ClusterActivity(False, 0, max(candidates) if candidates else None)
180
+
181
+
182
+ @dataclass(frozen=True)
183
+ class PoolRow:
184
+ name: str
185
+ status: str
186
+ ready_workers: int
187
+ total_workers: int
188
+ resources: str
189
+ workers: tuple["ReplicaRow", ...] = ()
190
+ yaml: str | None = None
191
+
192
+
193
+ @dataclass(frozen=True)
194
+ class StorageRow:
195
+ name: str
196
+ stores: str # e.g. "S3, GCS"
197
+ status: str
198
+ launched_at: float | None
199
+ last_use: str
200
+
201
+
202
+ @dataclass(frozen=True)
203
+ class WorkspaceRow:
204
+ name: str
205
+ active: bool
206
+ writable: bool = True
207
+ detail: str = ""
208
+
209
+
210
+ @dataclass(frozen=True)
211
+ class InfraRow:
212
+ name: str
213
+ enabled: bool
214
+ detail: str = ""
215
+
216
+
217
+ class Backend(Protocol):
218
+ """Everything the TUI needs from SkyPilot."""
219
+
220
+ endpoint: str
221
+
222
+ def server_info(self) -> ServerInfo: ...
223
+
224
+ # Whose resources the list_* calls return: only the caller's (False) or
225
+ # every user's (True). Toggled from the TUI.
226
+ all_users: bool
227
+
228
+ # listing
229
+ def list_clusters(self, refresh: bool = False) -> list[ClusterRow]: ...
230
+ def list_cluster_jobs(self, cluster: str) -> list[ClusterJobRow]: ...
231
+ def list_managed_jobs(self) -> list[ManagedJobRow]: ...
232
+ def list_usage(self, days: int | None = 30) -> list[UsageRow]: ...
233
+ def cluster_activity(self, clusters: list[ClusterRow]) -> dict[str, ClusterActivity]: ...
234
+ def list_pools(self) -> list[PoolRow]: ...
235
+ def list_storage(self) -> list[StorageRow]: ...
236
+ def list_workspaces(self) -> list[WorkspaceRow]: ...
237
+ def list_services(self) -> list[ServiceRow]: ...
238
+ def list_volumes(self) -> list[VolumeRow]: ...
239
+ def list_requests(self, limit: int = 100) -> list[RequestRow]: ...
240
+ def list_infra(self) -> list[InfraRow]: ...
241
+
242
+ # clusters
243
+ def launch_cluster(self, spec: ClusterSpec) -> str: ...
244
+ def exec_on_cluster(self, cluster: str, command: str) -> str: ...
245
+ def stop_cluster(self, cluster: str) -> str: ...
246
+ def start_cluster(self, cluster: str) -> str: ...
247
+ def down_cluster(self, cluster: str) -> str: ...
248
+ def set_autostop(self, cluster: str, minutes: int, down: bool) -> str: ...
249
+ def cancel_cluster_job(self, cluster: str, job_id: int) -> str: ...
250
+ def ssh_argv(self, cluster: str) -> list[str]: ...
251
+
252
+ # workspaces
253
+ def set_workspace(self, name: str) -> None: ...
254
+
255
+ # managed jobs (spec.pool, if set, submits the job to that pool)
256
+ def launch_managed_job(self, spec: JobSpec) -> str: ...
257
+ def cancel_managed_job(self, job_id: int) -> str: ...
258
+
259
+ # services
260
+ def serve_up(self, spec: ServiceSpec) -> str: ...
261
+ def serve_update(self, spec: ServiceSpec) -> str: ...
262
+ def serve_down(self, name: str) -> str: ...
263
+ def terminate_replica(self, service: str, replica_id: int) -> str: ...
264
+
265
+ # pools
266
+ def pool_apply(self, spec: PoolSpec) -> str: ...
267
+ def pool_down(self, name: str) -> str: ...
268
+
269
+ # storage buckets
270
+ def delete_storage(self, name: str) -> str: ...
271
+
272
+ # volumes
273
+ def apply_volume(self, spec: VolumeSpec) -> str: ...
274
+ def delete_volume(self, name: str) -> str: ...
275
+
276
+ # requests
277
+ def cancel_request(self, request_id: str) -> None: ...
278
+
279
+ # logs: block until done or `stop` is set, feeding lines to `sink`
280
+ def stream_request_logs(self, request_id: str, sink: LogSink, stop: threading.Event) -> None: ...
281
+ def stream_cluster_job_logs(self, cluster: str, job_id: int | None, sink: LogSink, stop: threading.Event) -> None: ...
282
+ def stream_managed_job_logs(self, job_id: int, sink: LogSink, stop: threading.Event, controller: bool = False, follow: bool = True) -> None: ...
283
+ def stream_service_logs(self, service: str, target: str, replica_id: int | None, sink: LogSink, stop: threading.Event) -> None: ...
284
+ def stream_pool_logs(self, pool: str, target: str, worker_id: int | None, sink: LogSink, stop: threading.Event) -> None: ...