server4agent 0.1.0__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.
- server4agent/__init__.py +79 -0
- server4agent/_async/__init__.py +0 -0
- server4agent/_async/client.py +559 -0
- server4agent/_async/transport.py +76 -0
- server4agent/_models.py +197 -0
- server4agent/_sync/__init__.py +0 -0
- server4agent/_sync/client.py +567 -0
- server4agent/_sync/transport.py +75 -0
- server4agent/_transport.py +115 -0
- server4agent/_version.py +1 -0
- server4agent/errors.py +103 -0
- server4agent/py.typed +0 -0
- server4agent/webhooks.py +59 -0
- server4agent-0.1.0.dist-info/METADATA +151 -0
- server4agent-0.1.0.dist-info/RECORD +17 -0
- server4agent-0.1.0.dist-info/WHEEL +4 -0
- server4agent-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
from typing import Callable, List, Optional, Sequence
|
|
6
|
+
|
|
7
|
+
from .. import _transport as _t
|
|
8
|
+
from .._models import (
|
|
9
|
+
ApiKey,
|
|
10
|
+
BUILD_TERMINAL_STATES,
|
|
11
|
+
CreatedApiKey,
|
|
12
|
+
CreatedWebhook,
|
|
13
|
+
Deployment,
|
|
14
|
+
ProjectTemplate,
|
|
15
|
+
ServerAction,
|
|
16
|
+
ServerTier,
|
|
17
|
+
TASK_TERMINAL_STATES,
|
|
18
|
+
Webhook,
|
|
19
|
+
_Record,
|
|
20
|
+
parse_build,
|
|
21
|
+
parse_project,
|
|
22
|
+
parse_server,
|
|
23
|
+
parse_task,
|
|
24
|
+
)
|
|
25
|
+
from .._transport import quote_segment as _seg
|
|
26
|
+
from ..errors import APITimeoutError
|
|
27
|
+
from .transport import SyncTransport
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# =============================================================================
|
|
31
|
+
# Handles — returned by create()/get(); data records you can also act on.
|
|
32
|
+
# =============================================================================
|
|
33
|
+
|
|
34
|
+
class Server(_Record):
|
|
35
|
+
id: str
|
|
36
|
+
task: str
|
|
37
|
+
tier: ServerTier
|
|
38
|
+
status: str
|
|
39
|
+
region: str
|
|
40
|
+
credits_used: float
|
|
41
|
+
storage_used_gb: float
|
|
42
|
+
public_url: Optional[str]
|
|
43
|
+
created_at: Optional[str]
|
|
44
|
+
|
|
45
|
+
def __init__(self, transport: SyncTransport, raw: dict) -> None:
|
|
46
|
+
self._t = transport
|
|
47
|
+
self.__dict__.update(parse_server(raw))
|
|
48
|
+
|
|
49
|
+
# scoped sub-resources (properties, so they stay out of repr) --------------
|
|
50
|
+
@property
|
|
51
|
+
def projects(self) -> "_ScopedProjects":
|
|
52
|
+
return _ScopedProjects(self._t, self.id)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def tasks(self) -> "_ScopedTasks":
|
|
56
|
+
return _ScopedTasks(self._t, self.id)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def builds(self) -> "_ScopedBuilds":
|
|
60
|
+
return _ScopedBuilds(self._t, self.id)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def files(self) -> "_ScopedFiles":
|
|
64
|
+
return _ScopedFiles(self._t, self.id)
|
|
65
|
+
|
|
66
|
+
# actions ------------------------------------------------------------------
|
|
67
|
+
def refresh(self) -> "Server":
|
|
68
|
+
r = self._t.request("GET", f"/servers/{_seg(self.id)}")
|
|
69
|
+
self.__dict__.update(parse_server(r["server"]))
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def update(self, *, task: Optional[str] = None, region: Optional[str] = None) -> "Server":
|
|
73
|
+
body = {k: v for k, v in {"task": task, "region": region}.items() if v is not None}
|
|
74
|
+
r = self._t.request("PATCH", f"/servers/{_seg(self.id)}", body=body)
|
|
75
|
+
self.__dict__.update(parse_server(r["server"]))
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
def delete(self) -> None:
|
|
79
|
+
self._t.request("DELETE", f"/servers/{_seg(self.id)}")
|
|
80
|
+
|
|
81
|
+
def control(self, action: ServerAction) -> dict:
|
|
82
|
+
return self._t.request("POST", f"/servers/{_seg(self.id)}/actions", body={"action": action})
|
|
83
|
+
|
|
84
|
+
def start(self) -> dict:
|
|
85
|
+
return self.control("start")
|
|
86
|
+
|
|
87
|
+
def stop(self) -> dict:
|
|
88
|
+
return self.control("stop")
|
|
89
|
+
|
|
90
|
+
def restart(self) -> dict:
|
|
91
|
+
return self.control("restart")
|
|
92
|
+
|
|
93
|
+
def exec(self, command: str) -> str:
|
|
94
|
+
"""Run a shell command on the server. Returns stdout."""
|
|
95
|
+
r = self._t.request("POST", f"/servers/{_seg(self.id)}/exec", body={"command": command})
|
|
96
|
+
return r["stdout"]
|
|
97
|
+
|
|
98
|
+
def deploy(self) -> str:
|
|
99
|
+
"""Publish the current workspace to a live public URL. Returns the URL."""
|
|
100
|
+
r = self._t.request("POST", f"/servers/{_seg(self.id)}/deployments")
|
|
101
|
+
return r["url"]
|
|
102
|
+
|
|
103
|
+
def deployments(self) -> List[Deployment]:
|
|
104
|
+
r = self._t.request("GET", f"/servers/{_seg(self.id)}/deployments")
|
|
105
|
+
return [Deployment(**d) for d in r["deployments"]]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Project(_Record):
|
|
109
|
+
id: str
|
|
110
|
+
server_id: str
|
|
111
|
+
name: str
|
|
112
|
+
slug: str
|
|
113
|
+
description: Optional[str]
|
|
114
|
+
visibility: str
|
|
115
|
+
lifecycle: str
|
|
116
|
+
runtime_status: str
|
|
117
|
+
template: Optional[str]
|
|
118
|
+
url: Optional[str]
|
|
119
|
+
created_at: str
|
|
120
|
+
updated_at: str
|
|
121
|
+
|
|
122
|
+
def __init__(self, transport: SyncTransport, raw: dict) -> None:
|
|
123
|
+
self._t = transport
|
|
124
|
+
self.__dict__.update(parse_project(raw))
|
|
125
|
+
|
|
126
|
+
def refresh(self) -> "Project":
|
|
127
|
+
r = self._t.request("GET", f"/projects/{_seg(self.id)}")
|
|
128
|
+
self.__dict__.update(parse_project(r))
|
|
129
|
+
return self
|
|
130
|
+
|
|
131
|
+
def update(
|
|
132
|
+
self,
|
|
133
|
+
*,
|
|
134
|
+
name: Optional[str] = None,
|
|
135
|
+
description: Optional[str] = None,
|
|
136
|
+
visibility: Optional[str] = None,
|
|
137
|
+
lifecycle: Optional[str] = None,
|
|
138
|
+
) -> "Project":
|
|
139
|
+
body = {
|
|
140
|
+
k: v
|
|
141
|
+
for k, v in {
|
|
142
|
+
"name": name,
|
|
143
|
+
"description": description,
|
|
144
|
+
"visibility": visibility,
|
|
145
|
+
"lifecycle": lifecycle,
|
|
146
|
+
}.items()
|
|
147
|
+
if v is not None
|
|
148
|
+
}
|
|
149
|
+
r = self._t.request("PATCH", f"/projects/{_seg(self.id)}", body=body)
|
|
150
|
+
self.__dict__.update(parse_project(r))
|
|
151
|
+
return self
|
|
152
|
+
|
|
153
|
+
def delete(self) -> None:
|
|
154
|
+
self._t.request("DELETE", f"/projects/{_seg(self.id)}")
|
|
155
|
+
|
|
156
|
+
def cleanup(self) -> None:
|
|
157
|
+
"""Ephemeral projects only. Use ``delete()`` for persistent ones."""
|
|
158
|
+
self._t.request("POST", f"/projects/{_seg(self.id)}/cleanup")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class Task(_Record):
|
|
162
|
+
id: str
|
|
163
|
+
server_id: str
|
|
164
|
+
goal: str
|
|
165
|
+
status: str
|
|
166
|
+
created_at: str
|
|
167
|
+
result: Optional[str]
|
|
168
|
+
question: Optional[str]
|
|
169
|
+
|
|
170
|
+
def __init__(self, transport: SyncTransport, raw: dict) -> None:
|
|
171
|
+
self._t = transport
|
|
172
|
+
self.__dict__.update(parse_task(raw))
|
|
173
|
+
|
|
174
|
+
def refresh(self) -> "Task":
|
|
175
|
+
r = self._t.request("GET", f"/servers/{_seg(self.server_id)}/tasks/{_seg(self.id)}")
|
|
176
|
+
self.__dict__.update(parse_task(r["task"]))
|
|
177
|
+
return self
|
|
178
|
+
|
|
179
|
+
def wait(
|
|
180
|
+
self,
|
|
181
|
+
*,
|
|
182
|
+
poll_interval: float = 2.0,
|
|
183
|
+
timeout: float = 300.0,
|
|
184
|
+
on_poll: Optional[Callable[["Task"], None]] = None,
|
|
185
|
+
) -> "Task":
|
|
186
|
+
"""Block until the task reaches a terminal state (done, failed, cancelled,
|
|
187
|
+
or awaiting_input). Raises ``APITimeoutError`` if ``timeout`` elapses first.
|
|
188
|
+
|
|
189
|
+
``on_poll`` is called with ``self`` after each refresh — handy for
|
|
190
|
+
streaming progress to a log or UI."""
|
|
191
|
+
deadline = time.monotonic() + timeout
|
|
192
|
+
while self.status not in TASK_TERMINAL_STATES:
|
|
193
|
+
if time.monotonic() >= deadline:
|
|
194
|
+
raise APITimeoutError(f"Task {self.id} still {self.status!r} after {timeout}s.")
|
|
195
|
+
time.sleep(poll_interval)
|
|
196
|
+
self.refresh()
|
|
197
|
+
if on_poll is not None:
|
|
198
|
+
on_poll(self)
|
|
199
|
+
return self
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class Build(_Record):
|
|
203
|
+
id: str
|
|
204
|
+
server_id: str
|
|
205
|
+
goal: str
|
|
206
|
+
status: str
|
|
207
|
+
steps: list
|
|
208
|
+
artifacts: list
|
|
209
|
+
url: Optional[str]
|
|
210
|
+
created_at: str
|
|
211
|
+
|
|
212
|
+
def __init__(self, transport: SyncTransport, raw: dict) -> None:
|
|
213
|
+
self._t = transport
|
|
214
|
+
self.__dict__.update(parse_build(raw))
|
|
215
|
+
|
|
216
|
+
def refresh(self) -> "Build":
|
|
217
|
+
r = self._t.request("GET", f"/servers/{_seg(self.server_id)}/builds/{_seg(self.id)}")
|
|
218
|
+
self.__dict__.update(parse_build(r["build"]))
|
|
219
|
+
return self
|
|
220
|
+
|
|
221
|
+
def wait(
|
|
222
|
+
self,
|
|
223
|
+
*,
|
|
224
|
+
poll_interval: float = 2.0,
|
|
225
|
+
timeout: float = 600.0,
|
|
226
|
+
on_poll: Optional[Callable[["Build"], None]] = None,
|
|
227
|
+
) -> "Build":
|
|
228
|
+
"""Block until the build completes. Raises ``APITimeoutError`` on timeout.
|
|
229
|
+
|
|
230
|
+
``on_poll`` is called with ``self`` after each refresh — handy for
|
|
231
|
+
streaming build-step progress."""
|
|
232
|
+
deadline = time.monotonic() + timeout
|
|
233
|
+
while self.status not in BUILD_TERMINAL_STATES:
|
|
234
|
+
if time.monotonic() >= deadline:
|
|
235
|
+
raise APITimeoutError(f"Build {self.id} still {self.status!r} after {timeout}s.")
|
|
236
|
+
time.sleep(poll_interval)
|
|
237
|
+
self.refresh()
|
|
238
|
+
if on_poll is not None:
|
|
239
|
+
on_poll(self)
|
|
240
|
+
return self
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# =============================================================================
|
|
244
|
+
# Scoped sub-resources — reached via a Server handle (server.tasks, ...).
|
|
245
|
+
# =============================================================================
|
|
246
|
+
|
|
247
|
+
class _ScopedProjects:
|
|
248
|
+
def __init__(self, transport: SyncTransport, server_id: str) -> None:
|
|
249
|
+
self._t = transport
|
|
250
|
+
self._sid = server_id
|
|
251
|
+
|
|
252
|
+
def list(self) -> List[Project]:
|
|
253
|
+
r = self._t.request("GET", f"/servers/{_seg(self._sid)}/projects")
|
|
254
|
+
return [Project(self._t, p) for p in r["projects"]]
|
|
255
|
+
|
|
256
|
+
def create(
|
|
257
|
+
self,
|
|
258
|
+
*,
|
|
259
|
+
name: Optional[str] = None,
|
|
260
|
+
description: Optional[str] = None,
|
|
261
|
+
visibility: Optional[str] = None,
|
|
262
|
+
lifecycle: Optional[str] = None,
|
|
263
|
+
template: Optional[str] = None,
|
|
264
|
+
) -> Project:
|
|
265
|
+
body = {
|
|
266
|
+
k: v
|
|
267
|
+
for k, v in {
|
|
268
|
+
"name": name,
|
|
269
|
+
"description": description,
|
|
270
|
+
"visibility": visibility,
|
|
271
|
+
"lifecycle": lifecycle,
|
|
272
|
+
"template": template,
|
|
273
|
+
}.items()
|
|
274
|
+
if v is not None
|
|
275
|
+
}
|
|
276
|
+
r = self._t.request("POST", f"/servers/{_seg(self._sid)}/projects", body=body)
|
|
277
|
+
return Project(self._t, r)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class _ScopedTasks:
|
|
281
|
+
def __init__(self, transport: SyncTransport, server_id: str) -> None:
|
|
282
|
+
self._t = transport
|
|
283
|
+
self._sid = server_id
|
|
284
|
+
|
|
285
|
+
def create(self, goal: str) -> Task:
|
|
286
|
+
r = self._t.request("POST", f"/servers/{_seg(self._sid)}/tasks", body={"goal": goal})
|
|
287
|
+
return Task(self._t, {"id": r["task_id"], "serverId": self._sid, "goal": goal, "status": r["status"], "createdAt": r.get("createdAt", "")})
|
|
288
|
+
|
|
289
|
+
def list(self) -> List[Task]:
|
|
290
|
+
r = self._t.request("GET", f"/servers/{_seg(self._sid)}/tasks")
|
|
291
|
+
return [Task(self._t, x) for x in r["tasks"]]
|
|
292
|
+
|
|
293
|
+
def get(self, task_id: str) -> Task:
|
|
294
|
+
r = self._t.request("GET", f"/servers/{_seg(self._sid)}/tasks/{_seg(task_id)}")
|
|
295
|
+
return Task(self._t, r["task"])
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class _ScopedBuilds:
|
|
299
|
+
def __init__(self, transport: SyncTransport, server_id: str) -> None:
|
|
300
|
+
self._t = transport
|
|
301
|
+
self._sid = server_id
|
|
302
|
+
|
|
303
|
+
def start(self, goal: str) -> Build:
|
|
304
|
+
r = self._t.request("POST", f"/servers/{_seg(self._sid)}/builds", body={"goal": goal})
|
|
305
|
+
return Build(self._t, {"id": r["build_id"], "serverId": self._sid, "goal": goal, "status": r["status"], "steps": r.get("steps", []), "createdAt": r.get("createdAt", "")})
|
|
306
|
+
|
|
307
|
+
def list(self) -> List[Build]:
|
|
308
|
+
r = self._t.request("GET", f"/servers/{_seg(self._sid)}/builds")
|
|
309
|
+
return [Build(self._t, x) for x in r["builds"]]
|
|
310
|
+
|
|
311
|
+
def get(self, build_id: str) -> Build:
|
|
312
|
+
r = self._t.request("GET", f"/servers/{_seg(self._sid)}/builds/{_seg(build_id)}")
|
|
313
|
+
return Build(self._t, r["build"])
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class _ScopedFiles:
|
|
317
|
+
def __init__(self, transport: SyncTransport, server_id: str) -> None:
|
|
318
|
+
self._t = transport
|
|
319
|
+
self._sid = server_id
|
|
320
|
+
|
|
321
|
+
def list(self) -> List[str]:
|
|
322
|
+
r = self._t.request("GET", f"/servers/{_seg(self._sid)}/files")
|
|
323
|
+
return r["files"]
|
|
324
|
+
|
|
325
|
+
def read(self, path: str) -> str:
|
|
326
|
+
r = self._t.request("GET", f"/servers/{_seg(self._sid)}/files", query={"path": path})
|
|
327
|
+
return r["content"]
|
|
328
|
+
|
|
329
|
+
def write(self, path: str, content: str) -> None:
|
|
330
|
+
self._t.request("PUT", f"/servers/{_seg(self._sid)}/files", body={"path": path, "content": content})
|
|
331
|
+
|
|
332
|
+
def delete(self, path: str) -> None:
|
|
333
|
+
self._t.request("DELETE", f"/servers/{_seg(self._sid)}/files", query={"path": path})
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# =============================================================================
|
|
337
|
+
# Top-level resources — the flat client.servers.* API.
|
|
338
|
+
# =============================================================================
|
|
339
|
+
|
|
340
|
+
class ServersResource:
|
|
341
|
+
def __init__(self, transport: SyncTransport) -> None:
|
|
342
|
+
self._t = transport
|
|
343
|
+
|
|
344
|
+
def create(
|
|
345
|
+
self, *, task: Optional[str] = None, tier: Optional[ServerTier] = None, region: Optional[str] = None
|
|
346
|
+
) -> Server:
|
|
347
|
+
body = {k: v for k, v in {"task": task, "tier": tier, "region": region}.items() if v is not None}
|
|
348
|
+
r = self._t.request("POST", "/servers", body=body)
|
|
349
|
+
# create returns a partial shape ({id,url,status,tier}); normalize to a Server.
|
|
350
|
+
return Server(self._t, {
|
|
351
|
+
"id": r["id"], "task": task or "", "tier": r["tier"], "status": r["status"],
|
|
352
|
+
"region": region or "", "publicUrl": r.get("url"),
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
def list(self) -> List[Server]:
|
|
356
|
+
r = self._t.request("GET", "/servers")
|
|
357
|
+
return [Server(self._t, s) for s in r["servers"]]
|
|
358
|
+
|
|
359
|
+
def get(self, server_id: str) -> Server:
|
|
360
|
+
r = self._t.request("GET", f"/servers/{_seg(server_id)}")
|
|
361
|
+
return Server(self._t, r["server"])
|
|
362
|
+
|
|
363
|
+
def update(self, server_id: str, *, task: Optional[str] = None, region: Optional[str] = None) -> Server:
|
|
364
|
+
body = {k: v for k, v in {"task": task, "region": region}.items() if v is not None}
|
|
365
|
+
r = self._t.request("PATCH", f"/servers/{_seg(server_id)}", body=body)
|
|
366
|
+
return Server(self._t, r["server"])
|
|
367
|
+
|
|
368
|
+
def delete(self, server_id: str) -> None:
|
|
369
|
+
self._t.request("DELETE", f"/servers/{_seg(server_id)}")
|
|
370
|
+
|
|
371
|
+
def control(self, server_id: str, action: ServerAction) -> dict:
|
|
372
|
+
return self._t.request("POST", f"/servers/{_seg(server_id)}/actions", body={"action": action})
|
|
373
|
+
|
|
374
|
+
def exec(self, server_id: str, command: str) -> str:
|
|
375
|
+
r = self._t.request("POST", f"/servers/{_seg(server_id)}/exec", body={"command": command})
|
|
376
|
+
return r["stdout"]
|
|
377
|
+
|
|
378
|
+
def deploy(self, server_id: str) -> str:
|
|
379
|
+
r = self._t.request("POST", f"/servers/{_seg(server_id)}/deployments")
|
|
380
|
+
return r["url"]
|
|
381
|
+
|
|
382
|
+
def list_deployments(self, server_id: str) -> List[Deployment]:
|
|
383
|
+
r = self._t.request("GET", f"/servers/{_seg(server_id)}/deployments")
|
|
384
|
+
return [Deployment(**d) for d in r["deployments"]]
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
class ProjectsResource:
|
|
388
|
+
def __init__(self, transport: SyncTransport) -> None:
|
|
389
|
+
self._t = transport
|
|
390
|
+
|
|
391
|
+
def list(self) -> List[Project]:
|
|
392
|
+
r = self._t.request("GET", "/projects")
|
|
393
|
+
return [Project(self._t, p) for p in r["projects"]]
|
|
394
|
+
|
|
395
|
+
def list_for_server(self, server_id: str) -> List[Project]:
|
|
396
|
+
return _ScopedProjects(self._t, server_id).list()
|
|
397
|
+
|
|
398
|
+
def create(self, server_id: str, **opts) -> Project:
|
|
399
|
+
return _ScopedProjects(self._t, server_id).create(**opts)
|
|
400
|
+
|
|
401
|
+
def get(self, project_id: str) -> Project:
|
|
402
|
+
r = self._t.request("GET", f"/projects/{_seg(project_id)}")
|
|
403
|
+
return Project(self._t, r)
|
|
404
|
+
|
|
405
|
+
def update(self, project_id: str, **patch) -> Project:
|
|
406
|
+
body = {k: v for k, v in patch.items() if v is not None}
|
|
407
|
+
r = self._t.request("PATCH", f"/projects/{_seg(project_id)}", body=body)
|
|
408
|
+
return Project(self._t, r)
|
|
409
|
+
|
|
410
|
+
def delete(self, project_id: str) -> None:
|
|
411
|
+
self._t.request("DELETE", f"/projects/{_seg(project_id)}")
|
|
412
|
+
|
|
413
|
+
def cleanup(self, project_id: str) -> None:
|
|
414
|
+
self._t.request("POST", f"/projects/{_seg(project_id)}/cleanup")
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
class TemplatesResource:
|
|
418
|
+
def __init__(self, transport: SyncTransport) -> None:
|
|
419
|
+
self._t = transport
|
|
420
|
+
|
|
421
|
+
def list(self) -> List[ProjectTemplate]:
|
|
422
|
+
r = self._t.request("GET", "/templates")
|
|
423
|
+
return [ProjectTemplate.parse(x) for x in r["templates"]]
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
class TasksResource:
|
|
427
|
+
def __init__(self, transport: SyncTransport) -> None:
|
|
428
|
+
self._t = transport
|
|
429
|
+
|
|
430
|
+
def create(self, server_id: str, goal: str) -> Task:
|
|
431
|
+
return _ScopedTasks(self._t, server_id).create(goal)
|
|
432
|
+
|
|
433
|
+
def list(self, server_id: str) -> List[Task]:
|
|
434
|
+
return _ScopedTasks(self._t, server_id).list()
|
|
435
|
+
|
|
436
|
+
def get(self, server_id: str, task_id: str) -> Task:
|
|
437
|
+
return _ScopedTasks(self._t, server_id).get(task_id)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
class BuildsResource:
|
|
441
|
+
def __init__(self, transport: SyncTransport) -> None:
|
|
442
|
+
self._t = transport
|
|
443
|
+
|
|
444
|
+
def start(self, server_id: str, goal: str) -> Build:
|
|
445
|
+
return _ScopedBuilds(self._t, server_id).start(goal)
|
|
446
|
+
|
|
447
|
+
def list(self, server_id: str) -> List[Build]:
|
|
448
|
+
return _ScopedBuilds(self._t, server_id).list()
|
|
449
|
+
|
|
450
|
+
def get(self, server_id: str, build_id: str) -> Build:
|
|
451
|
+
return _ScopedBuilds(self._t, server_id).get(build_id)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
class FilesResource:
|
|
455
|
+
def __init__(self, transport: SyncTransport) -> None:
|
|
456
|
+
self._t = transport
|
|
457
|
+
|
|
458
|
+
def list(self, server_id: str) -> List[str]:
|
|
459
|
+
return _ScopedFiles(self._t, server_id).list()
|
|
460
|
+
|
|
461
|
+
def read(self, server_id: str, path: str) -> str:
|
|
462
|
+
return _ScopedFiles(self._t, server_id).read(path)
|
|
463
|
+
|
|
464
|
+
def write(self, server_id: str, path: str, content: str) -> None:
|
|
465
|
+
_ScopedFiles(self._t, server_id).write(path, content)
|
|
466
|
+
|
|
467
|
+
def delete(self, server_id: str, path: str) -> None:
|
|
468
|
+
_ScopedFiles(self._t, server_id).delete(path)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
class KeysResource:
|
|
472
|
+
def __init__(self, transport: SyncTransport) -> None:
|
|
473
|
+
self._t = transport
|
|
474
|
+
|
|
475
|
+
def list(self) -> List[ApiKey]:
|
|
476
|
+
r = self._t.request("GET", "/keys")
|
|
477
|
+
return [ApiKey.parse(k) for k in r["keys"]]
|
|
478
|
+
|
|
479
|
+
def create(self, label: str) -> CreatedApiKey:
|
|
480
|
+
r = self._t.request("POST", "/keys", body={"label": label})
|
|
481
|
+
base = ApiKey.parse(r)
|
|
482
|
+
return CreatedApiKey(key=r["key"], **base.__dict__)
|
|
483
|
+
|
|
484
|
+
def update_scope(
|
|
485
|
+
self,
|
|
486
|
+
key_id: str,
|
|
487
|
+
*,
|
|
488
|
+
allowed_server_ids: Optional[Sequence[str]] = None,
|
|
489
|
+
allowed_project_ids: Optional[Sequence[str]] = None,
|
|
490
|
+
) -> dict:
|
|
491
|
+
body = {
|
|
492
|
+
"allowed_server_ids": list(allowed_server_ids) if allowed_server_ids else [],
|
|
493
|
+
"allowed_project_ids": list(allowed_project_ids) if allowed_project_ids else [],
|
|
494
|
+
}
|
|
495
|
+
return self._t.request("PATCH", f"/keys/{_seg(key_id)}", body=body)
|
|
496
|
+
|
|
497
|
+
def revoke(self, key_id: str) -> None:
|
|
498
|
+
self._t.request("DELETE", f"/keys/{_seg(key_id)}")
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
class WebhooksResource:
|
|
502
|
+
def __init__(self, transport: SyncTransport) -> None:
|
|
503
|
+
self._t = transport
|
|
504
|
+
|
|
505
|
+
def list(self) -> List[Webhook]:
|
|
506
|
+
r = self._t.request("GET", "/webhooks")
|
|
507
|
+
return [Webhook.parse(w) for w in r["webhooks"]]
|
|
508
|
+
|
|
509
|
+
def create(self, url: str, events: Optional[Sequence[str]] = None) -> CreatedWebhook:
|
|
510
|
+
body = {"url": url, "events": list(events) if events is not None else None}
|
|
511
|
+
r = self._t.request("POST", "/webhooks", body=body)
|
|
512
|
+
base = Webhook.parse(r)
|
|
513
|
+
return CreatedWebhook(secret=r["secret"], **base.__dict__)
|
|
514
|
+
|
|
515
|
+
def delete(self, webhook_id: str) -> None:
|
|
516
|
+
self._t.request("DELETE", f"/webhooks/{_seg(webhook_id)}")
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
class Server4Agent:
|
|
520
|
+
"""Client for the Server4Agent REST API.
|
|
521
|
+
|
|
522
|
+
This SDK is for the code *around* your agent: your backend, a CI job, a
|
|
523
|
+
webhook receiver, an admin script. If your agent itself does tool-calling,
|
|
524
|
+
point it at the MCP server directly — it doesn't need this.
|
|
525
|
+
|
|
526
|
+
from server4agent import Server4Agent
|
|
527
|
+
|
|
528
|
+
client = Server4Agent() # reads SERVER4AGENT_API_KEY
|
|
529
|
+
server = client.servers.create(tier="small")
|
|
530
|
+
build = server.builds.start("a FastAPI todo API").wait()
|
|
531
|
+
print(build.url)
|
|
532
|
+
|
|
533
|
+
Usable as a context manager to close the underlying connection pool.
|
|
534
|
+
"""
|
|
535
|
+
|
|
536
|
+
def __init__(
|
|
537
|
+
self,
|
|
538
|
+
api_key: Optional[str] = None,
|
|
539
|
+
*,
|
|
540
|
+
base_url: str = _t.DEFAULT_BASE_URL,
|
|
541
|
+
timeout: float = _t.DEFAULT_TIMEOUT,
|
|
542
|
+
max_retries: int = _t.DEFAULT_MAX_RETRIES,
|
|
543
|
+
) -> None:
|
|
544
|
+
key = api_key or os.environ.get("SERVER4AGENT_API_KEY")
|
|
545
|
+
if not key:
|
|
546
|
+
raise ValueError(
|
|
547
|
+
"Server4Agent: no API key. Pass api_key=... or set SERVER4AGENT_API_KEY."
|
|
548
|
+
)
|
|
549
|
+
self._t = SyncTransport(base_url, key, timeout=timeout, max_retries=max_retries)
|
|
550
|
+
|
|
551
|
+
self.servers = ServersResource(self._t)
|
|
552
|
+
self.projects = ProjectsResource(self._t)
|
|
553
|
+
self.templates = TemplatesResource(self._t)
|
|
554
|
+
self.tasks = TasksResource(self._t)
|
|
555
|
+
self.builds = BuildsResource(self._t)
|
|
556
|
+
self.files = FilesResource(self._t)
|
|
557
|
+
self.keys = KeysResource(self._t)
|
|
558
|
+
self.webhooks = WebhooksResource(self._t)
|
|
559
|
+
|
|
560
|
+
def close(self) -> None:
|
|
561
|
+
self._t.close()
|
|
562
|
+
|
|
563
|
+
def __enter__(self) -> "Server4Agent":
|
|
564
|
+
return self
|
|
565
|
+
|
|
566
|
+
def __exit__(self, *exc) -> None:
|
|
567
|
+
self.close()
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Mapping, Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .. import _transport as t
|
|
9
|
+
from ..errors import APIConnectionError, APITimeoutError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SyncTransport:
|
|
13
|
+
"""Blocking HTTP with timeouts, method-aware retries, and typed errors."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
base_url: str,
|
|
18
|
+
api_key: str,
|
|
19
|
+
*,
|
|
20
|
+
timeout: float = t.DEFAULT_TIMEOUT,
|
|
21
|
+
max_retries: int = t.DEFAULT_MAX_RETRIES,
|
|
22
|
+
client: Optional[httpx.Client] = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
self.max_retries = max_retries
|
|
25
|
+
self._client = client or httpx.Client(
|
|
26
|
+
base_url=base_url.rstrip("/"),
|
|
27
|
+
timeout=timeout,
|
|
28
|
+
headers=t.default_headers(api_key),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def request(
|
|
32
|
+
self,
|
|
33
|
+
method: str,
|
|
34
|
+
path: str,
|
|
35
|
+
*,
|
|
36
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
37
|
+
body: Any = None,
|
|
38
|
+
) -> Any:
|
|
39
|
+
params = t.clean_params(query)
|
|
40
|
+
attempt = 0
|
|
41
|
+
while True:
|
|
42
|
+
t.logger.debug("--> %s %s", method, path)
|
|
43
|
+
started = time.perf_counter()
|
|
44
|
+
try:
|
|
45
|
+
resp = self._client.request(method, path, params=params, json=body)
|
|
46
|
+
except httpx.TimeoutException:
|
|
47
|
+
if t.can_retry_exception(method, attempt, self.max_retries):
|
|
48
|
+
time.sleep(t.retry_delay(attempt, None))
|
|
49
|
+
attempt += 1
|
|
50
|
+
continue
|
|
51
|
+
raise APITimeoutError() from None
|
|
52
|
+
except httpx.TransportError as exc:
|
|
53
|
+
if t.can_retry_exception(method, attempt, self.max_retries):
|
|
54
|
+
time.sleep(t.retry_delay(attempt, None))
|
|
55
|
+
attempt += 1
|
|
56
|
+
continue
|
|
57
|
+
raise APIConnectionError(str(exc)) from None
|
|
58
|
+
|
|
59
|
+
elapsed_ms = (time.perf_counter() - started) * 1000
|
|
60
|
+
t.logger.debug("<-- %s %s %s (%.0fms)", method, path, resp.status_code, elapsed_ms)
|
|
61
|
+
|
|
62
|
+
if t.should_retry_status(method, resp.status_code, attempt, self.max_retries):
|
|
63
|
+
delay = t.retry_delay(attempt, resp.headers.get("retry-after"))
|
|
64
|
+
t.logger.debug("... retrying in %.2fs (attempt %d)", delay, attempt + 1)
|
|
65
|
+
time.sleep(delay)
|
|
66
|
+
attempt += 1
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
data = t.decode_json(resp.content)
|
|
70
|
+
if resp.status_code >= 400:
|
|
71
|
+
t.raise_for_status(resp.status_code, data, resp.headers)
|
|
72
|
+
return data
|
|
73
|
+
|
|
74
|
+
def close(self) -> None:
|
|
75
|
+
self._client.close()
|