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.
- snowflake_sql_api/__init__.py +6 -0
- snowflake_sql_api/_version.py +2 -2
- snowflake_sql_api/aclient.py +156 -6
- snowflake_sql_api/batches.py +298 -0
- snowflake_sql_api/client.py +160 -10
- snowflake_sql_api/exceptions.py +76 -4
- snowflake_sql_api/exports.py +71 -0
- snowflake_sql_api/pagination.py +80 -2
- snowflake_sql_api/transport.py +120 -48
- snowflake_sql_api/types.py +10 -6
- {snowflake_sql_api-0.1.2.dist-info → snowflake_sql_api-0.1.4.dist-info}/METADATA +52 -10
- snowflake_sql_api-0.1.4.dist-info/RECORD +23 -0
- snowflake_sql_api-0.1.2.dist-info/RECORD +0 -21
- {snowflake_sql_api-0.1.2.dist-info → snowflake_sql_api-0.1.4.dist-info}/WHEEL +0 -0
- {snowflake_sql_api-0.1.2.dist-info → snowflake_sql_api-0.1.4.dist-info}/entry_points.txt +0 -0
- {snowflake_sql_api-0.1.2.dist-info → snowflake_sql_api-0.1.4.dist-info}/licenses/LICENSE +0 -0
snowflake_sql_api/__init__.py
CHANGED
|
@@ -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",
|
snowflake_sql_api/_version.py
CHANGED
|
@@ -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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 1,
|
|
21
|
+
__version__ = version = '0.1.4'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 4)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
snowflake_sql_api/aclient.py
CHANGED
|
@@ -12,11 +12,23 @@ import os
|
|
|
12
12
|
import time
|
|
13
13
|
import uuid
|
|
14
14
|
from pathlib import Path
|
|
15
|
-
from typing import
|
|
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 .
|
|
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
|
|
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],
|
|
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
|
-
|
|
224
|
-
|
|
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)
|