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,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Mapping, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .. import _transport as t
|
|
10
|
+
from ..errors import APIConnectionError, APITimeoutError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AsyncTransport:
|
|
14
|
+
"""Async HTTP with timeouts, method-aware retries, and typed errors."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
base_url: str,
|
|
19
|
+
api_key: str,
|
|
20
|
+
*,
|
|
21
|
+
timeout: float = t.DEFAULT_TIMEOUT,
|
|
22
|
+
max_retries: int = t.DEFAULT_MAX_RETRIES,
|
|
23
|
+
client: Optional[httpx.AsyncClient] = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
self.max_retries = max_retries
|
|
26
|
+
self._client = client or httpx.AsyncClient(
|
|
27
|
+
base_url=base_url.rstrip("/"),
|
|
28
|
+
timeout=timeout,
|
|
29
|
+
headers=t.default_headers(api_key),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
async def request(
|
|
33
|
+
self,
|
|
34
|
+
method: str,
|
|
35
|
+
path: str,
|
|
36
|
+
*,
|
|
37
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
38
|
+
body: Any = None,
|
|
39
|
+
) -> Any:
|
|
40
|
+
params = t.clean_params(query)
|
|
41
|
+
attempt = 0
|
|
42
|
+
while True:
|
|
43
|
+
t.logger.debug("--> %s %s", method, path)
|
|
44
|
+
started = time.perf_counter()
|
|
45
|
+
try:
|
|
46
|
+
resp = await self._client.request(method, path, params=params, json=body)
|
|
47
|
+
except httpx.TimeoutException:
|
|
48
|
+
if t.can_retry_exception(method, attempt, self.max_retries):
|
|
49
|
+
await asyncio.sleep(t.retry_delay(attempt, None))
|
|
50
|
+
attempt += 1
|
|
51
|
+
continue
|
|
52
|
+
raise APITimeoutError() from None
|
|
53
|
+
except httpx.TransportError as exc:
|
|
54
|
+
if t.can_retry_exception(method, attempt, self.max_retries):
|
|
55
|
+
await asyncio.sleep(t.retry_delay(attempt, None))
|
|
56
|
+
attempt += 1
|
|
57
|
+
continue
|
|
58
|
+
raise APIConnectionError(str(exc)) from None
|
|
59
|
+
|
|
60
|
+
elapsed_ms = (time.perf_counter() - started) * 1000
|
|
61
|
+
t.logger.debug("<-- %s %s %s (%.0fms)", method, path, resp.status_code, elapsed_ms)
|
|
62
|
+
|
|
63
|
+
if t.should_retry_status(method, resp.status_code, attempt, self.max_retries):
|
|
64
|
+
delay = t.retry_delay(attempt, resp.headers.get("retry-after"))
|
|
65
|
+
t.logger.debug("... retrying in %.2fs (attempt %d)", delay, attempt + 1)
|
|
66
|
+
await asyncio.sleep(delay)
|
|
67
|
+
attempt += 1
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
data = t.decode_json(resp.content)
|
|
71
|
+
if resp.status_code >= 400:
|
|
72
|
+
t.raise_for_status(resp.status_code, data, resp.headers)
|
|
73
|
+
return data
|
|
74
|
+
|
|
75
|
+
async def aclose(self) -> None:
|
|
76
|
+
await self._client.aclose()
|
server4agent/_models.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Wire types and parsers.
|
|
2
|
+
|
|
3
|
+
Pure-data responses are plain dataclasses. The four resources you can *act on*
|
|
4
|
+
(server, project, task, build) are returned as handles defined in the sync/async
|
|
5
|
+
clients; the field-parsing for those lives here as ``parse_*`` functions so both
|
|
6
|
+
clients stay identical.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Dict, List, Literal, Optional
|
|
12
|
+
|
|
13
|
+
ServerTier = Literal["small", "medium", "large"]
|
|
14
|
+
ServerStatus = Literal["running", "sleeping", "building"]
|
|
15
|
+
ServerAction = Literal["start", "stop", "restart"]
|
|
16
|
+
ProjectVisibility = Literal["public", "private"]
|
|
17
|
+
ProjectLifecycle = Literal["persistent", "ephemeral"]
|
|
18
|
+
ProjectRuntimeStatus = Literal["unbuilt", "running", "paused"]
|
|
19
|
+
TaskStatus = Literal["running", "completed", "failed", "awaiting_input", "cancelled"]
|
|
20
|
+
BuildStatus = Literal["queued", "running", "completed"]
|
|
21
|
+
|
|
22
|
+
# A task poll stops here: nothing further happens without you (input) or it's over.
|
|
23
|
+
TASK_TERMINAL_STATES = frozenset({"completed", "failed", "cancelled", "awaiting_input"})
|
|
24
|
+
BUILD_TERMINAL_STATES = frozenset({"completed"})
|
|
25
|
+
|
|
26
|
+
WEBHOOK_EVENTS = (
|
|
27
|
+
"task.created",
|
|
28
|
+
"task.completed",
|
|
29
|
+
"task.failed",
|
|
30
|
+
"deployment.live",
|
|
31
|
+
"deployment.removed",
|
|
32
|
+
"server.created",
|
|
33
|
+
"server.stopped",
|
|
34
|
+
"server.deleted",
|
|
35
|
+
"quota.threshold",
|
|
36
|
+
"quota.cap_reached",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _Record:
|
|
41
|
+
"""Base for handle types: a readable repr over public (non-underscore) attrs."""
|
|
42
|
+
|
|
43
|
+
def __repr__(self) -> str:
|
|
44
|
+
fields = ", ".join(
|
|
45
|
+
f"{k}={v!r}" for k, v in self.__dict__.items() if not k.startswith("_")
|
|
46
|
+
)
|
|
47
|
+
return f"{type(self).__name__}({fields})"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# --- pure data (no behavior) --------------------------------------------------
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Deployment:
|
|
54
|
+
url: str
|
|
55
|
+
live: bool
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class ExecResult:
|
|
60
|
+
stdout: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class BuildStep:
|
|
65
|
+
key: str
|
|
66
|
+
label: str
|
|
67
|
+
status: Literal["pending", "running", "done"]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class ProjectTemplateDefaults:
|
|
72
|
+
name: str
|
|
73
|
+
description: str
|
|
74
|
+
visibility: ProjectVisibility
|
|
75
|
+
lifecycle: ProjectLifecycle
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class ProjectTemplate:
|
|
80
|
+
id: str
|
|
81
|
+
label: str
|
|
82
|
+
description: str
|
|
83
|
+
defaults: ProjectTemplateDefaults
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def parse(raw: dict) -> "ProjectTemplate":
|
|
87
|
+
return ProjectTemplate(
|
|
88
|
+
id=raw["id"],
|
|
89
|
+
label=raw["label"],
|
|
90
|
+
description=raw["description"],
|
|
91
|
+
defaults=ProjectTemplateDefaults(**raw["defaults"]),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class ApiKey:
|
|
97
|
+
id: str
|
|
98
|
+
label: str
|
|
99
|
+
masked: str # e.g. "sk_live_abc···wxyz"; never the full secret
|
|
100
|
+
allowed_server_ids: List[str]
|
|
101
|
+
allowed_project_ids: List[str]
|
|
102
|
+
created_at: str
|
|
103
|
+
last_used_at: Optional[str]
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def parse(raw: dict) -> "ApiKey":
|
|
107
|
+
return ApiKey(
|
|
108
|
+
id=raw["id"],
|
|
109
|
+
label=raw["label"],
|
|
110
|
+
masked=raw["masked"],
|
|
111
|
+
allowed_server_ids=raw.get("allowed_server_ids", []),
|
|
112
|
+
allowed_project_ids=raw.get("allowed_project_ids", []),
|
|
113
|
+
created_at=raw["created_at"],
|
|
114
|
+
last_used_at=raw.get("last_used_at"),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class CreatedApiKey(ApiKey):
|
|
120
|
+
# The plaintext sk_live_ key. Store it now — it can't be retrieved again.
|
|
121
|
+
key: str = ""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class Webhook:
|
|
126
|
+
id: str
|
|
127
|
+
url: str
|
|
128
|
+
events: List[str]
|
|
129
|
+
created_at: str
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def parse(raw: dict) -> "Webhook":
|
|
133
|
+
return Webhook(id=raw["id"], url=raw["url"], events=raw["events"], created_at=raw["created_at"])
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class CreatedWebhook(Webhook):
|
|
138
|
+
# The signing secret (whsec_...). Store it now — it can't be retrieved again.
|
|
139
|
+
secret: str = ""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# --- field parsers for the handle types --------------------------------------
|
|
143
|
+
|
|
144
|
+
def parse_server(raw: dict) -> Dict[str, Any]:
|
|
145
|
+
return dict(
|
|
146
|
+
id=raw["id"],
|
|
147
|
+
task=raw["task"],
|
|
148
|
+
tier=raw["tier"],
|
|
149
|
+
status=raw["status"],
|
|
150
|
+
region=raw["region"],
|
|
151
|
+
credits_used=raw.get("creditsUsed", raw.get("credits_used", 0)),
|
|
152
|
+
storage_used_gb=raw.get("storageUsedGb", raw.get("storage_used_gb", 0)),
|
|
153
|
+
public_url=raw.get("publicUrl", raw.get("public_url")),
|
|
154
|
+
created_at=raw.get("createdAt", raw.get("created_at")),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def parse_project(raw: dict) -> Dict[str, Any]:
|
|
159
|
+
return dict(
|
|
160
|
+
id=raw["id"],
|
|
161
|
+
server_id=raw["server_id"],
|
|
162
|
+
name=raw["name"],
|
|
163
|
+
slug=raw["slug"],
|
|
164
|
+
description=raw.get("description"),
|
|
165
|
+
visibility=raw["visibility"],
|
|
166
|
+
lifecycle=raw["lifecycle"],
|
|
167
|
+
runtime_status=raw["runtime_status"],
|
|
168
|
+
template=raw.get("template"),
|
|
169
|
+
url=raw.get("url"),
|
|
170
|
+
created_at=raw["created_at"],
|
|
171
|
+
updated_at=raw["updated_at"],
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def parse_task(raw: dict) -> Dict[str, Any]:
|
|
176
|
+
return dict(
|
|
177
|
+
id=raw["id"],
|
|
178
|
+
server_id=raw.get("serverId", raw.get("server_id")),
|
|
179
|
+
goal=raw["goal"],
|
|
180
|
+
status=raw["status"],
|
|
181
|
+
created_at=raw.get("createdAt", raw.get("created_at")),
|
|
182
|
+
result=raw.get("result"),
|
|
183
|
+
question=raw.get("question"),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def parse_build(raw: dict) -> Dict[str, Any]:
|
|
188
|
+
return dict(
|
|
189
|
+
id=raw["id"],
|
|
190
|
+
server_id=raw.get("serverId", raw.get("server_id")),
|
|
191
|
+
goal=raw["goal"],
|
|
192
|
+
status=raw["status"],
|
|
193
|
+
steps=[BuildStep(**s) for s in raw.get("steps", [])],
|
|
194
|
+
artifacts=raw.get("artifacts", []),
|
|
195
|
+
url=raw.get("url"),
|
|
196
|
+
created_at=raw.get("createdAt", raw.get("created_at")),
|
|
197
|
+
)
|
|
File without changes
|