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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "polygres-cli"
3
- version = "0.1.0"
3
+ version = "0.1.2"
4
4
  description = "Command-line interface for Polygres"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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,
@@ -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.0"
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 UTC, datetime, timedelta
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.0"
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.0"
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,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
- preview_body = preview_route.calls[0].request.content
1114
- import_body = import_route.calls[0].request.content
1115
- assert b'name="sample_row_count"' in preview_body
1116
- assert b'name="sample_row_count"' not in import_body
1117
- assert b'name="job_id"' in import_body
1118
- assert b'name="columns"' in import_body
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 = _stub(
1566
- respx.post(f"{API_BASE_URL}/projects/{PROJECT_ID}/imports/csv/preview"),
1567
- return_value=httpx.Response(
1568
- 200,
1569
- json={
1570
- "request_id": "req_preview",
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.0"}
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
- _stub(
1875
- respx.post(f"{API_BASE_URL}/projects/{PROJECT_ID}/imports/csv/preview"),
1876
- return_value=httpx.Response(
1877
- 200,
1878
- json={
1879
- "preview": {
1880
- "job_id": IMPORT_ID,
1881
- "encoding": "utf-8-sig",
1882
- "delimiter": "|",
1883
- "quote_char": "'",
1884
- "escape_char": "\\",
1885
- "has_header": False,
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
- _stub(
2045
- respx.post(f"{API_BASE_URL}/projects/{PROJECT_ID}/imports/csv/preview"),
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(UTC) + timedelta(minutes=5)).isoformat().replace("+00:00", "Z")
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=UTC)
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