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 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": json.dumps(columns)})
1050
- started = ctx.client.start_csv_import(project_id, file_path, import_fields)
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")
@@ -1,19 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
- import json
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, 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.0"
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
- 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(
124
- self, project_id: str, file: Path, fields: dict[str, str]
234
+ self, project_id: str, fields: dict[str, Any]
125
235
  ) -> 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
- )
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 json.JSONDecodeError:
482
+ except jsonlib.JSONDecodeError:
396
483
  return {}
397
484
  return payload if isinstance(payload, dict) else {}
398
485
 
@@ -16,6 +16,7 @@ LOCAL_DEPENDENCY = 9
16
16
 
17
17
  HTTP_EXIT_CODES = {
18
18
  400: USAGE,
19
+ 413: USAGE,
19
20
  401: AUTH,
20
21
  403: PERMISSION,
21
22
  404: NOT_FOUND,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: polygres-cli
3
- Version: 0.1.0
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
@@ -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,,