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.
- polygres_cli/__init__.py +1 -0
- polygres_cli/cli.py +1919 -0
- polygres_cli/cli_auth.py +89 -0
- polygres_cli/cli_client.py +427 -0
- polygres_cli/cli_config.py +91 -0
- polygres_cli/cli_errors.py +88 -0
- polygres_cli/cli_output.py +54 -0
- polygres_cli/cli_secrets.py +60 -0
- polygres_cli-0.1.0.dist-info/METADATA +95 -0
- polygres_cli-0.1.0.dist-info/RECORD +13 -0
- polygres_cli-0.1.0.dist-info/WHEEL +4 -0
- polygres_cli-0.1.0.dist-info/entry_points.txt +2 -0
- polygres_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
polygres_cli/cli_auth.py
ADDED
|
@@ -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."
|