polygres-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,89 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from typing import Any
5
+
6
+ from polygres_cli.cli_errors import AUTH, CliError
7
+
8
+
9
+ def clear_auth(config: dict[str, Any]) -> dict[str, Any]:
10
+ config.pop("auth", None)
11
+ return config
12
+
13
+
14
+ def parse_timestamp(value: object, *, field: str) -> datetime:
15
+ if not isinstance(value, str) or not value:
16
+ raise CliError(
17
+ "AUTH_RESPONSE_INVALID",
18
+ f"Authentication response did not include a valid {field}.",
19
+ exit_code=AUTH,
20
+ )
21
+ try:
22
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
23
+ except ValueError as exc:
24
+ raise CliError(
25
+ "AUTH_RESPONSE_INVALID",
26
+ f"Authentication response included an invalid {field}.",
27
+ exit_code=AUTH,
28
+ ) from exc
29
+ if parsed.tzinfo is None:
30
+ raise CliError(
31
+ "AUTH_RESPONSE_INVALID",
32
+ f"Authentication response included an invalid {field}.",
33
+ exit_code=AUTH,
34
+ )
35
+ return parsed.astimezone(timezone.utc)
36
+
37
+
38
+ def validate_start_response(payload: dict[str, Any]) -> tuple[str, str, str, datetime, int]:
39
+ values = tuple(payload.get(key) for key in ("login_session_id", "browser_url", "poll_token"))
40
+ if not all(isinstance(value, str) and value for value in values):
41
+ raise CliError(
42
+ "AUTH_RESPONSE_INVALID",
43
+ "Authentication start response is incomplete.",
44
+ exit_code=AUTH,
45
+ )
46
+ if not str(values[2]).startswith("pcli_poll_"):
47
+ raise CliError(
48
+ "AUTH_RESPONSE_INVALID",
49
+ "Authentication start response is incomplete.",
50
+ exit_code=AUTH,
51
+ )
52
+ interval = payload.get("poll_interval_seconds", 2)
53
+ if isinstance(interval, bool):
54
+ interval = 2
55
+ try:
56
+ interval = int(interval)
57
+ except (TypeError, ValueError):
58
+ interval = 2
59
+ return (
60
+ str(values[0]),
61
+ str(values[1]),
62
+ str(values[2]),
63
+ parse_timestamp(payload.get("expires_at"), field="expires_at"),
64
+ min(max(interval, 1), 30),
65
+ )
66
+
67
+
68
+ def validated_approved_auth(payload: dict[str, Any]) -> dict[str, Any]:
69
+ access_token = payload.get("access_token")
70
+ refresh_token = payload.get("refresh_token")
71
+ user = payload.get("user")
72
+ if not isinstance(access_token, str) or not access_token:
73
+ raise CliError(
74
+ "AUTH_RESPONSE_INVALID", "Login response omitted access_token.", exit_code=AUTH
75
+ )
76
+ if not isinstance(refresh_token, str) or not refresh_token:
77
+ raise CliError(
78
+ "AUTH_RESPONSE_INVALID", "Login response omitted refresh_token.", exit_code=AUTH
79
+ )
80
+ if not isinstance(user, dict):
81
+ raise CliError("AUTH_RESPONSE_INVALID", "Login response omitted user.", exit_code=AUTH)
82
+ expires_at = payload.get("expires_at")
83
+ parse_timestamp(expires_at, field="expires_at")
84
+ return {
85
+ "access_token": access_token,
86
+ "refresh_token": refresh_token,
87
+ "expires_at": expires_at,
88
+ "user": user,
89
+ }
@@ -0,0 +1,427 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ from collections.abc import Callable
6
+ from email.utils import parsedate_to_datetime
7
+ from pathlib import Path
8
+ from typing import Any, BinaryIO
9
+ from urllib.parse import urlsplit
10
+
11
+ import httpx
12
+
13
+ from polygres_cli.cli_errors import AUTH, UNAVAILABLE, CliError, api_error_from_response
14
+ from polygres_cli.cli_secrets import redact_string
15
+
16
+ VERSION = "0.1.0"
17
+ RETRY_STATUSES = {408, 429, 500, 502, 503, 504}
18
+ HEAVY_REQUEST_TIMEOUT = 120.0
19
+
20
+
21
+ class CliControlPlaneClient:
22
+ def __init__(
23
+ self,
24
+ *,
25
+ base_url: str,
26
+ access_token: str | None = None,
27
+ refresh_token: str | None = None,
28
+ on_token_refresh: Callable[[dict[str, Any]], None] | None = None,
29
+ on_refresh_auth_failure: Callable[[], None] | None = None,
30
+ verbose: bool = False,
31
+ trace: Callable[[str], None] | None = None,
32
+ timeout: float = 30.0,
33
+ max_retries: int = 2,
34
+ ) -> None:
35
+ self._base_url = base_url.rstrip("/")
36
+ self._access_token = access_token
37
+ self._refresh_token = refresh_token
38
+ self._on_token_refresh = on_token_refresh
39
+ self._on_refresh_auth_failure = on_refresh_auth_failure
40
+ self._refresh_attempted = False
41
+ self._verbose = verbose
42
+ self._trace = trace
43
+ self._timeout = timeout
44
+ self._max_retries = max_retries
45
+ self._client = httpx.Client(timeout=timeout)
46
+
47
+ def close(self) -> None:
48
+ self._client.close()
49
+
50
+ def __enter__(self) -> CliControlPlaneClient:
51
+ return self
52
+
53
+ def __exit__(self, *exc_info: object) -> None:
54
+ self.close()
55
+
56
+ def start_login(self, client: dict[str, Any]) -> dict[str, Any]:
57
+ return self._post("/cli/auth/start", {"client": client}, auth=False)
58
+
59
+ def poll_login(
60
+ self, login_session_id: str, poll_token: str, *, deadline: float | None = None
61
+ ) -> dict[str, Any]:
62
+ return self._post(
63
+ "/cli/auth/poll",
64
+ {"login_session_id": login_session_id, "poll_token": poll_token},
65
+ auth=False,
66
+ retry=True,
67
+ deadline=deadline,
68
+ )
69
+
70
+ def refresh_login(self, refresh_token: str) -> dict[str, Any]:
71
+ return self._post("/cli/auth/refresh", {"refresh_token": refresh_token}, auth=False)
72
+
73
+ def revoke_login(self, refresh_token: str) -> dict[str, Any]:
74
+ return self._post("/cli/auth/revoke", {"refresh_token": refresh_token}, auth=False)
75
+
76
+ def me(self) -> dict[str, Any]:
77
+ return self._get("/me")
78
+
79
+ def list_projects(self) -> dict[str, Any]:
80
+ return self._get("/projects")
81
+
82
+ def get_project(self, project_id: str) -> dict[str, Any]:
83
+ return self._get(f"/projects/{project_id}")
84
+
85
+ def create_project(
86
+ self,
87
+ name: str,
88
+ *,
89
+ request_timeout: float | None = None,
90
+ deadline: float | None = None,
91
+ ) -> dict[str, Any]:
92
+ return self._post(
93
+ "/projects", {"name": name}, timeout=request_timeout, deadline=deadline
94
+ )
95
+
96
+ def get_project_status(
97
+ self, project_id: str, *, deadline: float | None = None
98
+ ) -> dict[str, Any]:
99
+ return self._get(f"/projects/{project_id}/status", deadline=deadline)
100
+
101
+ def connection_info(self, project_id: str) -> dict[str, Any]:
102
+ return self._get(f"/projects/{project_id}/connection-info")
103
+
104
+ def list_api_keys(self, project_id: str) -> dict[str, Any]:
105
+ return self._get(f"/projects/{project_id}/api-keys")
106
+
107
+ def create_api_key(self, project_id: str, name: str) -> dict[str, Any]:
108
+ return self._post(f"/projects/{project_id}/api-keys", {"name": name})
109
+
110
+ def revoke_api_key(self, project_id: str, key_id: str) -> dict[str, Any]:
111
+ return self._delete(f"/projects/{project_id}/api-keys/{key_id}")
112
+
113
+ def csv_preview(self, project_id: str, file: Path, fields: dict[str, str]) -> dict[str, Any]:
114
+ with file.open("rb") as handle:
115
+ return self._multipart(
116
+ "POST",
117
+ f"/projects/{project_id}/imports/csv/preview",
118
+ handle,
119
+ file.name,
120
+ fields,
121
+ )
122
+
123
+ def start_csv_import(
124
+ self, project_id: str, file: Path, fields: dict[str, str]
125
+ ) -> dict[str, Any]:
126
+ with file.open("rb") as handle:
127
+ return self._multipart(
128
+ "POST",
129
+ f"/projects/{project_id}/imports/csv",
130
+ handle,
131
+ file.name,
132
+ fields,
133
+ )
134
+
135
+ def list_imports(self, project_id: str) -> dict[str, Any]:
136
+ return self._get(f"/projects/{project_id}/imports")
137
+
138
+ def get_import(
139
+ self, project_id: str, job_id: str, *, deadline: float | None = None
140
+ ) -> dict[str, Any]:
141
+ return self._get(f"/projects/{project_id}/imports/{job_id}", deadline=deadline)
142
+
143
+ def migrations_list(self, project_id: str) -> dict[str, Any]:
144
+ return self._get(f"/projects/{project_id}/migrations")
145
+
146
+ def migrations_create(self, project_id: str, name: str, sql_body: str) -> dict[str, Any]:
147
+ return self._post(
148
+ f"/projects/{project_id}/migrations",
149
+ {"name": name, "sql_body": sql_body},
150
+ )
151
+
152
+ def migrations_apply(self, project_id: str, migration_id: str) -> dict[str, Any]:
153
+ return self._post(
154
+ f"/projects/{project_id}/migrations/{migration_id}/apply",
155
+ {},
156
+ timeout=HEAVY_REQUEST_TIMEOUT,
157
+ )
158
+
159
+ def graph_discover(self, project_id: str) -> dict[str, Any]:
160
+ return self._post(f"/projects/{project_id}/graph/discover", {})
161
+
162
+ def get_graph_configuration(self, project_id: str) -> dict[str, Any]:
163
+ return self._get(f"/projects/{project_id}/graph/configuration")
164
+
165
+ def put_graph_configuration(self, project_id: str, payload: dict[str, Any]) -> dict[str, Any]:
166
+ return self._put(
167
+ f"/projects/{project_id}/graph/configuration",
168
+ payload,
169
+ timeout=HEAVY_REQUEST_TIMEOUT,
170
+ )
171
+
172
+ def graph_build(self, project_id: str) -> dict[str, Any]:
173
+ return self._post(
174
+ f"/projects/{project_id}/graph/build", {}, timeout=HEAVY_REQUEST_TIMEOUT
175
+ )
176
+
177
+ def graph_status(self, project_id: str) -> dict[str, Any]:
178
+ return self._get(f"/projects/{project_id}/graph/status")
179
+
180
+ def list_vector_configurations(self, project_id: str) -> dict[str, Any]:
181
+ return self._get(f"/projects/{project_id}/vector/configurations")
182
+
183
+ def create_vector_configuration(
184
+ self, project_id: str, payload: dict[str, Any]
185
+ ) -> dict[str, Any]:
186
+ return self._post(
187
+ f"/projects/{project_id}/vector/configurations",
188
+ payload,
189
+ timeout=HEAVY_REQUEST_TIMEOUT,
190
+ )
191
+
192
+ def delete_vector_configuration(self, project_id: str, config_id: str) -> dict[str, Any]:
193
+ return self._delete(f"/projects/{project_id}/vector/configurations/{config_id}")
194
+
195
+ def reindex_vector_configuration(self, project_id: str, config_id: str) -> dict[str, Any]:
196
+ return self._post(
197
+ f"/projects/{project_id}/vector/configurations/{config_id}/reindex",
198
+ {},
199
+ timeout=HEAVY_REQUEST_TIMEOUT,
200
+ )
201
+
202
+ def list_text_configurations(self, project_id: str) -> dict[str, Any]:
203
+ return self._get(f"/projects/{project_id}/text/configurations")
204
+
205
+ def create_text_configuration(self, project_id: str, payload: dict[str, Any]) -> dict[str, Any]:
206
+ return self._post(
207
+ f"/projects/{project_id}/text/configurations",
208
+ payload,
209
+ timeout=HEAVY_REQUEST_TIMEOUT,
210
+ )
211
+
212
+ def delete_text_configuration(self, project_id: str, config_id: str) -> dict[str, Any]:
213
+ return self._delete(f"/projects/{project_id}/text/configurations/{config_id}")
214
+
215
+ def retrieval_readiness(self, project_id: str) -> dict[str, Any]:
216
+ return self._get(f"/projects/{project_id}/retrieval/readiness")
217
+
218
+ def _get(self, path: str, *, deadline: float | None = None) -> dict[str, Any]:
219
+ return self._request("GET", path, retry=True, deadline=deadline)
220
+
221
+ def _post(
222
+ self,
223
+ path: str,
224
+ payload: dict[str, Any],
225
+ *,
226
+ auth: bool = True,
227
+ retry: bool = False,
228
+ timeout: float | None = None,
229
+ deadline: float | None = None,
230
+ ) -> dict[str, Any]:
231
+ return self._request(
232
+ "POST",
233
+ path,
234
+ json=payload,
235
+ auth=auth,
236
+ retry=retry,
237
+ timeout=timeout,
238
+ deadline=deadline,
239
+ )
240
+
241
+ def _put(
242
+ self, path: str, payload: dict[str, Any], *, timeout: float | None = None
243
+ ) -> dict[str, Any]:
244
+ return self._request("PUT", path, json=payload, retry=False, timeout=timeout)
245
+
246
+ def _delete(self, path: str) -> dict[str, Any]:
247
+ return self._request("DELETE", path, retry=False)
248
+
249
+ def _multipart(
250
+ self,
251
+ method: str,
252
+ path: str,
253
+ file: BinaryIO,
254
+ filename: str,
255
+ fields: dict[str, str],
256
+ ) -> dict[str, Any]:
257
+ return self._request(
258
+ method,
259
+ path,
260
+ data=fields,
261
+ files={"file": (filename, file, "text/csv")},
262
+ retry=False,
263
+ )
264
+
265
+ def _request(
266
+ self,
267
+ method: str,
268
+ path: str,
269
+ *,
270
+ json: dict[str, Any] | None = None,
271
+ data: dict[str, str] | None = None,
272
+ files: dict[str, Any] | None = None,
273
+ auth: bool = True,
274
+ retry: bool = False,
275
+ allow_refresh: bool = True,
276
+ timeout: float | None = None,
277
+ deadline: float | None = None,
278
+ ) -> dict[str, Any]:
279
+ if auth and not self._access_token:
280
+ raise CliError("AUTH_REQUIRED", "Run `polygres login` to continue.", exit_code=3)
281
+ headers = {"User-Agent": f"polygres-cli/{VERSION}"}
282
+ if auth and self._access_token:
283
+ headers["Authorization"] = f"Bearer {self._access_token}"
284
+ url = f"{self._base_url}{path}"
285
+ retry_budget = self._max_retries if retry else 0
286
+ started = time.monotonic()
287
+ response: httpx.Response | None = None
288
+ for attempt in range(retry_budget + 1):
289
+ remaining = _remaining_seconds(deadline)
290
+ if remaining is not None and remaining <= 0:
291
+ raise CliError("TIMEOUT", "Command deadline expired.", exit_code=UNAVAILABLE)
292
+ try:
293
+ request_kwargs: dict[str, Any] = {
294
+ "headers": headers,
295
+ "json": json,
296
+ "data": data,
297
+ "files": files,
298
+ }
299
+ request_timeout = timeout
300
+ if remaining is not None:
301
+ request_timeout = min(request_timeout or self._timeout, remaining)
302
+ if request_timeout is not None:
303
+ request_kwargs["timeout"] = request_timeout
304
+ response = self._client.request(method, url, **request_kwargs)
305
+ except (httpx.TimeoutException, httpx.NetworkError) as exc:
306
+ if attempt < retry_budget:
307
+ _sleep_before_retry(attempt, None, deadline=deadline)
308
+ continue
309
+ raise CliError(
310
+ "SERVICE_UNAVAILABLE",
311
+ "Polygres API is unavailable.",
312
+ exit_code=UNAVAILABLE,
313
+ ) from exc
314
+ if response.status_code in RETRY_STATUSES and attempt < retry_budget:
315
+ _sleep_before_retry(
316
+ attempt, response.headers.get("Retry-After"), deadline=deadline
317
+ )
318
+ continue
319
+ break
320
+ assert response is not None
321
+ elapsed_ms = int((time.monotonic() - started) * 1000)
322
+ payload = _json_payload(response)
323
+ if self._verbose:
324
+ request_id = payload.get("request_id") if isinstance(payload, dict) else None
325
+ self._emit_trace(method, path, response.status_code, elapsed_ms, request_id)
326
+ if response.is_error:
327
+ if (
328
+ auth
329
+ and allow_refresh
330
+ and response.status_code == 401
331
+ and self._refresh_token
332
+ and self._refresh_access_token()
333
+ ):
334
+ return self._request(
335
+ method,
336
+ path,
337
+ json=json,
338
+ data=data,
339
+ files=files,
340
+ auth=auth,
341
+ retry=retry,
342
+ allow_refresh=False,
343
+ timeout=timeout,
344
+ deadline=deadline,
345
+ )
346
+ raise api_error_from_response(response.status_code, payload)
347
+ return payload
348
+
349
+ def _refresh_access_token(self) -> bool:
350
+ if self._refresh_attempted or not self._refresh_token:
351
+ return False
352
+ self._refresh_attempted = True
353
+ try:
354
+ payload = self.refresh_login(self._refresh_token)
355
+ except CliError as exc:
356
+ if exc.exit_code == AUTH and self._on_refresh_auth_failure is not None:
357
+ self._on_refresh_auth_failure()
358
+ raise
359
+ access_token = payload.get("access_token")
360
+ refresh_token = payload.get("refresh_token")
361
+ if not isinstance(access_token, str) or not isinstance(refresh_token, str):
362
+ if self._on_refresh_auth_failure is not None:
363
+ self._on_refresh_auth_failure()
364
+ request_id = payload.get("request_id")
365
+ raise CliError(
366
+ "AUTH_REFRESH_INVALID",
367
+ "Refresh response did not include replacement tokens.",
368
+ exit_code=AUTH,
369
+ request_id=request_id if isinstance(request_id, str) else None,
370
+ )
371
+ self._access_token = access_token
372
+ self._refresh_token = refresh_token
373
+ if self._on_token_refresh is not None:
374
+ self._on_token_refresh(payload)
375
+ return True
376
+
377
+ def _emit_trace(
378
+ self, method: str, path: str, status: int, elapsed_ms: int, request_id: object
379
+ ) -> None:
380
+ if not self._trace:
381
+ return
382
+ parsed = urlsplit(path)
383
+ rendered_path = parsed.path or path
384
+ parts = [f"{method} {rendered_path} -> {status}", f"{elapsed_ms}ms"]
385
+ if request_id:
386
+ parts.append(f"request_id={request_id}")
387
+ self._trace(redact_string(" ".join(parts)))
388
+
389
+
390
+ def _json_payload(response: httpx.Response) -> dict[str, Any]:
391
+ if not response.content:
392
+ return {}
393
+ try:
394
+ payload = response.json()
395
+ except json.JSONDecodeError:
396
+ return {}
397
+ return payload if isinstance(payload, dict) else {}
398
+
399
+
400
+ def _sleep_before_retry(
401
+ attempt: int, retry_after: str | None, *, deadline: float | None = None
402
+ ) -> None:
403
+ delay = _retry_after_seconds(retry_after)
404
+ if delay is None:
405
+ delay = min(2**attempt, 5)
406
+ remaining = _remaining_seconds(deadline)
407
+ if remaining is not None:
408
+ delay = min(delay, max(remaining, 0.0))
409
+ if delay > 0:
410
+ time.sleep(delay)
411
+
412
+
413
+ def _remaining_seconds(deadline: float | None) -> float | None:
414
+ return None if deadline is None else deadline - time.monotonic()
415
+
416
+
417
+ def _retry_after_seconds(value: str | None) -> float | None:
418
+ if not value:
419
+ return None
420
+ try:
421
+ return max(float(value), 0.0)
422
+ except ValueError:
423
+ try:
424
+ parsed = parsedate_to_datetime(value)
425
+ except (TypeError, ValueError, OverflowError):
426
+ return None
427
+ return max(parsed.timestamp() - time.time(), 0.0)
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from polygres_cli.cli_errors import GENERAL_FAILURE, CliError
10
+
11
+ DEFAULT_API_BASE_URL = "https://api.polygres.com/v1"
12
+ CONFIG_VERSION = 1
13
+
14
+
15
+ def default_config_path() -> Path:
16
+ return Path.home() / ".config" / "polygres" / "config.json"
17
+
18
+
19
+ class ConfigStore:
20
+ def __init__(self, path: Path | None = None) -> None:
21
+ self.path = path or default_config_path()
22
+
23
+ def load(self) -> dict[str, Any]:
24
+ if not self.path.exists():
25
+ return {"version": CONFIG_VERSION, "api_base_url": DEFAULT_API_BASE_URL}
26
+ try:
27
+ payload = json.loads(self.path.read_text(encoding="utf-8"))
28
+ except json.JSONDecodeError as exc:
29
+ raise CliError(
30
+ code="CONFIG_INVALID",
31
+ message=f"Invalid Polygres config JSON at {self.path}.",
32
+ exit_code=GENERAL_FAILURE,
33
+ ) from exc
34
+ if not isinstance(payload, dict):
35
+ raise CliError(
36
+ code="CONFIG_INVALID",
37
+ message=f"Invalid Polygres config JSON at {self.path}.",
38
+ exit_code=GENERAL_FAILURE,
39
+ )
40
+ payload.setdefault("version", CONFIG_VERSION)
41
+ payload.setdefault("api_base_url", DEFAULT_API_BASE_URL)
42
+ return payload
43
+
44
+ def save(self, data: dict[str, Any]) -> None:
45
+ self.path.parent.mkdir(parents=True, exist_ok=True)
46
+ _chmod_owner_only(self.path.parent, 0o700)
47
+ temp_path = self.path.with_suffix(".json.tmp")
48
+ temp_path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
49
+ _chmod_owner_only(temp_path, 0o600)
50
+ os.replace(temp_path, self.path)
51
+ _chmod_owner_only(self.path, 0o600)
52
+
53
+
54
+ def resolve_api_base_url(config: dict[str, Any]) -> str:
55
+ return os.environ.get("POLYGRES_API_BASE_URL") or str(
56
+ config.get("api_base_url") or DEFAULT_API_BASE_URL
57
+ )
58
+
59
+
60
+ def access_token(config: dict[str, Any]) -> str | None:
61
+ env_token = os.environ.get("POLYGRES_ACCESS_TOKEN")
62
+ if env_token:
63
+ return env_token
64
+ auth = config.get("auth")
65
+ if isinstance(auth, dict) and isinstance(auth.get("access_token"), str):
66
+ return auth["access_token"]
67
+ return None
68
+
69
+
70
+ def env_access_token_set() -> bool:
71
+ return bool(os.environ.get("POLYGRES_ACCESS_TOKEN"))
72
+
73
+
74
+ def refresh_token(config: dict[str, Any]) -> str | None:
75
+ auth = config.get("auth")
76
+ if isinstance(auth, dict) and isinstance(auth.get("refresh_token"), str):
77
+ return auth["refresh_token"]
78
+ return None
79
+
80
+
81
+ def _chmod_owner_only(path: Path, mode: int) -> None:
82
+ if sys.platform == "win32":
83
+ return
84
+ try:
85
+ os.chmod(path, mode)
86
+ except OSError as exc:
87
+ raise CliError(
88
+ code="CONFIG_PERMISSIONS_INVALID",
89
+ message=f"Unable to set owner-only permissions on {path}.",
90
+ exit_code=GENERAL_FAILURE,
91
+ ) from exc
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+ SUCCESS = 0
7
+ GENERAL_FAILURE = 1
8
+ USAGE = 2
9
+ AUTH = 3
10
+ PERMISSION = 4
11
+ NOT_FOUND = 5
12
+ CONFLICT = 6
13
+ RATE_LIMITED = 7
14
+ UNAVAILABLE = 8
15
+ LOCAL_DEPENDENCY = 9
16
+
17
+ HTTP_EXIT_CODES = {
18
+ 400: USAGE,
19
+ 401: AUTH,
20
+ 403: PERMISSION,
21
+ 404: NOT_FOUND,
22
+ 409: CONFLICT,
23
+ 422: USAGE,
24
+ 429: RATE_LIMITED,
25
+ 500: UNAVAILABLE,
26
+ 502: UNAVAILABLE,
27
+ 503: UNAVAILABLE,
28
+ 504: UNAVAILABLE,
29
+ }
30
+
31
+
32
+ @dataclass
33
+ class CliError(Exception):
34
+ code: str
35
+ message: str
36
+ exit_code: int = GENERAL_FAILURE
37
+ details: dict[str, Any] = field(default_factory=dict)
38
+ request_id: str | None = None
39
+
40
+ def __str__(self) -> str:
41
+ return self.message
42
+
43
+
44
+ class UsageError(CliError):
45
+ def __init__(self, message: str, *, code: str = "INVALID_USAGE") -> None:
46
+ super().__init__(code=code, message=message, exit_code=USAGE)
47
+
48
+
49
+ def api_error_from_response(status_code: int, payload: dict[str, Any] | None) -> CliError:
50
+ payload = payload or {}
51
+ error = payload.get("error")
52
+ if not isinstance(error, dict):
53
+ error = {}
54
+ return CliError(
55
+ code=str(error.get("code") or _default_code(status_code)),
56
+ message=str(error.get("message") or _default_message(status_code)),
57
+ details=error.get("details") if isinstance(error.get("details"), dict) else {},
58
+ request_id=payload.get("request_id"),
59
+ exit_code=HTTP_EXIT_CODES.get(status_code, GENERAL_FAILURE),
60
+ )
61
+
62
+
63
+ def _default_code(status_code: int) -> str:
64
+ if status_code == 401:
65
+ return "AUTH_REQUIRED"
66
+ if status_code == 403:
67
+ return "PERMISSION_DENIED"
68
+ if status_code == 404:
69
+ return "NOT_FOUND"
70
+ if status_code == 429:
71
+ return "RATE_LIMITED"
72
+ if status_code in {500, 502, 503, 504}:
73
+ return "SERVICE_UNAVAILABLE"
74
+ return "API_ERROR"
75
+
76
+
77
+ def _default_message(status_code: int) -> str:
78
+ if status_code == 401:
79
+ return "Run `polygres login` to continue."
80
+ if status_code == 403:
81
+ return "Permission denied."
82
+ if status_code == 404:
83
+ return "Resource not found."
84
+ if status_code == 429:
85
+ return "Rate limited."
86
+ if status_code in {500, 502, 503, 504}:
87
+ return "Polygres API is unavailable."
88
+ return "Polygres API request failed."