hivepods-cli 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.
@@ -0,0 +1,3 @@
1
+ """HivePods CLI — manage DockHive pods from your terminal."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,193 @@
1
+ """Browser sign-in for the CLI.
2
+
3
+ Starts a throwaway web server on 127.0.0.1, opens the DockHive sign-in page in
4
+ the browser, and waits for the page to hand the session back to that local
5
+ address once the user has approved. Falls back cleanly if no browser is around.
6
+
7
+ The page can hand the session back two ways:
8
+ * POST /callback (form or JSON body with state/token/refresh) — preferred, the
9
+ token never appears in a URL or the browser history;
10
+ * GET /callback?state=…&token=… — still accepted; the reply immediately
11
+ replaces the history entry so the token doesn't linger there.
12
+
13
+ Anything else (wrong path, wrong state, wrong Host, stray requests from other
14
+ pages) gets a 4xx and is ignored: we keep waiting for the genuine callback until
15
+ the timeout.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import secrets
21
+ import threading
22
+ import time
23
+ import urllib.parse
24
+ import webbrowser
25
+ from http.server import BaseHTTPRequestHandler, HTTPServer
26
+ from typing import Optional
27
+
28
+ # How long we'll sit waiting for the browser round-trip before giving up.
29
+ TIMEOUT_SECONDS = 300
30
+ MAX_BODY = 64 * 1024
31
+
32
+ _DONE_PAGE = b"""<!doctype html><meta charset="utf-8">
33
+ <meta name="referrer" content="no-referrer">
34
+ <title>DockHive CLI</title>
35
+ <script>try{history.replaceState(null,'','/done')}catch(e){}</script>
36
+ <style>body{font:16px -apple-system,Segoe UI,sans-serif;background:#0b0c0f;color:#e8e8e8;
37
+ display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}
38
+ div{text-align:center;max-width:22rem;padding:2rem}</style>
39
+ <div><h2>%s</h2><p>%s</p></div>
40
+ """
41
+
42
+
43
+ class _Result:
44
+ token: Optional[str] = None
45
+ refresh: Optional[str] = None
46
+ error: Optional[str] = None
47
+
48
+
49
+ class _Handler(BaseHTTPRequestHandler):
50
+ # bound by start_login() before the server spins up
51
+ expected_state = ""
52
+ expected_port = 0
53
+ result = _Result()
54
+ done = None # threading.Event set once a genuine callback was handled
55
+
56
+ def log_message(self, *_args): # silence the default stderr logging
57
+ pass
58
+
59
+ def _reply(self, heading: str, detail: str, code: int = 200):
60
+ body = _DONE_PAGE % (heading.encode(), detail.encode())
61
+ self.send_response(code)
62
+ self.send_header("Content-Type", "text/html; charset=utf-8")
63
+ self.send_header("Content-Length", str(len(body)))
64
+ self.send_header("Cache-Control", "no-store")
65
+ self.send_header("Referrer-Policy", "no-referrer")
66
+ self.end_headers()
67
+ self.wfile.write(body)
68
+
69
+ def _host_ok(self) -> bool:
70
+ # Blocks DNS-rebinding pages from talking to the loopback server.
71
+ host = (self.headers.get("Host") or "").lower()
72
+ return host in (f"127.0.0.1:{self.expected_port}", f"localhost:{self.expected_port}")
73
+
74
+ def _handle(self, params: dict):
75
+ if not self._host_ok():
76
+ self._reply("Not allowed", "You can close this window.", code=421)
77
+ return
78
+ state = params.get("state") or ""
79
+ if not isinstance(state, str) or not secrets.compare_digest(
80
+ state.encode(), type(self).expected_state.encode()):
81
+ # Not our callback (stale tab, another page probing): ignore it.
82
+ self._reply("Something went wrong", "This sign-in link is not valid. Close this and retry.",
83
+ code=400)
84
+ return
85
+
86
+ result = type(self).result
87
+ if params.get("error"):
88
+ result.error = "sign-in was cancelled"
89
+ self._reply("Sign-in cancelled", "You can close this window.")
90
+ type(self).done.set()
91
+ return
92
+
93
+ token = params.get("token") or ""
94
+ if not isinstance(token, str) or not token:
95
+ self._reply("Something went wrong", "No session was returned. Close this and retry.", code=400)
96
+ return
97
+
98
+ result.token = token
99
+ refresh = params.get("refresh")
100
+ result.refresh = refresh if isinstance(refresh, str) and refresh else None
101
+ self._reply("You're signed in", "Head back to your terminal — you can close this window.")
102
+ type(self).done.set()
103
+
104
+ def do_GET(self):
105
+ parsed = urllib.parse.urlparse(self.path)
106
+ if parsed.path != "/callback":
107
+ self._reply("Not found", "You can close this window.", code=404)
108
+ return
109
+ params = {k: v[0] for k, v in urllib.parse.parse_qs(parsed.query).items()}
110
+ self._handle(params)
111
+
112
+ def do_POST(self):
113
+ parsed = urllib.parse.urlparse(self.path)
114
+ if parsed.path != "/callback":
115
+ self._reply("Not found", "You can close this window.", code=404)
116
+ return
117
+ try:
118
+ length = int(self.headers.get("Content-Length") or 0)
119
+ except ValueError:
120
+ length = 0
121
+ if length <= 0 or length > MAX_BODY:
122
+ self._reply("Something went wrong", "Invalid request.", code=400)
123
+ return
124
+ raw = self.rfile.read(length).decode("utf-8", "replace")
125
+ ctype = (self.headers.get("Content-Type") or "").split(";")[0].strip().lower()
126
+ if ctype == "application/json":
127
+ try:
128
+ params = json.loads(raw)
129
+ except ValueError:
130
+ params = None
131
+ if not isinstance(params, dict):
132
+ self._reply("Something went wrong", "Invalid request.", code=400)
133
+ return
134
+ else:
135
+ params = {k: v[0] for k, v in urllib.parse.parse_qs(raw).items()}
136
+ self._handle(params)
137
+
138
+
139
+ def start_login(web_url: str, open_browser: bool = True,
140
+ timeout: float = TIMEOUT_SECONDS) -> _Result:
141
+ """Run the loopback sign-in dance and return the captured session.
142
+
143
+ Raises RuntimeError on timeout or if the browser reports an error.
144
+ """
145
+ state = secrets.token_urlsafe(24)
146
+
147
+ server = HTTPServer(("127.0.0.1", 0), _Handler)
148
+ port = server.server_address[1]
149
+ done = threading.Event()
150
+ _Handler.expected_state = state
151
+ _Handler.expected_port = port
152
+ _Handler.result = _Result()
153
+ _Handler.done = done
154
+
155
+ auth_url = (
156
+ web_url.rstrip("/")
157
+ + "/cli-auth?"
158
+ + urllib.parse.urlencode({"port": port, "state": state})
159
+ )
160
+
161
+ deadline = time.monotonic() + timeout
162
+
163
+ def _serve():
164
+ # Keep answering until the genuine callback arrives or time runs out;
165
+ # wrong-state / stray requests don't end the wait.
166
+ while not done.is_set():
167
+ remaining = deadline - time.monotonic()
168
+ if remaining <= 0:
169
+ break
170
+ server.timeout = min(remaining, 1.0)
171
+ server.handle_request()
172
+
173
+ worker = threading.Thread(target=_serve, daemon=True)
174
+ worker.start()
175
+
176
+ opened = webbrowser.open(auth_url) if open_browser else False
177
+ if not opened:
178
+ print("Open this URL in your browser to sign in:\n " + auth_url)
179
+
180
+ worker.join(timeout + 5)
181
+ try:
182
+ server.server_close()
183
+ except OSError:
184
+ pass
185
+
186
+ result = _Handler.result
187
+ if not done.is_set():
188
+ raise RuntimeError("timed out waiting for the browser sign-in")
189
+ if result.error:
190
+ raise RuntimeError(result.error)
191
+ if not result.token:
192
+ raise RuntimeError("no session was returned")
193
+ return result
hivepods_cli/client.py ADDED
@@ -0,0 +1,410 @@
1
+ """HivePods API client."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import json
6
+ import re
7
+ import time
8
+ from typing import Callable, Optional
9
+ from urllib.parse import urlparse, urlunparse
10
+
11
+ import requests
12
+
13
+
14
+ # Statuses a pod passes through while the server is still working on it.
15
+ TRANSITIONAL = frozenset({
16
+ "provisioning", "initializing", "starting", "stopping", "restarting",
17
+ "suspending", "resuming", "applying", "terminating",
18
+ })
19
+
20
+
21
+ class WaitTimeout(Exception):
22
+ """Polling gave up; `last` is the last representation seen."""
23
+
24
+ def __init__(self, last: Optional[dict]):
25
+ super().__init__("timed out")
26
+ self.last = last
27
+
28
+
29
+ class APIError(Exception):
30
+ """Raised when the API returns an error. The message is safe to show."""
31
+
32
+ def __init__(self, message: str, status: Optional[int] = None):
33
+ super().__init__(message)
34
+ self.status = status
35
+
36
+
37
+ # Anything mentioning these is an implementation detail, not something to show.
38
+ _INTERNAL_WORDS = re.compile(
39
+ r"docker|container|kubernetes|k8s|kvm|qemu|traceback|daemon|celery|redis|"
40
+ r"postgres|psycopg|django|exception|errno|/var/|/app/|sha256:",
41
+ re.IGNORECASE,
42
+ )
43
+
44
+ _GENERIC = {
45
+ 400: "The request wasn't accepted. Check the values and try again.",
46
+ 409: "That can't be done right now. Check the environment's status and try again.",
47
+ 413: "That file is too large.",
48
+ 429: "Too many requests — please wait a moment and try again.",
49
+ }
50
+
51
+
52
+ def _jwt_expired(token: str, skew: int = 30) -> bool:
53
+ """True if `token` is a JWT whose exp has passed (unverified peek)."""
54
+ parts = token.split(".")
55
+ if len(parts) != 3:
56
+ return False
57
+ try:
58
+ pad = "=" * (-len(parts[1]) % 4)
59
+ claims = json.loads(base64.urlsafe_b64decode(parts[1] + pad))
60
+ exp = claims.get("exp")
61
+ except (ValueError, TypeError):
62
+ return False
63
+ return isinstance(exp, (int, float)) and exp <= time.time() + skew
64
+
65
+
66
+ class APIClient:
67
+ def __init__(self, api_url: str, token: Optional[str] = None, timeout: int = 30,
68
+ refresh_token: Optional[str] = None,
69
+ on_tokens: Optional[Callable[[str, Optional[str]], None]] = None):
70
+ self.api_url = api_url.rstrip("/")
71
+ self.token = token
72
+ self.refresh_token = refresh_token
73
+ # Called with (access, refresh) whenever the session is renewed, so the
74
+ # caller can persist it.
75
+ self.on_tokens = on_tokens
76
+ self.timeout = timeout
77
+
78
+ # ---- internals -----------------------------------------------------
79
+ def _headers(self) -> dict:
80
+ headers = {"Accept": "application/json"}
81
+ if self.token:
82
+ headers["Authorization"] = f"Bearer {self.token}"
83
+ return headers
84
+
85
+ def refresh(self) -> bool:
86
+ """Swap the refresh token for a new access token. True on success."""
87
+ if not self.refresh_token:
88
+ return False
89
+ try:
90
+ resp = requests.post(
91
+ f"{self.api_url}/api/v1/auth/refresh",
92
+ json={"refresh": self.refresh_token},
93
+ headers={"Accept": "application/json"},
94
+ timeout=self.timeout,
95
+ )
96
+ except requests.RequestException:
97
+ return False
98
+ if resp.status_code != 200:
99
+ return False
100
+ try:
101
+ data = resp.json()
102
+ except ValueError:
103
+ return False
104
+ access = data.get("access")
105
+ if not access:
106
+ return False
107
+ self.token = access
108
+ # The auth service rotates refresh tokens; keep the new one if sent.
109
+ self.refresh_token = data.get("refresh") or self.refresh_token
110
+ if self.on_tokens:
111
+ self.on_tokens(self.token, self.refresh_token)
112
+ return True
113
+
114
+ def ensure_fresh(self) -> None:
115
+ """Renew an expired access token up front (e.g. before opening a socket)."""
116
+ if self.token and self.refresh_token and _jwt_expired(self.token):
117
+ self.refresh()
118
+
119
+ def _request(self, method: str, path: str, _retried: bool = False, **kwargs):
120
+ if not _retried:
121
+ self.ensure_fresh()
122
+ url = f"{self.api_url}{path}"
123
+ extra = kwargs.pop("headers", None) or {} # per-call extras (X-Region)
124
+ headers = self._headers()
125
+ headers.update(extra)
126
+ try:
127
+ resp = requests.request(
128
+ method, url, headers=headers, timeout=self.timeout, **kwargs
129
+ )
130
+ except requests.RequestException:
131
+ host = urlparse(url).netloc or url
132
+ raise APIError(f"Could not reach DockHive ({host}). Check your connection and try again.")
133
+
134
+ if resp.status_code == 401:
135
+ if not _retried and self.refresh():
136
+ return self._request(method, path, _retried=True, headers=extra, **kwargs)
137
+ raise APIError("Please sign in first: run `hivepod login`.", 401)
138
+ if resp.status_code == 403:
139
+ raise APIError("Permission denied for this resource.", 403)
140
+ if resp.status_code == 404:
141
+ raise APIError("Not found.", 404)
142
+ if resp.status_code >= 500:
143
+ raise APIError(_server_message(resp), resp.status_code)
144
+ if resp.status_code >= 400:
145
+ raise APIError(_format_error(resp), resp.status_code)
146
+ if resp.status_code == 204 or not resp.content:
147
+ return None
148
+ return resp.json()
149
+
150
+ def _paginated(self, path: str, params: Optional[dict] = None) -> list:
151
+ """Return all results, following DRF page pagination."""
152
+ items: list = []
153
+ data = self._request("GET", path, params=params)
154
+ while True:
155
+ if isinstance(data, dict) and "results" in data:
156
+ items.extend(data["results"])
157
+ nxt = data.get("next")
158
+ if not nxt:
159
+ break
160
+ # `next` may name an internal host; keep our base, take its path+query.
161
+ parsed = urlparse(nxt)
162
+ data = self._request("GET", parsed.path + (f"?{parsed.query}" if parsed.query else ""))
163
+ elif isinstance(data, list):
164
+ items.extend(data)
165
+ break
166
+ else:
167
+ break
168
+ return items
169
+
170
+ # ---- auth ----------------------------------------------------------
171
+ def login(self, email: str, password: str, mfa_code: Optional[str] = None) -> dict:
172
+ """Password sign-in. Returns the response; {'requires_mfa': True} means
173
+ call again with mfa_code."""
174
+ body = {"email": email, "password": password}
175
+ if mfa_code:
176
+ body["mfa_code"] = mfa_code
177
+ data = self._request("POST", "/api/v1/auth/login", json=body) or {}
178
+ if data.get("requires_mfa"):
179
+ return data
180
+ token = data.get("access")
181
+ if not token:
182
+ raise APIError("Sign-in succeeded but no session was returned.")
183
+ self.token = token
184
+ self.refresh_token = data.get("refresh")
185
+ return data
186
+
187
+ def logout(self) -> None:
188
+ """Best-effort: revoke the refresh token on the server."""
189
+ if not (self.token and self.refresh_token):
190
+ return
191
+ try:
192
+ requests.post(f"{self.api_url}/api/v1/auth/logout",
193
+ json={"refresh": self.refresh_token},
194
+ headers=self._headers(), timeout=10)
195
+ except requests.RequestException:
196
+ pass
197
+
198
+ # ---- OS catalog ----------------------------------------------------
199
+ def list_os(self, include_gated: bool = False, family: Optional[str] = None) -> list:
200
+ params = {}
201
+ if include_gated:
202
+ params["available"] = "all"
203
+ if family:
204
+ params["family"] = family
205
+ return self._paginated("/api/v1/hivepods/catalog/os-images/", params=params)
206
+
207
+ # ---- pods ----------------------------------------------------------
208
+ def list_pods(self) -> list:
209
+ return self._paginated("/api/v1/hivepods/")
210
+
211
+ def get_pod(self, pod_id: str) -> dict:
212
+ return self._request("GET", f"/api/v1/hivepods/{pod_id}/")
213
+
214
+ def create_pod(self, payload: dict, region: Optional[str] = None) -> dict:
215
+ # X-Region lets the gateway route the create to a chosen region (multi-region).
216
+ headers = {"X-Region": region} if region else None
217
+ return self._request("POST", "/api/v1/hivepods/", json=payload, headers=headers)
218
+
219
+ def list_tiers(self) -> list:
220
+ """Resource sizes + specs. Falls back to built-ins on older servers."""
221
+ try:
222
+ return self._request("GET", "/api/v1/hivepods/tiers/") or []
223
+ except APIError:
224
+ return [
225
+ {"tier": "nano", "label": "Nano", "cpu": 0.5, "memory": "512m", "storage": "5g"},
226
+ {"tier": "micro", "label": "Micro", "cpu": 1, "memory": "1g", "storage": "10g"},
227
+ {"tier": "small", "label": "Small", "cpu": 2, "memory": "2g", "storage": "25g"},
228
+ {"tier": "medium", "label": "Medium", "cpu": 4, "memory": "4g", "storage": "50g"},
229
+ ]
230
+
231
+ def list_regions(self) -> list:
232
+ """Regions available for pod creation (best-effort)."""
233
+ try:
234
+ data = self._request("GET", "/api/v1/hivepods/regions/")
235
+ if isinstance(data, dict):
236
+ return data.get("regions", [])
237
+ return data or []
238
+ except APIError:
239
+ return []
240
+
241
+ def delete_pod(self, pod_id: str) -> None:
242
+ self._request("DELETE", f"/api/v1/hivepods/{pod_id}/")
243
+
244
+ def pod_action(self, pod_id: str, action: str, parameters: Optional[dict] = None) -> dict:
245
+ """Request a lifecycle action. The server answers 202 with the pod's
246
+ transitional status (older servers: 200 once done); see wait_for_pod."""
247
+ return self._request(
248
+ "POST", f"/api/v1/hivepods/{pod_id}/execute_action/",
249
+ json={"action": action, "parameters": parameters or {}},
250
+ ) or {}
251
+
252
+ def update_pod(self, pod_id: str, changes: dict) -> dict:
253
+ return self._request("PATCH", f"/api/v1/hivepods/{pod_id}/", json=changes)
254
+
255
+ def wait_for_pod(self, pod_id: str, timeout: float = 300, interval: float = 1.0,
256
+ on_status: Optional[Callable[[str], None]] = None,
257
+ sleep: Callable[[float], None] = time.sleep,
258
+ clock: Callable[[], float] = time.monotonic) -> Optional[dict]:
259
+ """Poll until the pod leaves every transitional status. Returns the pod,
260
+ or None once it no longer exists (destroyed). Raises WaitTimeout."""
261
+ deadline = clock() + timeout
262
+ last = None
263
+ delay = interval
264
+ while True:
265
+ try:
266
+ last = self.get_pod(pod_id)
267
+ except APIError as exc:
268
+ if exc.status == 404:
269
+ return None
270
+ raise
271
+ status = (last or {}).get("status", "")
272
+ if on_status:
273
+ on_status(status)
274
+ if status not in TRANSITIONAL:
275
+ return last
276
+ if clock() >= deadline:
277
+ raise WaitTimeout(last)
278
+ sleep(delay)
279
+ delay = min(delay * 1.5, 5.0)
280
+
281
+ # ---- backups -------------------------------------------------------
282
+ def create_backup(self, pod_id: str, name: str, description: str = "") -> dict:
283
+ return self._request("POST", f"/api/v1/hivepods/{pod_id}/backup/",
284
+ json={"name": name, "description": description})
285
+
286
+ def list_backups(self, pod_id: Optional[str] = None) -> list:
287
+ params = {"pod_id": pod_id} if pod_id else None
288
+ return self._paginated("/api/v1/hivepods/backups/", params=params)
289
+
290
+ def get_backup(self, backup_id: str) -> dict:
291
+ return self._request("GET", f"/api/v1/hivepods/backups/{backup_id}/")
292
+
293
+ def delete_backup(self, backup_id: str) -> None:
294
+ self._request("DELETE", f"/api/v1/hivepods/backups/{backup_id}/")
295
+
296
+ def restore_backup(self, backup_id: str, name: str, **options) -> dict:
297
+ body = {"name": name}
298
+ body.update({k: v for k, v in options.items() if v not in (None, "")})
299
+ return self._request("POST", f"/api/v1/hivepods/backups/{backup_id}/restore/", json=body)
300
+
301
+ def wait_for_backup(self, backup_id: str, timeout: float = 3600, interval: float = 2.0,
302
+ sleep: Callable[[float], None] = time.sleep,
303
+ clock: Callable[[], float] = time.monotonic) -> dict:
304
+ deadline = clock() + timeout
305
+ delay = interval
306
+ while True:
307
+ backup = self.get_backup(backup_id)
308
+ if backup.get("status") != "creating":
309
+ return backup
310
+ if clock() >= deadline:
311
+ raise WaitTimeout(backup)
312
+ sleep(delay)
313
+ delay = min(delay * 1.5, 10.0)
314
+
315
+ def resolve_backup(self, ref: str) -> dict:
316
+ """A backup by full id or a unique id prefix (as shown by `backup list`)."""
317
+ ref = ref.strip().lower()
318
+ backups = self.list_backups()
319
+ exact = [b for b in backups if str(b.get("id", "")).lower() == ref]
320
+ if exact:
321
+ return exact[0]
322
+ if len(ref) >= 4:
323
+ matches = [b for b in backups if str(b.get("id", "")).lower().startswith(ref)]
324
+ if len(matches) == 1:
325
+ return matches[0]
326
+ if len(matches) > 1:
327
+ raise APIError(f"'{ref}' matches more than one backup; use more of the id.")
328
+ raise APIError(f"No backup with id '{ref}'.")
329
+
330
+ def pod_exec(self, pod_id: str, command: str) -> dict:
331
+ """Run one command; returns {'exit_code', 'output', 'truncated', 'timed_out'}."""
332
+ return self._request(
333
+ "POST", f"/api/v1/hivepods/{pod_id}/exec/", json={"command": command},
334
+ )
335
+
336
+ def pod_metrics(self, pod_id: str) -> dict:
337
+ return self._request("GET", f"/api/v1/hivepods/{pod_id}/metrics/")
338
+
339
+ def pod_logs(self, pod_id: str, tail: int = 100) -> dict:
340
+ return self._request("GET", f"/api/v1/hivepods/{pod_id}/logs/", params={"tail": int(tail)})
341
+
342
+ def resolve_pod(self, name_or_id: str) -> dict:
343
+ """Accept a pod's id (region-tagged, as listed) or its name; return the pod."""
344
+ pods = self.list_pods()
345
+ for pod in pods:
346
+ if pod.get("id") == name_or_id or pod.get("name") == name_or_id:
347
+ return pod
348
+ # A bare uuid still matches a region-tagged id (<region>-<uuid>).
349
+ for pod in pods:
350
+ if str(pod.get("id", "")).endswith("-" + name_or_id) and len(name_or_id) == 36:
351
+ return pod
352
+ raise APIError(f"No environment named or with id '{name_or_id}'.")
353
+
354
+ # ---- websocket helpers ---------------------------------------------
355
+ def ws_url(self, path: str) -> str:
356
+ parsed = urlparse(self.api_url)
357
+ scheme = "wss" if parsed.scheme == "https" else "ws"
358
+ base_path = parsed.path.rstrip("/")
359
+ return urlunparse((scheme, parsed.netloc, base_path + path, "", "", ""))
360
+
361
+ def pod_ws_url(self, pod_id: str, kind: str) -> str:
362
+ """Terminal/log socket for a pod, under the hivepods prefix the gateway routes."""
363
+ return self.ws_url(f"/api/v1/hivepods/ws/pods/{pod_id}/{kind}/")
364
+
365
+ def ws_headers(self) -> dict:
366
+ """Credentials for sockets go in a header, never the URL (URLs get logged)."""
367
+ self.ensure_fresh()
368
+ return {"Authorization": f"Bearer {self.token}"} if self.token else {}
369
+
370
+
371
+ def _clean(text: str) -> Optional[str]:
372
+ text = str(text).strip()
373
+ if not text or _INTERNAL_WORDS.search(text):
374
+ return None
375
+ return text[:300]
376
+
377
+
378
+ def _server_message(resp) -> str:
379
+ if resp.status_code in (502, 503, 504):
380
+ return "DockHive is temporarily unavailable. Please try again in a moment."
381
+ try:
382
+ data = resp.json()
383
+ except ValueError:
384
+ data = None
385
+ if isinstance(data, dict):
386
+ msg = _clean(data.get("error") or data.get("message") or "")
387
+ if msg:
388
+ return msg
389
+ return "Something went wrong on our side. Please try again."
390
+
391
+
392
+ def _format_error(resp) -> str:
393
+ try:
394
+ data = resp.json()
395
+ except ValueError:
396
+ return _GENERIC.get(resp.status_code, f"The request failed (HTTP {resp.status_code}).")
397
+ if isinstance(data, dict):
398
+ parts = []
399
+ for key, val in data.items():
400
+ if isinstance(val, list):
401
+ val = "; ".join(str(v) for v in val)
402
+ elif isinstance(val, dict):
403
+ val = "; ".join(f"{k}: {v}" for k, v in val.items())
404
+ val = _clean(val)
405
+ if val is None:
406
+ continue
407
+ parts.append(val if key in ("error", "message", "detail", "non_field_errors") else f"{key}: {val}")
408
+ if parts:
409
+ return " | ".join(parts)
410
+ return _GENERIC.get(resp.status_code, f"The request failed (HTTP {resp.status_code}).")