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.
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/PKG-INFO +20 -1
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/README.md +19 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/__init__.py +6 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/_version.py +2 -2
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/aclient.py +48 -0
- snowflake_sql_api-0.1.4/snowflake_sql_api/batches.py +298 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/client.py +64 -4
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/exceptions.py +76 -4
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/transport.py +120 -48
- snowflake_sql_api-0.1.4/tests/test_batch_writes.py +1023 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/.gitignore +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/CONTRIBUTING.md +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/LICENSE +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/pyproject.toml +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/auth.py +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/bindings.py +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/cli.py +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/escaping.py +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/exports.py +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/pagination.py +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/py.typed +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/retry.py +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/row_mapping.py +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/testing.py +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/snowflake_sql_api/types.py +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/__init__.py +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/conftest.py +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/smoke/__init__.py +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/smoke/test_smoke.py +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/streaming_support.py +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/support.py +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/test_aclient.py +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/test_auth.py +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/test_bindings.py +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/test_cli.py +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/test_client.py +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/test_escaping.py +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/test_exports.py +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/test_import_footprint.py +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/test_package.py +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/test_regression.py +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/test_retry.py +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/test_streaming.py +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/test_testing.py +0 -0
- {snowflake_sql_api-0.1.3 → snowflake_sql_api-0.1.4}/tests/test_transport.py +0 -0
- {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
|
+
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.
|
|
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
|
|
@@ -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
|
|
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 +=
|
|
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
|
|
88
|
+
return _affected_count(data[0][0], strict)
|
|
78
89
|
except (ValueError, TypeError):
|
|
79
|
-
|
|
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
|
|
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
|
-
"""
|
|
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
|