polygres-cli 0.1.0__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.
- {polygres_cli-0.1.0 → polygres_cli-0.1.2}/PKG-INFO +1 -1
- {polygres_cli-0.1.0 → polygres_cli-0.1.2}/pyproject.toml +1 -1
- {polygres_cli-0.1.0 → polygres_cli-0.1.2}/src/polygres_cli/cli.py +4 -20
- {polygres_cli-0.1.0 → polygres_cli-0.1.2}/src/polygres_cli/cli_client.py +130 -43
- {polygres_cli-0.1.0 → polygres_cli-0.1.2}/src/polygres_cli/cli_errors.py +1 -0
- {polygres_cli-0.1.0 → polygres_cli-0.1.2}/tests/heavy/test_cli_installed_staging.py +1 -1
- {polygres_cli-0.1.0 → polygres_cli-0.1.2}/tests/test_cli.py +129 -54
- {polygres_cli-0.1.0 → polygres_cli-0.1.2}/.github/workflows/ci.yml +0 -0
- {polygres_cli-0.1.0 → polygres_cli-0.1.2}/.github/workflows/publish-python-cli.yml +0 -0
- {polygres_cli-0.1.0 → polygres_cli-0.1.2}/.gitignore +0 -0
- {polygres_cli-0.1.0 → polygres_cli-0.1.2}/CONTRIBUTING.md +0 -0
- {polygres_cli-0.1.0 → polygres_cli-0.1.2}/LICENSE +0 -0
- {polygres_cli-0.1.0 → polygres_cli-0.1.2}/README.md +0 -0
- {polygres_cli-0.1.0 → polygres_cli-0.1.2}/SECURITY.md +0 -0
- {polygres_cli-0.1.0 → polygres_cli-0.1.2}/src/polygres_cli/__init__.py +0 -0
- {polygres_cli-0.1.0 → polygres_cli-0.1.2}/src/polygres_cli/cli_auth.py +0 -0
- {polygres_cli-0.1.0 → polygres_cli-0.1.2}/src/polygres_cli/cli_config.py +0 -0
- {polygres_cli-0.1.0 → polygres_cli-0.1.2}/src/polygres_cli/cli_output.py +0 -0
- {polygres_cli-0.1.0 → polygres_cli-0.1.2}/src/polygres_cli/cli_secrets.py +0 -0
|
@@ -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")
|
|
@@ -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
|
|
|
@@ -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.
|
|
34
|
+
assert version.stdout.strip() == "polygres 0.1.2"
|
|
35
35
|
|
|
36
36
|
whoami = subprocess.run(
|
|
37
37
|
[executable, "--json", "whoami"],
|
|
@@ -1,10 +1,12 @@
|
|
|
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
|
-
from datetime import
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
8
10
|
from pathlib import Path
|
|
9
11
|
|
|
10
12
|
import httpx
|
|
@@ -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.
|
|
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.
|
|
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 =
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
"
|
|
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,15 +1148,61 @@ 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
|
-
|
|
1114
|
-
|
|
1115
|
-
assert
|
|
1116
|
-
assert
|
|
1117
|
-
assert
|
|
1118
|
-
assert
|
|
1151
|
+
complete_payload = json.loads(preview_route.calls[0].request.content)
|
|
1152
|
+
import_payload = json.loads(import_route.calls[0].request.content)
|
|
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
|
|
1160
|
+
assert import_route.calls[0].request.headers["content-type"] == "application/json"
|
|
1161
|
+
assert import_payload["job_id"] == IMPORT_ID
|
|
1162
|
+
assert [column["name"] for column in import_payload["columns"]] == ["id", "title"]
|
|
1163
|
+
assert str(csv_path).encode() not in import_route.calls[0].request.content
|
|
1119
1164
|
assert json.loads(out)["import"]["status"] == "succeeded"
|
|
1120
1165
|
|
|
1121
1166
|
|
|
1167
|
+
@ROUTE_CTX
|
|
1168
|
+
def test_import_csv_preserves_tier_limit_error_and_skips_final_submission(
|
|
1169
|
+
capsys: pytest.CaptureFixture[str],
|
|
1170
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1171
|
+
tmp_path: Path,
|
|
1172
|
+
) -> None:
|
|
1173
|
+
write_config(tmp_path, {"version": 1, "selected_project_id": PROJECT_ID})
|
|
1174
|
+
csv_path = tmp_path / "documents.csv"
|
|
1175
|
+
csv_path.write_text("id,title\n1,Hello\n", encoding="utf-8")
|
|
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"},
|
|
1183
|
+
},
|
|
1184
|
+
},
|
|
1185
|
+
status_code=413,
|
|
1186
|
+
)
|
|
1187
|
+
import_route = _stub(respx.post(f"{API_BASE_URL}/projects/{PROJECT_ID}/imports/csv"))
|
|
1188
|
+
|
|
1189
|
+
rc, out, err = run_cli(
|
|
1190
|
+
["--json", "import", "csv", str(csv_path), "--table", "documents"],
|
|
1191
|
+
capsys,
|
|
1192
|
+
monkeypatch,
|
|
1193
|
+
tmp_path,
|
|
1194
|
+
)
|
|
1195
|
+
|
|
1196
|
+
assert rc == 2
|
|
1197
|
+
assert err == ""
|
|
1198
|
+
payload = json.loads(out)
|
|
1199
|
+
assert payload["error"]["code"] == "IMPORT_LIMIT_EXCEEDED"
|
|
1200
|
+
assert payload["error"]["details"] == {"limit_bytes": 8, "source": "storage_bytes"}
|
|
1201
|
+
assert payload["request_id"] == "req_import_limit"
|
|
1202
|
+
assert preview_route.call_count == 1
|
|
1203
|
+
assert import_route.call_count == 0
|
|
1204
|
+
|
|
1205
|
+
|
|
1122
1206
|
@ROUTE_CTX
|
|
1123
1207
|
def test_import_status_without_jobs_returns_successful_empty_result(
|
|
1124
1208
|
capsys: pytest.CaptureFixture[str],
|
|
@@ -1562,16 +1646,12 @@ def test_csv_import_uses_preview_columns_and_wait_polling(
|
|
|
1562
1646
|
csv_path = tmp_path / "documents.csv"
|
|
1563
1647
|
csv_path.write_text("id,title\n1,Hello\n", encoding="utf-8")
|
|
1564
1648
|
columns = [{"name": "id", "type": "text", "nullable": False}]
|
|
1565
|
-
preview_route =
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
"preview": {"job_id": IMPORT_ID, "columns": columns},
|
|
1572
|
-
},
|
|
1573
|
-
),
|
|
1574
|
-
)
|
|
1649
|
+
preview_route = _stub_csv_direct_preview(
|
|
1650
|
+
{
|
|
1651
|
+
"request_id": "req_preview",
|
|
1652
|
+
"preview": {"job_id": IMPORT_ID, "columns": columns},
|
|
1653
|
+
}
|
|
1654
|
+
)[3]
|
|
1575
1655
|
import_route = _stub(
|
|
1576
1656
|
respx.post(f"{API_BASE_URL}/projects/{PROJECT_ID}/imports/csv"),
|
|
1577
1657
|
return_value=httpx.Response(
|
|
@@ -1803,7 +1883,7 @@ def test_login_browser_fallback_polls_stores_tokens_and_redacts_output(
|
|
|
1803
1883
|
assert ACCESS_TOKEN not in out
|
|
1804
1884
|
assert REFRESH_TOKEN not in out
|
|
1805
1885
|
assert json.loads(start_route.calls[0].request.content) == {
|
|
1806
|
-
"client": {"name": "polygres-cli", "version": "0.1.
|
|
1886
|
+
"client": {"name": "polygres-cli", "version": "0.1.2"}
|
|
1807
1887
|
}
|
|
1808
1888
|
assert json.loads(poll_route.calls[0].request.content) == {
|
|
1809
1889
|
"login_session_id": "cls_abcdefghijklmnopqrstuvwxyz",
|
|
@@ -1871,22 +1951,18 @@ def test_csv_import_propagates_preview_effective_parser_settings(
|
|
|
1871
1951
|
path = tmp_path / "documents.csv"
|
|
1872
1952
|
path.write_text("id|title\n1|Hello\n", encoding="utf-8")
|
|
1873
1953
|
columns = [{"name": "id", "type": "text", "nullable": False}]
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
"
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
"columns": columns,
|
|
1887
|
-
}
|
|
1888
|
-
},
|
|
1889
|
-
),
|
|
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
|
+
}
|
|
1890
1966
|
)
|
|
1891
1967
|
import_route = _stub(
|
|
1892
1968
|
respx.post(f"{API_BASE_URL}/projects/{PROJECT_ID}/imports/csv"),
|
|
@@ -2041,11 +2117,8 @@ def test_human_import_polling_writes_progress_only_to_stderr(
|
|
|
2041
2117
|
path = tmp_path / "documents.csv"
|
|
2042
2118
|
path.write_text("id\n1\n", encoding="utf-8")
|
|
2043
2119
|
monkeypatch.setattr(cli.time, "sleep", lambda _seconds: None)
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
return_value=httpx.Response(
|
|
2047
|
-
200, json={"preview": {"job_id": IMPORT_ID, "columns": [{"name": "id"}]}}
|
|
2048
|
-
),
|
|
2120
|
+
_stub_csv_direct_preview(
|
|
2121
|
+
{"preview": {"job_id": IMPORT_ID, "columns": [{"name": "id"}]}}
|
|
2049
2122
|
)
|
|
2050
2123
|
_stub(
|
|
2051
2124
|
respx.post(f"{API_BASE_URL}/projects/{PROJECT_ID}/imports/csv"),
|
|
@@ -2590,7 +2663,9 @@ def test_quiet_login_prints_essential_fallback_to_stderr_when_browser_fails(
|
|
|
2590
2663
|
monkeypatch: pytest.MonkeyPatch,
|
|
2591
2664
|
tmp_path: Path,
|
|
2592
2665
|
) -> None:
|
|
2593
|
-
expires = (datetime.now(
|
|
2666
|
+
expires = (datetime.now(timezone.utc) + timedelta(minutes=5)).isoformat().replace(
|
|
2667
|
+
"+00:00", "Z"
|
|
2668
|
+
)
|
|
2594
2669
|
_stub(
|
|
2595
2670
|
respx.post(f"{API_BASE_URL}/cli/auth/start"),
|
|
2596
2671
|
return_value=httpx.Response(
|
|
@@ -2688,7 +2763,7 @@ def test_config_save_fails_closed_when_owner_only_permissions_cannot_be_set(
|
|
|
2688
2763
|
def test_retry_after_http_date_is_parsed_as_non_negative_seconds(
|
|
2689
2764
|
monkeypatch: pytest.MonkeyPatch,
|
|
2690
2765
|
) -> None:
|
|
2691
|
-
now = datetime(2026, 7, 9, 12, 0, tzinfo=
|
|
2766
|
+
now = datetime(2026, 7, 9, 12, 0, tzinfo=timezone.utc)
|
|
2692
2767
|
monkeypatch.setattr(cli_client.time, "time", lambda: now.timestamp())
|
|
2693
2768
|
|
|
2694
2769
|
delay = cli_client._retry_after_seconds("Thu, 09 Jul 2026 12:00:05 GMT")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|