snowflake-sql-api 0.1.2__py3-none-any.whl → 0.1.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.2'
22
- __version_tuple__ = version_tuple = (0, 1, 2)
21
+ __version__ = version = '0.1.4'
22
+ __version_tuple__ = version_tuple = (0, 1, 4)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -12,11 +12,23 @@ import os
12
12
  import time
13
13
  import uuid
14
14
  from pathlib import Path
15
- from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
15
+ from typing import (
16
+ Any,
17
+ AsyncGenerator,
18
+ Callable,
19
+ Dict,
20
+ Iterable,
21
+ List,
22
+ Optional,
23
+ Sequence,
24
+ TextIO,
25
+ Tuple,
26
+ )
16
27
 
17
28
  import httpx
18
29
 
19
30
  from .auth import KeypairAuthenticator, account_hostname
31
+ from .batches import BatchWriteResult, _BatchExecution, _BatchPlanner
20
32
  from .bindings import to_bindings
21
33
  from .client import DEFAULT_POLL_TIMEOUT, _columns_from, _rows_affected
22
34
  from .escaping import quote_identifier, quote_name
@@ -26,7 +38,13 @@ from .exceptions import (
26
38
  SnowflakeRequestError,
27
39
  SnowflakeTimeoutError,
28
40
  )
29
- from .pagination import fetch_all_partitions_async
41
+ from .exports import (
42
+ CSVWriter,
43
+ validate_batch_size,
44
+ validate_export_columns,
45
+ write_jsonl,
46
+ )
47
+ from .pagination import fetch_all_partitions_async, iter_result_rows_async
30
48
  from .retry import DEFAULT_RETRY_POLICY, RetryPolicy
31
49
  from .transport import AsyncTransport, StatementResponse
32
50
  from .types import ColumnMeta, Row, coerce_rows
@@ -201,13 +219,13 @@ class AsyncSnowflakeClient:
201
219
  )
202
220
  await asyncio.sleep(self._poll_interval)
203
221
 
204
- async def _collect(
222
+ async def _prepare_result(
205
223
  self,
206
224
  response: StatementResponse,
207
225
  *,
208
226
  timeout: Optional[float],
209
227
  poll: bool = True,
210
- ) -> Tuple[List[ColumnMeta], List[List[Any]]]:
228
+ ) -> Tuple[List[ColumnMeta], str, Dict[str, Any]]:
211
229
  if response.is_running:
212
230
  if not poll:
213
231
  raise ResultNotReady(statement_handle=response.statement_handle)
@@ -220,8 +238,27 @@ class AsyncSnowflakeClient:
220
238
  handle = response.statement_handle
221
239
  if handle is None:
222
240
  raise SnowflakeRequestError("completed statement has no handle")
223
- raw_rows = await fetch_all_partitions_async(self._transport, handle, body)
224
- return columns, raw_rows
241
+ return columns, handle, body
242
+
243
+ async def _collect(
244
+ self,
245
+ response: StatementResponse,
246
+ *,
247
+ timeout: Optional[float],
248
+ poll: bool = True,
249
+ ) -> Tuple[List[ColumnMeta], List[List[Any]]]:
250
+ columns, handle, body = await self._prepare_result(
251
+ response, timeout=timeout, poll=poll
252
+ )
253
+ return columns, await fetch_all_partitions_async(self._transport, handle, body)
254
+
255
+ async def _stream_result(
256
+ self, sql: str, params: Params
257
+ ) -> Tuple[List[ColumnMeta], AsyncGenerator[Row, None]]:
258
+ columns, handle, body = await self._prepare_result(
259
+ await self._run(sql, params), timeout=self._statement_timeout
260
+ )
261
+ return columns, iter_result_rows_async(self._transport, handle, body, columns)
225
262
 
226
263
  # -- query surface ----------------------------------------------------
227
264
 
@@ -233,6 +270,73 @@ class AsyncSnowflakeClient:
233
270
  )
234
271
  return coerce_rows(raw_rows, columns)
235
272
 
273
+ async def query_stream(
274
+ self, sql: str, params: Params = None
275
+ ) -> AsyncGenerator[Row, None]:
276
+ """Yield converted rows on demand; call directly with async for.
277
+
278
+ Submission starts on first iteration. Keep the client open while
279
+ consuming; await aclose() on a retained stream when stopping early.
280
+ """
281
+ _, rows = await self._stream_result(sql, params)
282
+ try:
283
+ async for row in rows:
284
+ yield row
285
+ finally:
286
+ await rows.aclose()
287
+
288
+ async def query_batches(
289
+ self, sql: str, params: Params = None, *, batch_size: int = 1000
290
+ ) -> AsyncGenerator[List[Row], None]:
291
+ """Yield batches of at most batch_size rows across partition boundaries."""
292
+ validate_batch_size(batch_size)
293
+ rows = self.query_stream(sql, params)
294
+ try:
295
+ batch: List[Row] = []
296
+ async for row in rows:
297
+ batch.append(row)
298
+ if len(batch) == batch_size:
299
+ yield batch
300
+ batch = []
301
+ if batch:
302
+ yield batch
303
+ finally:
304
+ await rows.aclose()
305
+
306
+ async def export_csv(self, sql: str, output: TextIO, params: Params = None) -> int:
307
+ """Await query I/O and write CSV to a caller-owned synchronous text sink.
308
+
309
+ Return the data-row count. Failures may leave partial output.
310
+ """
311
+ columns, rows = await self._stream_result(sql, params)
312
+ try:
313
+ writer = CSVWriter(output, columns)
314
+ count = 0
315
+ async for row in rows:
316
+ writer.write(row)
317
+ count += 1
318
+ finally:
319
+ await rows.aclose()
320
+ return count
321
+
322
+ async def export_jsonl(
323
+ self, sql: str, output: TextIO, params: Params = None
324
+ ) -> int:
325
+ """Await query I/O and write JSONL to a caller-owned synchronous text sink.
326
+
327
+ Return the row count. Failures may leave partial output.
328
+ """
329
+ columns, rows = await self._stream_result(sql, params)
330
+ try:
331
+ validate_export_columns(columns)
332
+ count = 0
333
+ async for row in rows:
334
+ write_jsonl(output, row)
335
+ count += 1
336
+ finally:
337
+ await rows.aclose()
338
+ return count
339
+
236
340
  async def query_one(self, sql: str, params: Params = None) -> Optional[Row]:
237
341
  """Return the first row, or ``None`` if the result is empty."""
238
342
  rows = await self.query(sql, params)
@@ -286,6 +390,52 @@ class AsyncSnowflakeClient:
286
390
 
287
391
  # -- async submit -----------------------------------------------------
288
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
+
289
439
  async def submit(self, sql: str, params: Params = None) -> AsyncQueryHandle:
290
440
  """Submit a long-running statement and return an :class:`AsyncQueryHandle`."""
291
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)