polygres-cli 0.1.1__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_client.py +119 -31
- {polygres_cli-0.1.1.dist-info → polygres_cli-0.1.2.dist-info}/METADATA +1 -1
- {polygres_cli-0.1.1.dist-info → polygres_cli-0.1.2.dist-info}/RECORD +6 -6
- {polygres_cli-0.1.1.dist-info → polygres_cli-0.1.2.dist-info}/WHEEL +0 -0
- {polygres_cli-0.1.1.dist-info → polygres_cli-0.1.2.dist-info}/entry_points.txt +0 -0
- {polygres_cli-0.1.1.dist-info → polygres_cli-0.1.2.dist-info}/licenses/LICENSE +0 -0
polygres_cli/cli_client.py
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
3
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,13 +113,121 @@ 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(
|
|
@@ -243,30 +353,12 @@ class CliControlPlaneClient:
|
|
|
243
353
|
def _delete(self, path: str) -> dict[str, Any]:
|
|
244
354
|
return self._request("DELETE", path, retry=False)
|
|
245
355
|
|
|
246
|
-
def _multipart(
|
|
247
|
-
self,
|
|
248
|
-
method: str,
|
|
249
|
-
path: str,
|
|
250
|
-
file: BinaryIO,
|
|
251
|
-
filename: str,
|
|
252
|
-
fields: dict[str, str],
|
|
253
|
-
) -> dict[str, Any]:
|
|
254
|
-
return self._request(
|
|
255
|
-
method,
|
|
256
|
-
path,
|
|
257
|
-
data=fields,
|
|
258
|
-
files={"file": (filename, file, "text/csv")},
|
|
259
|
-
retry=False,
|
|
260
|
-
)
|
|
261
|
-
|
|
262
356
|
def _request(
|
|
263
357
|
self,
|
|
264
358
|
method: str,
|
|
265
359
|
path: str,
|
|
266
360
|
*,
|
|
267
361
|
json: dict[str, Any] | None = None,
|
|
268
|
-
data: dict[str, str] | None = None,
|
|
269
|
-
files: dict[str, Any] | None = None,
|
|
270
362
|
auth: bool = True,
|
|
271
363
|
retry: bool = False,
|
|
272
364
|
allow_refresh: bool = True,
|
|
@@ -289,8 +381,6 @@ class CliControlPlaneClient:
|
|
|
289
381
|
try:
|
|
290
382
|
request_kwargs: dict[str, Any] = {
|
|
291
383
|
"headers": headers,
|
|
292
|
-
"data": data,
|
|
293
|
-
"files": files,
|
|
294
384
|
}
|
|
295
385
|
if json is not None:
|
|
296
386
|
headers["Content-Type"] = "application/json"
|
|
@@ -334,8 +424,6 @@ class CliControlPlaneClient:
|
|
|
334
424
|
method,
|
|
335
425
|
path,
|
|
336
426
|
json=json,
|
|
337
|
-
data=data,
|
|
338
|
-
files=files,
|
|
339
427
|
auth=auth,
|
|
340
428
|
retry=retry,
|
|
341
429
|
allow_refresh=False,
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
polygres_cli/__init__.py,sha256=8Rp0AQILGzy9FXAxyB6P9xshB8dKxfYMjY8Iw0W7jlY,44
|
|
2
2
|
polygres_cli/cli.py,sha256=xRQBz8cAujfmTebwxRKNboTjyi_dN7do95BUM4vd5HI,73912
|
|
3
3
|
polygres_cli/cli_auth.py,sha256=2AXXNvBb3P1Nt96KVHwGAnLEo_IV4cfSkRx6tvRsVNU,3070
|
|
4
|
-
polygres_cli/cli_client.py,sha256=
|
|
4
|
+
polygres_cli/cli_client.py,sha256=xB6zqwOQ57wcmjBcHmap2roxPwyNIB4BCWwYRe1HOJQ,19748
|
|
5
5
|
polygres_cli/cli_config.py,sha256=cLxyXNhY07nrjxTsZt1ZH_L1H-HyRV6li4UIHMduR5o,2985
|
|
6
6
|
polygres_cli/cli_errors.py,sha256=SL-y4pMqFH0Lx-6MkElQymAQYW-xuDlCoCI4qki61ZQ,2349
|
|
7
7
|
polygres_cli/cli_output.py,sha256=8efqKYqKy4Uf1C_Uzkf1zVT-KVtdJ4SkCwvoise9wG0,1680
|
|
8
8
|
polygres_cli/cli_secrets.py,sha256=q_NiebEIHcc09BZxNw6sVyJ86fF3dYkzrhgmbfviz5M,1962
|
|
9
|
-
polygres_cli-0.1.
|
|
10
|
-
polygres_cli-0.1.
|
|
11
|
-
polygres_cli-0.1.
|
|
12
|
-
polygres_cli-0.1.
|
|
13
|
-
polygres_cli-0.1.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|