polygres-cli 0.1.1__tar.gz → 0.1.2__tar.gz

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,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,6 +1,6 @@
1
1
  [project]
2
2
  name = "polygres-cli"
3
- version = "0.1.1"
3
+ version = "0.1.2"
4
4
  description = "Command-line interface for Polygres"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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,
@@ -31,7 +31,7 @@ def test_installed_cli_staging_whoami_contract(tmp_path: Path) -> None:
31
31
  [executable, "--version"], env=env, text=True, capture_output=True, check=False
32
32
  )
33
33
  assert version.returncode == 0
34
- assert version.stdout.strip() == "polygres 0.1.1"
34
+ assert version.stdout.strip() == "polygres 0.1.2"
35
35
 
36
36
  whoami = subprocess.run(
37
37
  [executable, "--json", "whoami"],
@@ -1,7 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import hashlib
3
4
  import json
4
5
  import os
6
+ import re
5
7
  import stat
6
8
  import subprocess
7
9
  from datetime import datetime, timedelta, timezone
@@ -36,6 +38,46 @@ def _stub(route: object, **kwargs: object) -> object:
36
38
  return getattr(route, "mo" + "ck")(**kwargs)
37
39
 
38
40
 
41
+ def _stub_csv_direct_preview(
42
+ preview: dict[str, object], *, status_code: int = 200
43
+ ) -> tuple[object, object, object, object]:
44
+ session_route = _stub(
45
+ respx.post(f"{API_BASE_URL}/projects/{PROJECT_ID}/imports/csv/upload-sessions"),
46
+ return_value=httpx.Response(
47
+ status_code if status_code != 200 else 200,
48
+ json=(
49
+ preview
50
+ if status_code != 200
51
+ else {
52
+ "upload": {
53
+ "job_id": IMPORT_ID,
54
+ "upload_url": "https://blob.example.test/file.csv?sig=secret",
55
+ "block_size_bytes": 8 * 1024 * 1024,
56
+ }
57
+ }
58
+ ),
59
+ ),
60
+ )
61
+ block_route = _stub(
62
+ respx.put(
63
+ re.compile(r"https://blob\.example\.test/file\.csv\?.*comp=block&blockid=.*")
64
+ ),
65
+ return_value=httpx.Response(201),
66
+ )
67
+ commit_route = _stub(
68
+ respx.put(re.compile(r"https://blob\.example\.test/file\.csv\?.*comp=blocklist.*")),
69
+ return_value=httpx.Response(201),
70
+ )
71
+ complete_route = _stub(
72
+ respx.post(
73
+ f"{API_BASE_URL}/projects/{PROJECT_ID}/imports/csv/upload-sessions/"
74
+ f"{IMPORT_ID}/complete"
75
+ ),
76
+ return_value=httpx.Response(200, json=preview),
77
+ )
78
+ return session_route, block_route, commit_route, complete_route
79
+
80
+
39
81
  def run_cli(
40
82
  args: list[str],
41
83
  capsys: pytest.CaptureFixture[str],
@@ -93,7 +135,7 @@ def test_version_and_config_path_json(
93
135
  ) -> None:
94
136
  rc, out, _ = run_cli(["--version"], capsys, monkeypatch, tmp_path)
95
137
  assert rc == 0
96
- assert out.strip() == "polygres 0.1.1"
138
+ assert out.strip() == "polygres 0.1.2"
97
139
 
98
140
  rc, out, _ = run_cli(["--json", "config", "path"], capsys, monkeypatch, tmp_path)
99
141
  assert rc == 0
@@ -430,7 +472,7 @@ def test_projects_list_uses_env_token_and_selected_project_json(
430
472
  assert err == ""
431
473
  assert route.called
432
474
  assert route.calls[0].request.headers["Authorization"] == f"Bearer {ACCESS_TOKEN}"
433
- assert route.calls[0].request.headers["User-Agent"] == "polygres-cli/0.1.1"
475
+ assert route.calls[0].request.headers["User-Agent"] == "polygres-cli/0.1.2"
434
476
  assert json.loads(out) == {
435
477
  "projects": [{"id": PROJECT_ID, "name": "Support", "status": "ready"}],
436
478
  "selected_project_id": PROJECT_ID,
@@ -1066,18 +1108,14 @@ def test_import_csv_sends_sample_row_count_only_to_preview(
1066
1108
  write_config(tmp_path, {"version": 1, "selected_project_id": PROJECT_ID})
1067
1109
  csv_path = tmp_path / "documents.csv"
1068
1110
  csv_path.write_text("id,title\n1,Hello\n", encoding="utf-8")
1069
- preview_route = _stub(
1070
- respx.post(f"{API_BASE_URL}/projects/{PROJECT_ID}/imports/csv/preview"),
1071
- return_value=httpx.Response(
1072
- 200,
1073
- json={
1074
- "request_id": "req_preview",
1075
- "preview": {
1076
- "job_id": IMPORT_ID,
1077
- "columns": [{"name": "id"}, {"name": "title"}],
1078
- },
1111
+ session_route, block_route, commit_route, preview_route = _stub_csv_direct_preview(
1112
+ {
1113
+ "request_id": "req_preview",
1114
+ "preview": {
1115
+ "job_id": IMPORT_ID,
1116
+ "columns": [{"name": "id"}, {"name": "title"}],
1079
1117
  },
1080
- ),
1118
+ }
1081
1119
  )
1082
1120
  import_route = _stub(
1083
1121
  respx.post(f"{API_BASE_URL}/projects/{PROJECT_ID}/imports/csv"),
@@ -1110,9 +1148,15 @@ def test_import_csv_sends_sample_row_count_only_to_preview(
1110
1148
  assert err == ""
1111
1149
  assert preview_route.called
1112
1150
  assert import_route.called
1113
- preview_body = preview_route.calls[0].request.content
1151
+ complete_payload = json.loads(preview_route.calls[0].request.content)
1114
1152
  import_payload = json.loads(import_route.calls[0].request.content)
1115
- assert b'name="sample_row_count"' in preview_body
1153
+ assert session_route.called
1154
+ assert block_route.called
1155
+ assert commit_route.called
1156
+ assert complete_payload["sample_row_count"] == 50
1157
+ assert complete_payload["file_size_bytes"] == csv_path.stat().st_size
1158
+ assert complete_payload["sha256"] == hashlib.sha256(csv_path.read_bytes()).hexdigest()
1159
+ assert csv_path.read_bytes() not in preview_route.calls[0].request.content
1116
1160
  assert import_route.calls[0].request.headers["content-type"] == "application/json"
1117
1161
  assert import_payload["job_id"] == IMPORT_ID
1118
1162
  assert [column["name"] for column in import_payload["columns"]] == ["id", "title"]
@@ -1129,19 +1173,16 @@ def test_import_csv_preserves_tier_limit_error_and_skips_final_submission(
1129
1173
  write_config(tmp_path, {"version": 1, "selected_project_id": PROJECT_ID})
1130
1174
  csv_path = tmp_path / "documents.csv"
1131
1175
  csv_path.write_text("id,title\n1,Hello\n", encoding="utf-8")
1132
- preview_route = _stub(
1133
- respx.post(f"{API_BASE_URL}/projects/{PROJECT_ID}/imports/csv/preview"),
1134
- return_value=httpx.Response(
1135
- 413,
1136
- json={
1137
- "request_id": "req_import_limit",
1138
- "error": {
1139
- "code": "IMPORT_LIMIT_EXCEEDED",
1140
- "message": "File exceeds the project tier storage limit.",
1141
- "details": {"limit_bytes": 8, "source": "storage_bytes"},
1142
- },
1176
+ preview_route, _, _, _ = _stub_csv_direct_preview(
1177
+ {
1178
+ "request_id": "req_import_limit",
1179
+ "error": {
1180
+ "code": "IMPORT_LIMIT_EXCEEDED",
1181
+ "message": "File exceeds the project tier storage limit.",
1182
+ "details": {"limit_bytes": 8, "source": "storage_bytes"},
1143
1183
  },
1144
- ),
1184
+ },
1185
+ status_code=413,
1145
1186
  )
1146
1187
  import_route = _stub(respx.post(f"{API_BASE_URL}/projects/{PROJECT_ID}/imports/csv"))
1147
1188
 
@@ -1605,16 +1646,12 @@ def test_csv_import_uses_preview_columns_and_wait_polling(
1605
1646
  csv_path = tmp_path / "documents.csv"
1606
1647
  csv_path.write_text("id,title\n1,Hello\n", encoding="utf-8")
1607
1648
  columns = [{"name": "id", "type": "text", "nullable": False}]
1608
- preview_route = _stub(
1609
- respx.post(f"{API_BASE_URL}/projects/{PROJECT_ID}/imports/csv/preview"),
1610
- return_value=httpx.Response(
1611
- 200,
1612
- json={
1613
- "request_id": "req_preview",
1614
- "preview": {"job_id": IMPORT_ID, "columns": columns},
1615
- },
1616
- ),
1617
- )
1649
+ preview_route = _stub_csv_direct_preview(
1650
+ {
1651
+ "request_id": "req_preview",
1652
+ "preview": {"job_id": IMPORT_ID, "columns": columns},
1653
+ }
1654
+ )[3]
1618
1655
  import_route = _stub(
1619
1656
  respx.post(f"{API_BASE_URL}/projects/{PROJECT_ID}/imports/csv"),
1620
1657
  return_value=httpx.Response(
@@ -1846,7 +1883,7 @@ def test_login_browser_fallback_polls_stores_tokens_and_redacts_output(
1846
1883
  assert ACCESS_TOKEN not in out
1847
1884
  assert REFRESH_TOKEN not in out
1848
1885
  assert json.loads(start_route.calls[0].request.content) == {
1849
- "client": {"name": "polygres-cli", "version": "0.1.1"}
1886
+ "client": {"name": "polygres-cli", "version": "0.1.2"}
1850
1887
  }
1851
1888
  assert json.loads(poll_route.calls[0].request.content) == {
1852
1889
  "login_session_id": "cls_abcdefghijklmnopqrstuvwxyz",
@@ -1914,22 +1951,18 @@ def test_csv_import_propagates_preview_effective_parser_settings(
1914
1951
  path = tmp_path / "documents.csv"
1915
1952
  path.write_text("id|title\n1|Hello\n", encoding="utf-8")
1916
1953
  columns = [{"name": "id", "type": "text", "nullable": False}]
1917
- _stub(
1918
- respx.post(f"{API_BASE_URL}/projects/{PROJECT_ID}/imports/csv/preview"),
1919
- return_value=httpx.Response(
1920
- 200,
1921
- json={
1922
- "preview": {
1923
- "job_id": IMPORT_ID,
1924
- "encoding": "utf-8-sig",
1925
- "delimiter": "|",
1926
- "quote_char": "'",
1927
- "escape_char": "\\",
1928
- "has_header": False,
1929
- "columns": columns,
1930
- }
1931
- },
1932
- ),
1954
+ _stub_csv_direct_preview(
1955
+ {
1956
+ "preview": {
1957
+ "job_id": IMPORT_ID,
1958
+ "encoding": "utf-8-sig",
1959
+ "delimiter": "|",
1960
+ "quote_char": "'",
1961
+ "escape_char": "\\",
1962
+ "has_header": False,
1963
+ "columns": columns,
1964
+ }
1965
+ }
1933
1966
  )
1934
1967
  import_route = _stub(
1935
1968
  respx.post(f"{API_BASE_URL}/projects/{PROJECT_ID}/imports/csv"),
@@ -2084,11 +2117,8 @@ def test_human_import_polling_writes_progress_only_to_stderr(
2084
2117
  path = tmp_path / "documents.csv"
2085
2118
  path.write_text("id\n1\n", encoding="utf-8")
2086
2119
  monkeypatch.setattr(cli.time, "sleep", lambda _seconds: None)
2087
- _stub(
2088
- respx.post(f"{API_BASE_URL}/projects/{PROJECT_ID}/imports/csv/preview"),
2089
- return_value=httpx.Response(
2090
- 200, json={"preview": {"job_id": IMPORT_ID, "columns": [{"name": "id"}]}}
2091
- ),
2120
+ _stub_csv_direct_preview(
2121
+ {"preview": {"job_id": IMPORT_ID, "columns": [{"name": "id"}]}}
2092
2122
  )
2093
2123
  _stub(
2094
2124
  respx.post(f"{API_BASE_URL}/projects/{PROJECT_ID}/imports/csv"),
File without changes
File without changes
File without changes
File without changes