polygres-cli 0.1.0__py3-none-any.whl → 0.1.2__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/cli.py +4 -20
- polygres_cli/cli_client.py +130 -43
- polygres_cli/cli_errors.py +1 -0
- {polygres_cli-0.1.0.dist-info → polygres_cli-0.1.2.dist-info}/METADATA +1 -1
- polygres_cli-0.1.2.dist-info/RECORD +13 -0
- polygres_cli-0.1.0.dist-info/RECORD +0 -13
- {polygres_cli-0.1.0.dist-info → polygres_cli-0.1.2.dist-info}/WHEEL +0 -0
- {polygres_cli-0.1.0.dist-info → polygres_cli-0.1.2.dist-info}/entry_points.txt +0 -0
- {polygres_cli-0.1.0.dist-info → polygres_cli-0.1.2.dist-info}/licenses/LICENSE +0 -0
polygres_cli/cli.py
CHANGED
|
@@ -45,7 +45,6 @@ UUID_LIKE_RE = re.compile(
|
|
|
45
45
|
)
|
|
46
46
|
SQL_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
47
47
|
MIGRATION_NAME_RE = SQL_IDENTIFIER_RE
|
|
48
|
-
MAX_CSV_UPLOAD_BYTES = 1024**3
|
|
49
48
|
GRAPH_CONFIGURATION_KEYS = {
|
|
50
49
|
"registered_tables",
|
|
51
50
|
"registered_relationships",
|
|
@@ -992,19 +991,6 @@ def handle_text_configs_delete(ctx: Context, args: argparse.Namespace) -> int:
|
|
|
992
991
|
|
|
993
992
|
def handle_import_csv(ctx: Context, args: argparse.Namespace) -> int:
|
|
994
993
|
file_path = _readable_file(args.file)
|
|
995
|
-
try:
|
|
996
|
-
file_size = file_path.stat().st_size
|
|
997
|
-
except OSError as exc:
|
|
998
|
-
raise CliError(
|
|
999
|
-
"VALIDATION_ERROR", f"Unable to read file: {file_path}", exit_code=USAGE
|
|
1000
|
-
) from exc
|
|
1001
|
-
if file_size > MAX_CSV_UPLOAD_BYTES:
|
|
1002
|
-
raise CliError(
|
|
1003
|
-
"IMPORT_LIMIT_EXCEEDED",
|
|
1004
|
-
"CSV file exceeds the 1 GiB local upload limit.",
|
|
1005
|
-
exit_code=USAGE,
|
|
1006
|
-
details={"limit_bytes": MAX_CSV_UPLOAD_BYTES, "file_size_bytes": file_size},
|
|
1007
|
-
)
|
|
1008
994
|
_validate_identifiers(args.schema, args.table)
|
|
1009
995
|
preview_fields = {
|
|
1010
996
|
"target_schema": args.schema,
|
|
@@ -1032,22 +1018,20 @@ def handle_import_csv(ctx: Context, args: argparse.Namespace) -> int:
|
|
|
1032
1018
|
columns = preview_payload.get("columns")
|
|
1033
1019
|
if not isinstance(columns, list):
|
|
1034
1020
|
raise CliError("IMPORT_INVALID", "CSV preview response did not include columns.")
|
|
1035
|
-
import_fields = {
|
|
1021
|
+
import_fields: dict[str, object] = {
|
|
1036
1022
|
"target_schema": args.schema,
|
|
1037
1023
|
"target_table": args.table,
|
|
1038
1024
|
"mode": args.mode,
|
|
1039
1025
|
"encoding": str(preview_payload.get("encoding", args.encoding)),
|
|
1040
1026
|
"delimiter": str(preview_payload.get("delimiter", args.delimiter or "")),
|
|
1041
1027
|
"quote_char": str(preview_payload.get("quote_char", args.quote_char or '"')),
|
|
1042
|
-
"has_header": (
|
|
1043
|
-
"true" if preview_payload.get("has_header", not args.no_header) else "false"
|
|
1044
|
-
),
|
|
1028
|
+
"has_header": bool(preview_payload.get("has_header", not args.no_header)),
|
|
1045
1029
|
}
|
|
1046
1030
|
effective_escape = preview_payload.get("escape_char", args.escape_char)
|
|
1047
1031
|
if effective_escape is not None:
|
|
1048
1032
|
import_fields["escape_char"] = str(effective_escape)
|
|
1049
|
-
import_fields.update({"job_id": job_id, "columns":
|
|
1050
|
-
started = ctx.client.start_csv_import(project_id,
|
|
1033
|
+
import_fields.update({"job_id": job_id, "columns": columns})
|
|
1034
|
+
started = ctx.client.start_csv_import(project_id, import_fields)
|
|
1051
1035
|
output = _import_output(started)
|
|
1052
1036
|
if args.wait and output["import"].get("status") not in {"succeeded", "failed", "cancelled"}:
|
|
1053
1037
|
import_id = output["import"].get("id")
|
polygres_cli/cli_client.py
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import json as jsonlib
|
|
4
6
|
import time
|
|
5
7
|
from collections.abc import Callable
|
|
6
8
|
from email.utils import parsedate_to_datetime
|
|
7
9
|
from pathlib import Path
|
|
8
|
-
from typing import Any
|
|
9
|
-
from urllib.parse import urlsplit
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import quote, urlsplit
|
|
10
12
|
|
|
11
13
|
import httpx
|
|
12
14
|
|
|
13
15
|
from polygres_cli.cli_errors import AUTH, UNAVAILABLE, CliError, api_error_from_response
|
|
14
16
|
from polygres_cli.cli_secrets import redact_string
|
|
15
17
|
|
|
16
|
-
VERSION = "0.1.
|
|
18
|
+
VERSION = "0.1.2"
|
|
17
19
|
RETRY_STATUSES = {408, 429, 500, 502, 503, 504}
|
|
18
20
|
HEAVY_REQUEST_TIMEOUT = 120.0
|
|
19
21
|
|
|
@@ -111,26 +113,131 @@ class CliControlPlaneClient:
|
|
|
111
113
|
return self._delete(f"/projects/{project_id}/api-keys/{key_id}")
|
|
112
114
|
|
|
113
115
|
def csv_preview(self, project_id: str, file: Path, fields: dict[str, str]) -> dict[str, Any]:
|
|
116
|
+
file_size = file.stat().st_size
|
|
117
|
+
session = self._post(
|
|
118
|
+
f"/projects/{project_id}/imports/csv/upload-sessions",
|
|
119
|
+
{"original_filename": file.name, "file_size_bytes": file_size},
|
|
120
|
+
)
|
|
121
|
+
upload = session.get("upload")
|
|
122
|
+
if not isinstance(upload, dict):
|
|
123
|
+
raise CliError("IMPORT_INVALID", "Upload session response is invalid.")
|
|
124
|
+
job_id = upload.get("job_id")
|
|
125
|
+
upload_url = upload.get("upload_url")
|
|
126
|
+
block_size = upload.get("block_size_bytes")
|
|
127
|
+
if (
|
|
128
|
+
not isinstance(job_id, str)
|
|
129
|
+
or not isinstance(upload_url, str)
|
|
130
|
+
or isinstance(block_size, bool)
|
|
131
|
+
or not isinstance(block_size, int)
|
|
132
|
+
or block_size <= 0
|
|
133
|
+
):
|
|
134
|
+
raise CliError("IMPORT_INVALID", "Upload session response is incomplete.")
|
|
135
|
+
sha256 = self._upload_csv_blocks(file, upload_url, block_size)
|
|
136
|
+
payload: dict[str, Any] = {
|
|
137
|
+
"job_id": job_id,
|
|
138
|
+
"original_filename": file.name,
|
|
139
|
+
"file_size_bytes": file_size,
|
|
140
|
+
"sha256": sha256,
|
|
141
|
+
"target_schema": fields.get("target_schema", "public"),
|
|
142
|
+
"target_table": fields.get("target_table"),
|
|
143
|
+
"mode": fields.get("mode", "create_table"),
|
|
144
|
+
"encoding": fields.get("encoding", "utf-8"),
|
|
145
|
+
"quote_char": fields.get("quote_char", '"'),
|
|
146
|
+
"has_header": fields.get("has_header", "true") == "true",
|
|
147
|
+
"sample_row_count": int(fields.get("sample_row_count", "50")),
|
|
148
|
+
}
|
|
149
|
+
for name in ("delimiter", "escape_char"):
|
|
150
|
+
if name in fields:
|
|
151
|
+
payload[name] = fields[name]
|
|
152
|
+
return self._post(
|
|
153
|
+
f"/projects/{project_id}/imports/csv/upload-sessions/{job_id}/complete",
|
|
154
|
+
payload,
|
|
155
|
+
timeout=HEAVY_REQUEST_TIMEOUT,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def _upload_csv_blocks(self, file: Path, upload_url: str, block_size: int) -> str:
|
|
159
|
+
digest = hashlib.sha256()
|
|
160
|
+
block_ids: list[str] = []
|
|
114
161
|
with file.open("rb") as handle:
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
162
|
+
index = 0
|
|
163
|
+
while chunk := handle.read(block_size):
|
|
164
|
+
digest.update(chunk)
|
|
165
|
+
block_id = base64.b64encode(f"{index:08d}".encode()).decode()
|
|
166
|
+
block_ids.append(block_id)
|
|
167
|
+
block_url = f"{upload_url}&comp=block&blockid={quote(block_id, safe='')}"
|
|
168
|
+
self._blob_request(
|
|
169
|
+
"PUT",
|
|
170
|
+
block_url,
|
|
171
|
+
content=chunk,
|
|
172
|
+
headers={
|
|
173
|
+
"Content-MD5": base64.b64encode(
|
|
174
|
+
hashlib.md5(chunk, usedforsecurity=False).digest()
|
|
175
|
+
).decode(),
|
|
176
|
+
"x-ms-version": "2023-11-03",
|
|
177
|
+
},
|
|
178
|
+
)
|
|
179
|
+
index += 1
|
|
180
|
+
if block_ids:
|
|
181
|
+
block_list = "<?xml version=\"1.0\" encoding=\"utf-8\"?><BlockList>" + "".join(
|
|
182
|
+
f"<Latest>{block_id}</Latest>" for block_id in block_ids
|
|
183
|
+
) + "</BlockList>"
|
|
184
|
+
self._blob_request(
|
|
185
|
+
"PUT",
|
|
186
|
+
f"{upload_url}&comp=blocklist",
|
|
187
|
+
content=block_list.encode(),
|
|
188
|
+
headers={"Content-Type": "application/xml", "x-ms-version": "2023-11-03"},
|
|
189
|
+
)
|
|
190
|
+
else:
|
|
191
|
+
self._blob_request(
|
|
192
|
+
"PUT",
|
|
193
|
+
upload_url,
|
|
194
|
+
content=b"",
|
|
195
|
+
headers={"x-ms-blob-type": "BlockBlob", "x-ms-version": "2023-11-03"},
|
|
196
|
+
)
|
|
197
|
+
return digest.hexdigest()
|
|
198
|
+
|
|
199
|
+
def _blob_request(
|
|
200
|
+
self, method: str, url: str, *, content: bytes, headers: dict[str, str]
|
|
201
|
+
) -> None:
|
|
202
|
+
response: httpx.Response | None = None
|
|
203
|
+
for attempt in range(self._max_retries + 1):
|
|
204
|
+
try:
|
|
205
|
+
response = self._client.request(
|
|
206
|
+
method,
|
|
207
|
+
url,
|
|
208
|
+
content=content,
|
|
209
|
+
headers=headers,
|
|
210
|
+
timeout=HEAVY_REQUEST_TIMEOUT,
|
|
211
|
+
)
|
|
212
|
+
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
|
213
|
+
if attempt < self._max_retries:
|
|
214
|
+
_sleep_before_retry(attempt, None)
|
|
215
|
+
continue
|
|
216
|
+
raise CliError(
|
|
217
|
+
"IMPORT_UPLOAD_FAILED",
|
|
218
|
+
"Direct CSV upload is unavailable.",
|
|
219
|
+
exit_code=UNAVAILABLE,
|
|
220
|
+
) from exc
|
|
221
|
+
if response.status_code in RETRY_STATUSES and attempt < self._max_retries:
|
|
222
|
+
_sleep_before_retry(attempt, response.headers.get("Retry-After"))
|
|
223
|
+
continue
|
|
224
|
+
break
|
|
225
|
+
assert response is not None
|
|
226
|
+
if response.is_error:
|
|
227
|
+
raise CliError(
|
|
228
|
+
"IMPORT_UPLOAD_FAILED",
|
|
229
|
+
f"Direct CSV upload failed with HTTP {response.status_code}.",
|
|
230
|
+
exit_code=UNAVAILABLE,
|
|
121
231
|
)
|
|
122
232
|
|
|
123
233
|
def start_csv_import(
|
|
124
|
-
self, project_id: str,
|
|
234
|
+
self, project_id: str, fields: dict[str, Any]
|
|
125
235
|
) -> dict[str, Any]:
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
file.name,
|
|
132
|
-
fields,
|
|
133
|
-
)
|
|
236
|
+
return self._post(
|
|
237
|
+
f"/projects/{project_id}/imports/csv",
|
|
238
|
+
fields,
|
|
239
|
+
timeout=HEAVY_REQUEST_TIMEOUT,
|
|
240
|
+
)
|
|
134
241
|
|
|
135
242
|
def list_imports(self, project_id: str) -> dict[str, Any]:
|
|
136
243
|
return self._get(f"/projects/{project_id}/imports")
|
|
@@ -246,30 +353,12 @@ class CliControlPlaneClient:
|
|
|
246
353
|
def _delete(self, path: str) -> dict[str, Any]:
|
|
247
354
|
return self._request("DELETE", path, retry=False)
|
|
248
355
|
|
|
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
356
|
def _request(
|
|
266
357
|
self,
|
|
267
358
|
method: str,
|
|
268
359
|
path: str,
|
|
269
360
|
*,
|
|
270
361
|
json: dict[str, Any] | None = None,
|
|
271
|
-
data: dict[str, str] | None = None,
|
|
272
|
-
files: dict[str, Any] | None = None,
|
|
273
362
|
auth: bool = True,
|
|
274
363
|
retry: bool = False,
|
|
275
364
|
allow_refresh: bool = True,
|
|
@@ -292,10 +381,10 @@ class CliControlPlaneClient:
|
|
|
292
381
|
try:
|
|
293
382
|
request_kwargs: dict[str, Any] = {
|
|
294
383
|
"headers": headers,
|
|
295
|
-
"json": json,
|
|
296
|
-
"data": data,
|
|
297
|
-
"files": files,
|
|
298
384
|
}
|
|
385
|
+
if json is not None:
|
|
386
|
+
headers["Content-Type"] = "application/json"
|
|
387
|
+
request_kwargs["content"] = jsonlib.dumps(json)
|
|
299
388
|
request_timeout = timeout
|
|
300
389
|
if remaining is not None:
|
|
301
390
|
request_timeout = min(request_timeout or self._timeout, remaining)
|
|
@@ -335,8 +424,6 @@ class CliControlPlaneClient:
|
|
|
335
424
|
method,
|
|
336
425
|
path,
|
|
337
426
|
json=json,
|
|
338
|
-
data=data,
|
|
339
|
-
files=files,
|
|
340
427
|
auth=auth,
|
|
341
428
|
retry=retry,
|
|
342
429
|
allow_refresh=False,
|
|
@@ -392,7 +479,7 @@ def _json_payload(response: httpx.Response) -> dict[str, Any]:
|
|
|
392
479
|
return {}
|
|
393
480
|
try:
|
|
394
481
|
payload = response.json()
|
|
395
|
-
except
|
|
482
|
+
except jsonlib.JSONDecodeError:
|
|
396
483
|
return {}
|
|
397
484
|
return payload if isinstance(payload, dict) else {}
|
|
398
485
|
|
polygres_cli/cli_errors.py
CHANGED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
polygres_cli/__init__.py,sha256=8Rp0AQILGzy9FXAxyB6P9xshB8dKxfYMjY8Iw0W7jlY,44
|
|
2
|
+
polygres_cli/cli.py,sha256=xRQBz8cAujfmTebwxRKNboTjyi_dN7do95BUM4vd5HI,73912
|
|
3
|
+
polygres_cli/cli_auth.py,sha256=2AXXNvBb3P1Nt96KVHwGAnLEo_IV4cfSkRx6tvRsVNU,3070
|
|
4
|
+
polygres_cli/cli_client.py,sha256=xB6zqwOQ57wcmjBcHmap2roxPwyNIB4BCWwYRe1HOJQ,19748
|
|
5
|
+
polygres_cli/cli_config.py,sha256=cLxyXNhY07nrjxTsZt1ZH_L1H-HyRV6li4UIHMduR5o,2985
|
|
6
|
+
polygres_cli/cli_errors.py,sha256=SL-y4pMqFH0Lx-6MkElQymAQYW-xuDlCoCI4qki61ZQ,2349
|
|
7
|
+
polygres_cli/cli_output.py,sha256=8efqKYqKy4Uf1C_Uzkf1zVT-KVtdJ4SkCwvoise9wG0,1680
|
|
8
|
+
polygres_cli/cli_secrets.py,sha256=q_NiebEIHcc09BZxNw6sVyJ86fF3dYkzrhgmbfviz5M,1962
|
|
9
|
+
polygres_cli-0.1.2.dist-info/METADATA,sha256=_dQPfQfXVyH3t8v5up8AgH-BDYj2sDNMw7ykGSlB1Mg,3275
|
|
10
|
+
polygres_cli-0.1.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
11
|
+
polygres_cli-0.1.2.dist-info/entry_points.txt,sha256=j_tqtMTwo6q54nc_7ZAVArSHEbKAIcJXEr6VUptRcHE,51
|
|
12
|
+
polygres_cli-0.1.2.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
13
|
+
polygres_cli-0.1.2.dist-info/RECORD,,
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
polygres_cli/__init__.py,sha256=8Rp0AQILGzy9FXAxyB6P9xshB8dKxfYMjY8Iw0W7jlY,44
|
|
2
|
-
polygres_cli/cli.py,sha256=TlKF8Epnu7d8lU8D4FqDrdQrw9nh-BgSNxyhEbIO1X0,74489
|
|
3
|
-
polygres_cli/cli_auth.py,sha256=2AXXNvBb3P1Nt96KVHwGAnLEo_IV4cfSkRx6tvRsVNU,3070
|
|
4
|
-
polygres_cli/cli_client.py,sha256=vFFShCRcfu1ilQKieA0gl1mPSpJhWwau2OE-8O1GsBk,15591
|
|
5
|
-
polygres_cli/cli_config.py,sha256=cLxyXNhY07nrjxTsZt1ZH_L1H-HyRV6li4UIHMduR5o,2985
|
|
6
|
-
polygres_cli/cli_errors.py,sha256=llCplR7X9blAWHrWOXUntHS3KqCcToJOTSR-U1076Cw,2333
|
|
7
|
-
polygres_cli/cli_output.py,sha256=8efqKYqKy4Uf1C_Uzkf1zVT-KVtdJ4SkCwvoise9wG0,1680
|
|
8
|
-
polygres_cli/cli_secrets.py,sha256=q_NiebEIHcc09BZxNw6sVyJ86fF3dYkzrhgmbfviz5M,1962
|
|
9
|
-
polygres_cli-0.1.0.dist-info/METADATA,sha256=TyUr6DSbSfMrHbp2wzCwM1auVL1AjDA53BtNFPcObk4,3275
|
|
10
|
-
polygres_cli-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
11
|
-
polygres_cli-0.1.0.dist-info/entry_points.txt,sha256=j_tqtMTwo6q54nc_7ZAVArSHEbKAIcJXEr6VUptRcHE,51
|
|
12
|
-
polygres_cli-0.1.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
13
|
-
polygres_cli-0.1.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|