snowflake-sql-api 0.1.3__tar.gz → 0.1.4__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.
Files changed (46) hide show
  1. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/PKG-INFO +20 -1
  2. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/README.md +19 -0
  3. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/__init__.py +6 -0
  4. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/_version.py +2 -2
  5. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/aclient.py +48 -0
  6. snowflake_sql_api-0.1.4/snowflake_sql_api/batches.py +298 -0
  7. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/client.py +64 -4
  8. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/exceptions.py +76 -4
  9. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/transport.py +120 -48
  10. snowflake_sql_api-0.1.4/tests/test_batch_writes.py +1023 -0
  11. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/.gitignore +0 -0
  12. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/CONTRIBUTING.md +0 -0
  13. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/LICENSE +0 -0
  14. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/pyproject.toml +0 -0
  15. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/auth.py +0 -0
  16. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/bindings.py +0 -0
  17. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/cli.py +0 -0
  18. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/escaping.py +0 -0
  19. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/exports.py +0 -0
  20. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/pagination.py +0 -0
  21. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/py.typed +0 -0
  22. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/retry.py +0 -0
  23. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/row_mapping.py +0 -0
  24. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/testing.py +0 -0
  25. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/types.py +0 -0
  26. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/__init__.py +0 -0
  27. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/conftest.py +0 -0
  28. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/smoke/__init__.py +0 -0
  29. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/smoke/test_smoke.py +0 -0
  30. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/streaming_support.py +0 -0
  31. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/support.py +0 -0
  32. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/test_aclient.py +0 -0
  33. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/test_auth.py +0 -0
  34. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/test_bindings.py +0 -0
  35. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/test_cli.py +0 -0
  36. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/test_client.py +0 -0
  37. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/test_escaping.py +0 -0
  38. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/test_exports.py +0 -0
  39. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/test_import_footprint.py +0 -0
  40. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/test_package.py +0 -0
  41. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/test_regression.py +0 -0
  42. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/test_retry.py +0 -0
  43. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/test_streaming.py +0 -0
  44. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/test_testing.py +0 -0
  45. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/test_transport.py +0 -0
  46. {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: snowflake-sql-api
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Lightweight, pure-Python client for Snowflake's SQL API v2
5
5
  Project-URL: Homepage, https://github.com/hampsterx/snowflake-sql-api
6
6
  Project-URL: Repository, https://github.com/hampsterx/snowflake-sql-api
@@ -155,6 +155,25 @@ with the largest decoded partition plus the application batch. See
155
155
  [streaming results and exports](docs/streaming.md) for row streams, async usage,
156
156
  serialization, and partial-output behavior.
157
157
 
158
+ Insert a record stream in bounded requests:
159
+
160
+ ```python
161
+ with SnowflakeClient.from_env() as client:
162
+ progress = client.insert_batches(
163
+ "events", ["id", "label"], ((i, f"event {i}") for i in range(2500)),
164
+ max_rows=1000, max_bytes=1_000_000,
165
+ )
166
+ print(progress.input_rows, progress.rows_affected)
167
+ ```
168
+
169
+ `insert_many()` sends one statement; `insert_batches()` sends sequential statements
170
+ and can partially complete. Its byte budget covers the exact JSON body. On failure,
171
+ `BatchWriteError` reports confirmed progress, consumed input and the failed request's
172
+ identity. Reconstruct replayable input and reconcile unknown outcomes before retrying;
173
+ do not resume with the same advanced iterator. The async equivalent is awaited and
174
+ accepts the same synchronous iterable, whose production can block the event loop.
175
+ See [batch writes](docs/batch-writes.md) for limits and recovery semantics.
176
+
158
177
  ## Prior art and alternatives
159
178
 
160
179
  `snowflake-sql-api` is not the first REST-based Snowflake client. If it does not
@@ -91,6 +91,25 @@ with the largest decoded partition plus the application batch. See
91
91
  [streaming results and exports](docs/streaming.md) for row streams, async usage,
92
92
  serialization, and partial-output behavior.
93
93
 
94
+ Insert a record stream in bounded requests:
95
+
96
+ ```python
97
+ with SnowflakeClient.from_env() as client:
98
+ progress = client.insert_batches(
99
+ "events", ["id", "label"], ((i, f"event {i}") for i in range(2500)),
100
+ max_rows=1000, max_bytes=1_000_000,
101
+ )
102
+ print(progress.input_rows, progress.rows_affected)
103
+ ```
104
+
105
+ `insert_many()` sends one statement; `insert_batches()` sends sequential statements
106
+ and can partially complete. Its byte budget covers the exact JSON body. On failure,
107
+ `BatchWriteError` reports confirmed progress, consumed input and the failed request's
108
+ identity. Reconstruct replayable input and reconcile unknown outcomes before retrying;
109
+ do not resume with the same advanced iterator. The async equivalent is awaited and
110
+ accepts the same synchronous iterable, whose production can block the event loop.
111
+ See [batch writes](docs/batch-writes.md) for limits and recovery semantics.
112
+
94
113
  ## Prior art and alternatives
95
114
 
96
115
  `snowflake-sql-api` is not the first REST-based Snowflake client. If it does not
@@ -27,8 +27,10 @@ except ImportError: # pragma: no cover - plain source checkout, never built
27
27
  __version__ = "0.0.0.dev0"
28
28
 
29
29
  from .aclient import AsyncQueryHandle, AsyncSnowflakeClient
30
+ from .batches import BatchSummary, BatchWriteResult, FailedBatch
30
31
  from .client import QueryHandle, SnowflakeClient
31
32
  from .exceptions import (
33
+ BatchWriteError,
32
34
  ResultNotReady,
33
35
  SnowflakeAuthError,
34
36
  SnowflakeConfigError,
@@ -48,6 +50,10 @@ __all__ = [
48
50
  "QueryHandle",
49
51
  "AsyncQueryHandle",
50
52
  "RetryPolicy",
53
+ "BatchSummary",
54
+ "BatchWriteResult",
55
+ "FailedBatch",
56
+ "BatchWriteError",
51
57
  "SnowflakeError",
52
58
  "SnowflakeConfigError",
53
59
  "SnowflakeAuthError",
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.1.3'
22
- __version_tuple__ = version_tuple = (0, 1, 3)
21
+ __version__ = version = '0.1.4'
22
+ __version_tuple__ = version_tuple = (0, 1, 4)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -17,6 +17,7 @@ from typing import (
17
17
  AsyncGenerator,
18
18
  Callable,
19
19
  Dict,
20
+ Iterable,
20
21
  List,
21
22
  Optional,
22
23
  Sequence,
@@ -27,6 +28,7 @@ from typing import (
27
28
  import httpx
28
29
 
29
30
  from .auth import KeypairAuthenticator, account_hostname
31
+ from .batches import BatchWriteResult, _BatchExecution, _BatchPlanner
30
32
  from .bindings import to_bindings
31
33
  from .client import DEFAULT_POLL_TIMEOUT, _columns_from, _rows_affected
32
34
  from .escaping import quote_identifier, quote_name
@@ -388,6 +390,52 @@ class AsyncSnowflakeClient:
388
390
 
389
391
  # -- async submit -----------------------------------------------------
390
392
 
393
+ async def insert_batches(
394
+ self,
395
+ table: str,
396
+ columns: Sequence[str],
397
+ rows: Iterable[Sequence[Any]],
398
+ *,
399
+ max_rows: int = 1000,
400
+ max_bytes: int = 1_000_000,
401
+ ) -> BatchWriteResult:
402
+ """Insert a single-pass source sequentially within row and wire-byte budgets.
403
+
404
+ Independent statements can commit before a later failure. BatchWriteError
405
+ reports confirmed progress and uncertain outcomes; see docs/batch-writes.md.
406
+ Input is an ordinary iterable, consumed on the calling thread.
407
+ """
408
+ planner = _BatchPlanner(
409
+ table, columns, rows, max_rows, max_bytes, self._build_payload
410
+ )
411
+ while (batch := planner.next_batch()) is not None:
412
+ try:
413
+ self._notify(batch.sql, batch.params)
414
+ except Exception as exc:
415
+ raise planner.error(exc, "validation") from exc
416
+ execution = _BatchExecution(planner, batch)
417
+ try:
418
+ response = await self._transport.submit(
419
+ {}, request_id=execution.failed.request_id, content=batch.content
420
+ )
421
+ execution.received(
422
+ response.statement_handle,
423
+ response.submission_attempts,
424
+ response.is_running,
425
+ )
426
+ if response.is_running:
427
+ handle = response.statement_handle
428
+ if handle is None:
429
+ raise SnowflakeRequestError("running statement has no handle")
430
+ response = await self._poll(handle, self._statement_timeout)
431
+ execution.succeeded = True
432
+ affected = _rows_affected(response.body, strict=True)
433
+ except Exception as exc:
434
+ raise execution.error(exc) from exc
435
+ planner.confirm(batch, affected)
436
+ del batch
437
+ return planner.result()
438
+
391
439
  async def submit(self, sql: str, params: Params = None) -> AsyncQueryHandle:
392
440
  """Submit a long-running statement and return an :class:`AsyncQueryHandle`."""
393
441
  response = await self._run(sql, params, async_exec=True)
@@ -0,0 +1,298 @@
1
+ """Bounded INSERT planning and immutable progress shared by both clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import uuid
7
+ from collections.abc import Iterable, Iterator, Sequence
8
+ from dataclasses import dataclass, replace
9
+ from typing import Any, Callable, Literal
10
+
11
+ from .bindings import to_bindings
12
+ from .escaping import quote_identifier, quote_name
13
+ from .exceptions import (
14
+ BatchWriteError,
15
+ SnowflakeAuthError,
16
+ SnowflakeConfigError,
17
+ SnowflakeProgrammingError,
18
+ SnowflakeRequestError,
19
+ )
20
+
21
+ __all__ = ["BatchSummary", "BatchWriteResult", "FailedBatch"]
22
+
23
+ BatchPhase = Literal["input", "validation", "submission", "execution"]
24
+ BatchOutcome = Literal["not_executed", "failed", "unknown", "succeeded"]
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class BatchSummary:
29
+ """One confirmed INSERT, with zero-based batch and source offsets."""
30
+
31
+ index: int
32
+ start_row_index: int
33
+ row_count: int
34
+ rows_affected: int
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class BatchWriteResult:
39
+ """Confirmed progress only; never retains input records or request bodies."""
40
+
41
+ batches: tuple[BatchSummary, ...] = ()
42
+
43
+ @property
44
+ def input_rows(self) -> int:
45
+ return sum(batch.row_count for batch in self.batches)
46
+
47
+ @property
48
+ def rows_affected(self) -> int:
49
+ return sum(batch.rows_affected for batch in self.batches)
50
+
51
+ @property
52
+ def completed_batches(self) -> int:
53
+ return len(self.batches)
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class FailedBatch:
58
+ """Prepared INSERT identity; an unknown outcome requires reconciliation."""
59
+
60
+ index: int
61
+ start_row_index: int
62
+ row_count: int
63
+ request_id: str
64
+ statement_handle: str | None = None
65
+ submission_attempts: int = 0
66
+
67
+
68
+ def _encode(value: Any) -> bytes:
69
+ return json.dumps(
70
+ value, ensure_ascii=False, separators=(",", ":"), allow_nan=False
71
+ ).encode("utf-8")
72
+
73
+
74
+ @dataclass
75
+ class _PreparedBatch:
76
+ sql: str
77
+ params: list[Any]
78
+ content: bytes
79
+ row_count: int
80
+
81
+
82
+ class _BatchPlanner:
83
+ def __init__(
84
+ self,
85
+ table: str,
86
+ columns: Sequence[str],
87
+ rows: Iterable[Sequence[Any]],
88
+ max_rows: int,
89
+ max_bytes: int,
90
+ build_payload: Callable[[str, Any], dict[str, Any]],
91
+ ) -> None:
92
+ if not columns:
93
+ raise SnowflakeConfigError("insert_batches requires at least one column")
94
+ for name, limit in (("max_rows", max_rows), ("max_bytes", max_bytes)):
95
+ if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:
96
+ raise SnowflakeConfigError(f"{name} must be a positive integer")
97
+ if max_rows > 200_000:
98
+ raise SnowflakeConfigError("max_rows must be <= 200000")
99
+ cols = ", ".join(quote_identifier(c) for c in columns)
100
+ self.prefix = f"INSERT INTO {quote_name(table)} ({cols}) VALUES "
101
+ self.placeholder = "(" + ", ".join(["?"] * len(columns)) + ")"
102
+ self.width = len(columns)
103
+ self.max_rows, self.max_bytes = max_rows, max_bytes
104
+ self.source = rows
105
+ self.iterator: Iterator[Sequence[Any]] | None = None
106
+ self.build_payload = build_payload
107
+ self.context: bytes | None = None
108
+ self.single_overhead = 0
109
+ self.pending: tuple[list[Any], list[bytes]] | None = None
110
+ self.exhausted = False
111
+ self.consumed_rows = 0
112
+ self.confirmed_rows = 0
113
+ self.completed: list[BatchSummary] = []
114
+
115
+ def result(self) -> BatchWriteResult:
116
+ return BatchWriteResult(tuple(self.completed))
117
+
118
+ def error(
119
+ self,
120
+ cause: Exception,
121
+ phase: BatchPhase,
122
+ outcome: BatchOutcome = "not_executed",
123
+ failed_batch: FailedBatch | None = None,
124
+ ) -> BatchWriteError:
125
+ return BatchWriteError(
126
+ f"batch write stopped during {phase}: {cause}",
127
+ completed=self.result(),
128
+ next_row_index=self.confirmed_rows,
129
+ consumed_rows=self.consumed_rows,
130
+ failed_batch=failed_batch,
131
+ phase=phase,
132
+ outcome=outcome,
133
+ )
134
+
135
+ def _record(self) -> tuple[list[Any], list[bytes]] | None:
136
+ if self.exhausted:
137
+ return None
138
+ try:
139
+ if self.iterator is None:
140
+ self.iterator = iter(self.source)
141
+ except Exception as exc:
142
+ raise self.error(exc, "input") from exc
143
+ try:
144
+ row = next(self.iterator)
145
+ except StopIteration:
146
+ self.exhausted = True
147
+ return None
148
+ except Exception as exc:
149
+ raise self.error(exc, "input") from exc
150
+ self.consumed_rows += 1
151
+ try:
152
+ if not isinstance(row, Sequence) or isinstance(
153
+ row, (str, bytes, bytearray)
154
+ ):
155
+ raise SnowflakeConfigError("each row must be a positional sequence")
156
+ if len(row) != self.width:
157
+ raise SnowflakeConfigError(
158
+ f"each row must have {self.width} values, got {len(row)}"
159
+ )
160
+ values = list(row)
161
+ return values, [_encode(value) for value in to_bindings(values).values()]
162
+ except Exception as exc:
163
+ raise self.error(exc, "validation") from exc
164
+
165
+ @staticmethod
166
+ def _fragment(values: list[bytes], offset: int) -> bytes:
167
+ return b",".join(
168
+ _encode(str(offset + i)) + b":" + value for i, value in enumerate(values, 1)
169
+ )
170
+
171
+ def _body(self, sql: str, bindings: bytes) -> bytes:
172
+ assert self.context is not None
173
+ return (
174
+ b'{"statement":'
175
+ + _encode(sql)
176
+ + self.context
177
+ + b',"bindings":{'
178
+ + bindings
179
+ + b"}}"
180
+ )
181
+
182
+ def next_batch(self) -> _PreparedBatch | None:
183
+ # Configuration is validated before this point; input errors discard the
184
+ # unsent buffer. Only a validated singleton can trigger a byte-boundary flush.
185
+ params: list[Any] = []
186
+ fragments: list[bytes] = []
187
+ row_count = 0
188
+ size = 0
189
+ while row_count < self.max_rows:
190
+ record = self.pending
191
+ self.pending = None
192
+ if record is None:
193
+ record = self._record()
194
+ if record is None:
195
+ break
196
+ values, encoded = record
197
+ try:
198
+ if self.context is None:
199
+ context = self.build_payload("", None)
200
+ del context["statement"]
201
+ self.context = _encode(context)[1:-1]
202
+ if self.context:
203
+ self.context = b"," + self.context
204
+ self.single_overhead = len(
205
+ self._body(self.prefix + self.placeholder, b"")
206
+ )
207
+ first = self._fragment(encoded, 0)
208
+ single_size = self.single_overhead + len(first)
209
+ if single_size > self.max_bytes:
210
+ raise SnowflakeConfigError(
211
+ f"row {self.consumed_rows - 1} exceeds max_bytes={self.max_bytes}"
212
+ )
213
+ fragment = (
214
+ first if row_count == 0 else self._fragment(encoded, len(params))
215
+ )
216
+ candidate = (
217
+ single_size
218
+ if row_count == 0
219
+ else size + 2 + len(self.placeholder) + 1 + len(fragment)
220
+ )
221
+ if candidate > self.max_bytes:
222
+ self.pending = record
223
+ break
224
+ fragments.append(fragment)
225
+ params.extend(values)
226
+ row_count += 1
227
+ size = candidate
228
+ except Exception as exc:
229
+ raise self.error(exc, "validation") from exc
230
+ if not row_count:
231
+ return None
232
+ sql = self.prefix + ", ".join([self.placeholder] * row_count)
233
+ content = self._body(sql, b",".join(fragments))
234
+ if len(content) != size or len(content) > self.max_bytes:
235
+ budget_error = SnowflakeConfigError(
236
+ "batch body exceeds its measured byte budget"
237
+ )
238
+ raise self.error(budget_error, "validation") from budget_error
239
+ return _PreparedBatch(sql, params, content, row_count)
240
+
241
+ def confirm(self, batch: _PreparedBatch, rows_affected: int) -> None:
242
+ self.completed.append(
243
+ BatchSummary(
244
+ len(self.completed), self.confirmed_rows, batch.row_count, rows_affected
245
+ )
246
+ )
247
+ self.confirmed_rows += batch.row_count
248
+
249
+
250
+ class _BatchExecution:
251
+ """Parent POST context survives polling and conservative error classification."""
252
+
253
+ def __init__(self, planner: _BatchPlanner, batch: _PreparedBatch) -> None:
254
+ self.planner = planner
255
+ self.failed = FailedBatch(
256
+ len(planner.completed),
257
+ planner.confirmed_rows,
258
+ batch.row_count,
259
+ str(uuid.uuid4()),
260
+ )
261
+ self.phase: BatchPhase = "submission"
262
+ self.succeeded = False
263
+
264
+ def received(self, handle: str | None, attempts: int | None, running: bool) -> None:
265
+ self.failed = replace(
266
+ self.failed, statement_handle=handle, submission_attempts=attempts or 0
267
+ )
268
+ if not running or handle is not None:
269
+ self.phase = "execution"
270
+ self.succeeded = not running
271
+
272
+ def error(self, cause: Exception) -> BatchWriteError:
273
+ attempts = getattr(cause, "submission_attempts", None)
274
+ handle = getattr(cause, "statement_handle", None) or getattr(
275
+ cause.__cause__, "statement_handle", None
276
+ )
277
+ self.failed = replace(
278
+ self.failed,
279
+ submission_attempts=(
280
+ self.failed.submission_attempts if attempts is None else attempts
281
+ ),
282
+ statement_handle=handle or self.failed.statement_handle,
283
+ )
284
+ outcome: BatchOutcome = "succeeded" if self.succeeded else "unknown"
285
+ if not self.succeeded and self.failed.submission_attempts <= 1:
286
+ if isinstance(cause, SnowflakeProgrammingError):
287
+ self.phase, outcome = "execution", "failed"
288
+ elif self.phase == "submission" and (
289
+ attempts == 0
290
+ or isinstance(cause, SnowflakeAuthError)
291
+ and cause.status_code == 401
292
+ or isinstance(cause, SnowflakeRequestError)
293
+ and cause.status_code in (400, 401, 403)
294
+ ):
295
+ outcome = "not_executed"
296
+ if isinstance(cause, SnowflakeProgrammingError):
297
+ self.phase = "execution"
298
+ return self.planner.error(cause, self.phase, outcome, self.failed)
@@ -18,6 +18,7 @@ from typing import (
18
18
  Callable,
19
19
  Dict,
20
20
  Generator,
21
+ Iterable,
21
22
  List,
22
23
  Optional,
23
24
  Sequence,
@@ -28,6 +29,7 @@ from typing import (
28
29
  import httpx
29
30
 
30
31
  from .auth import KeypairAuthenticator, account_hostname
32
+ from .batches import BatchWriteResult, _BatchExecution, _BatchPlanner
31
33
  from .bindings import to_bindings
32
34
  from .escaping import quote_identifier, quote_name
33
35
  from .exceptions import (
@@ -59,7 +61,16 @@ OnQuery = Callable[[str, "Params"], None]
59
61
  DEFAULT_POLL_TIMEOUT = 600.0
60
62
 
61
63
 
62
- def _rows_affected(body: Dict[str, Any]) -> int:
64
+ def _affected_count(value: Any, strict: bool) -> int:
65
+ if strict and (isinstance(value, bool) or not isinstance(value, (int, str))):
66
+ raise ValueError("rows-affected count must be an integer")
67
+ count = int(value)
68
+ if strict and count < 0:
69
+ raise ValueError("rows-affected count must be nonnegative")
70
+ return count
71
+
72
+
73
+ def _rows_affected(body: Dict[str, Any], *, strict: bool = False) -> int:
63
74
  """Extract the rows-affected count from a DML response."""
64
75
  stats = body.get("stats")
65
76
  if isinstance(stats, dict):
@@ -67,16 +78,19 @@ def _rows_affected(body: Dict[str, Any]) -> int:
67
78
  found = False
68
79
  for key in ("numRowsInserted", "numRowsUpdated", "numRowsDeleted"):
69
80
  if key in stats:
70
- total += int(stats[key])
81
+ total += _affected_count(stats[key], strict)
71
82
  found = True
72
83
  if found:
73
84
  return total
74
85
  data = body.get("data")
75
86
  if data and data[0]:
76
87
  try:
77
- return int(data[0][0])
88
+ return _affected_count(data[0][0], strict)
78
89
  except (ValueError, TypeError):
79
- pass
90
+ if strict:
91
+ raise
92
+ if strict:
93
+ raise ValueError("response has no rows-affected count")
80
94
  return 0
81
95
 
82
96
 
@@ -421,6 +435,52 @@ class SnowflakeClient:
421
435
  sql = f"INSERT INTO {quote_name(table)} ({cols_sql}) VALUES {values_sql}"
422
436
  return self.execute(sql, flat)
423
437
 
438
+ def insert_batches(
439
+ self,
440
+ table: str,
441
+ columns: Sequence[str],
442
+ rows: Iterable[Sequence[Any]],
443
+ *,
444
+ max_rows: int = 1000,
445
+ max_bytes: int = 1_000_000,
446
+ ) -> BatchWriteResult:
447
+ """Insert a single-pass source sequentially within row and wire-byte budgets.
448
+
449
+ Independent statements can commit before a later failure. BatchWriteError
450
+ reports confirmed progress and uncertain outcomes; see docs/batch-writes.md.
451
+ Input is an ordinary iterable, consumed on the calling thread.
452
+ """
453
+ planner = _BatchPlanner(
454
+ table, columns, rows, max_rows, max_bytes, self._build_payload
455
+ )
456
+ while (batch := planner.next_batch()) is not None:
457
+ try:
458
+ self._notify(batch.sql, batch.params)
459
+ except Exception as exc:
460
+ raise planner.error(exc, "validation") from exc
461
+ execution = _BatchExecution(planner, batch)
462
+ try:
463
+ response = self._transport.submit(
464
+ {}, request_id=execution.failed.request_id, content=batch.content
465
+ )
466
+ execution.received(
467
+ response.statement_handle,
468
+ response.submission_attempts,
469
+ response.is_running,
470
+ )
471
+ if response.is_running:
472
+ handle = response.statement_handle
473
+ if handle is None:
474
+ raise SnowflakeRequestError("running statement has no handle")
475
+ response = self._poll(handle, self._statement_timeout)
476
+ execution.succeeded = True
477
+ affected = _rows_affected(response.body, strict=True)
478
+ except Exception as exc:
479
+ raise execution.error(exc) from exc
480
+ planner.confirm(batch, affected)
481
+ del batch
482
+ return planner.result()
483
+
424
484
  # -- async submit -----------------------------------------------------
425
485
 
426
486
  def submit(self, sql: str, params: Params = None) -> QueryHandle:
@@ -7,10 +7,15 @@ HTTP responses onto these types; see :mod:`snowflake_sql_api.transport`.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- from typing import Optional
10
+ from functools import partial
11
+ from typing import TYPE_CHECKING, Any, Optional
12
+
13
+ if TYPE_CHECKING:
14
+ from .batches import BatchOutcome, BatchPhase, BatchWriteResult, FailedBatch
11
15
 
12
16
  __all__ = [
13
17
  "SnowflakeError",
18
+ "BatchWriteError",
14
19
  "SnowflakeConfigError",
15
20
  "SnowflakeAuthError",
16
21
  "SnowflakeRequestError",
@@ -23,7 +28,54 @@ __all__ = [
23
28
 
24
29
 
25
30
  class SnowflakeError(Exception):
26
- """Base class for every error raised by this library."""
31
+ """Base class for every error raised by this library.
32
+
33
+ ``submission_attempts`` counts actual parent POST attempts, or is None for
34
+ errors outside statement submission (including GET requests).
35
+ """
36
+
37
+ def __init__(
38
+ self, *args: object, submission_attempts: Optional[int] = None
39
+ ) -> None:
40
+ super().__init__(*args)
41
+ self.submission_attempts = submission_attempts
42
+
43
+
44
+ class BatchWriteError(SnowflakeError):
45
+ """A batch import stopped; inspect confirmed progress and reconcile unknown writes."""
46
+
47
+ def __init__(
48
+ self,
49
+ message: str,
50
+ *,
51
+ completed: BatchWriteResult,
52
+ next_row_index: int,
53
+ consumed_rows: int,
54
+ failed_batch: Optional[FailedBatch],
55
+ phase: BatchPhase,
56
+ outcome: BatchOutcome,
57
+ ) -> None:
58
+ super().__init__(message)
59
+ self.completed = completed
60
+ self.next_row_index = next_row_index
61
+ self.consumed_rows = consumed_rows
62
+ self.failed_batch = failed_batch
63
+ self.phase = phase
64
+ self.outcome = outcome
65
+
66
+ def __reduce__(self) -> tuple[Any, ...]:
67
+ fields = {
68
+ key: getattr(self, key)
69
+ for key in (
70
+ "completed",
71
+ "next_row_index",
72
+ "consumed_rows",
73
+ "failed_batch",
74
+ "phase",
75
+ "outcome",
76
+ )
77
+ }
78
+ return partial(type(self), **fields), self.args, self.__dict__
27
79
 
28
80
 
29
81
  class SnowflakeConfigError(SnowflakeError):
@@ -35,7 +87,26 @@ class SnowflakeConfigError(SnowflakeError):
35
87
 
36
88
 
37
89
  class SnowflakeAuthError(SnowflakeError):
38
- """Keypair JWT generation or Snowflake authentication failed."""
90
+ """Snowflake rejected authentication (HTTP 401).
91
+
92
+ Carries the server envelope. Local JWT signing errors propagate with their
93
+ original types instead of being converted to this exception.
94
+ """
95
+
96
+ def __init__(
97
+ self,
98
+ *args: object,
99
+ status_code: Optional[int] = None,
100
+ code: Optional[str] = None,
101
+ sql_state: Optional[str] = None,
102
+ statement_handle: Optional[str] = None,
103
+ submission_attempts: Optional[int] = None,
104
+ ) -> None:
105
+ super().__init__(*args, submission_attempts=submission_attempts)
106
+ self.status_code = status_code
107
+ self.code = code
108
+ self.sql_state = sql_state
109
+ self.statement_handle = statement_handle
39
110
 
40
111
 
41
112
  class SnowflakeRequestError(SnowflakeError):
@@ -54,8 +125,9 @@ class SnowflakeRequestError(SnowflakeError):
54
125
  sql_state: Optional[str] = None,
55
126
  request_id: Optional[str] = None,
56
127
  statement_handle: Optional[str] = None,
128
+ submission_attempts: Optional[int] = None,
57
129
  ) -> None:
58
- super().__init__(message)
130
+ super().__init__(message, submission_attempts=submission_attempts)
59
131
  self.status_code = status_code
60
132
  self.code = code
61
133
  self.sql_state = sql_state