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.
@@ -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, BinaryIO
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.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
- return self._multipart(
116
- "POST",
117
- f"/projects/{project_id}/imports/csv/preview",
118
- handle,
119
- file.name,
120
- fields,
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: polygres-cli
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Command-line interface for Polygres
5
5
  Project-URL: Homepage, https://polygres.com
6
6
  Project-URL: Documentation, https://docs.evokoa.com/polygres
@@ -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=kOAIqln4yRxIiT7CiIWjF_Q92WyYaoYZux0AkjHh4rM,15636
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.1.dist-info/METADATA,sha256=jN4RvBenDzdQ8yqvKZqCSJYNBZZYiplesEBRv5JU2mI,3275
10
- polygres_cli-0.1.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
11
- polygres_cli-0.1.1.dist-info/entry_points.txt,sha256=j_tqtMTwo6q54nc_7ZAVArSHEbKAIcJXEr6VUptRcHE,51
12
- polygres_cli-0.1.1.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
13
- polygres_cli-0.1.1.dist-info/RECORD,,
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,,