snowflake-sql-api 0.1.1__tar.gz → 0.1.3__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.1 → snowflake_sql_api-0.1.3}/CONTRIBUTING.md +1 -1
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/PKG-INFO +38 -14
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/README.md +32 -8
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/pyproject.toml +17 -16
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/snowflake_sql_api/__init__.py +2 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/snowflake_sql_api/_version.py +2 -2
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/snowflake_sql_api/aclient.py +108 -6
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/snowflake_sql_api/client.py +96 -6
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/snowflake_sql_api/exceptions.py +10 -0
- snowflake_sql_api-0.1.3/snowflake_sql_api/exports.py +71 -0
- snowflake_sql_api-0.1.3/snowflake_sql_api/pagination.py +131 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/snowflake_sql_api/transport.py +14 -4
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/snowflake_sql_api/types.py +10 -6
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/conftest.py +6 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/smoke/test_smoke.py +37 -0
- snowflake_sql_api-0.1.3/tests/streaming_support.py +73 -0
- snowflake_sql_api-0.1.3/tests/test_exports.py +221 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/test_import_footprint.py +1 -3
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/test_package.py +3 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/test_regression.py +61 -5
- snowflake_sql_api-0.1.3/tests/test_streaming.py +279 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/test_testing.py +0 -2
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/test_transport.py +23 -0
- snowflake_sql_api-0.1.1/snowflake_sql_api/pagination.py +0 -53
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/.gitignore +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/LICENSE +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/snowflake_sql_api/auth.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/snowflake_sql_api/bindings.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/snowflake_sql_api/cli.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/snowflake_sql_api/escaping.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/snowflake_sql_api/py.typed +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/snowflake_sql_api/retry.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/snowflake_sql_api/row_mapping.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/snowflake_sql_api/testing.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/__init__.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/smoke/__init__.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/support.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/test_aclient.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/test_auth.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/test_bindings.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/test_cli.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/test_client.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/test_escaping.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/test_retry.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/test_types.py +0 -0
|
@@ -34,7 +34,7 @@ All four must pass (and `pre-commit run --all-files` is clean). Use `coverage ru
|
|
|
34
34
|
(`snowflake_sql_api.testing`), and `coverage run` starts tracing before that
|
|
35
35
|
plugin is imported so import-time lines are measured correctly. The coverage gate
|
|
36
36
|
is enforced (`fail_under = 89` in `pyproject.toml`); `coverage report` exits
|
|
37
|
-
non-zero below it. CI runs the same checks across Python 3.
|
|
37
|
+
non-zero below it. CI runs the same checks across Python 3.11-3.15.
|
|
38
38
|
|
|
39
39
|
Checklist:
|
|
40
40
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: snowflake-sql-api
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
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
|
|
@@ -34,20 +34,20 @@ Classifier: Intended Audience :: Developers
|
|
|
34
34
|
Classifier: License :: OSI Approved :: MIT License
|
|
35
35
|
Classifier: Operating System :: OS Independent
|
|
36
36
|
Classifier: Programming Language :: Python :: 3
|
|
37
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
38
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
39
37
|
Classifier: Programming Language :: Python :: 3.11
|
|
40
38
|
Classifier: Programming Language :: Python :: 3.12
|
|
41
39
|
Classifier: Programming Language :: Python :: 3.13
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
42
42
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
43
43
|
Classifier: Topic :: Database
|
|
44
44
|
Classifier: Typing :: Typed
|
|
45
|
-
Requires-Python: >=3.
|
|
45
|
+
Requires-Python: >=3.11
|
|
46
46
|
Requires-Dist: cryptography>=3.4
|
|
47
47
|
Requires-Dist: httpx>=0.24
|
|
48
48
|
Requires-Dist: pyjwt>=2.4
|
|
49
49
|
Provides-Extra: dev
|
|
50
|
-
Requires-Dist: black<
|
|
50
|
+
Requires-Dist: black<26.6,>=26.5; extra == 'dev'
|
|
51
51
|
Requires-Dist: build>=1.0; extra == 'dev'
|
|
52
52
|
Requires-Dist: coverage[toml]>=7.0; extra == 'dev'
|
|
53
53
|
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
@@ -64,17 +64,24 @@ Description-Content-Type: text/markdown
|
|
|
64
64
|
|
|
65
65
|
# snowflake-sql-api
|
|
66
66
|
|
|
67
|
+
[](https://github.com/hampsterx/snowflake-sql-api/actions/workflows/ci.yml)
|
|
68
|
+
[](https://pypi.org/project/snowflake-sql-api/)
|
|
69
|
+
[](https://pypi.org/project/snowflake-sql-api/)
|
|
70
|
+
[](LICENSE)
|
|
71
|
+
[](https://peps.python.org/pep-0561/)
|
|
72
|
+
|
|
67
73
|
A lightweight, pure-Python client for [Snowflake's SQL API v2](https://docs.snowflake.com/en/developer-guide/sql-api/index)
|
|
68
74
|
(the `POST /api/v2/statements` REST endpoint).
|
|
69
75
|
|
|
70
|
-
> **Status:
|
|
76
|
+
> **Status: pre-1.0.** The API may still change between minor versions.
|
|
71
77
|
|
|
72
78
|
## Why
|
|
73
79
|
|
|
74
80
|
The official [`snowflake-connector-python`](https://pypi.org/project/snowflake-connector-python/)
|
|
75
|
-
is ~75 MB installed (150 MB with pandas/pyarrow)
|
|
76
|
-
|
|
77
|
-
|
|
81
|
+
is ~75 MB installed (150 MB with pandas/pyarrow) and ships a compiled extension
|
|
82
|
+
that slows container builds and inflates cold starts (its import alone is ~1.5 s
|
|
83
|
+
versus ~0.3 s for this client). For serverless and AWS Lambda workloads that only
|
|
84
|
+
need to run SQL, that is a lot of weight.
|
|
78
85
|
|
|
79
86
|
`snowflake-sql-api` talks to Snowflake's SQL API over plain HTTP (`httpx`), so it
|
|
80
87
|
stays small and cold-starts quickly. Core dependencies: `httpx`, `PyJWT`,
|
|
@@ -86,6 +93,7 @@ stays small and cold-starts quickly. Core dependencies: `httpx`, `PyJWT`,
|
|
|
86
93
|
- Keypair (JWT) authentication, including encrypted private keys
|
|
87
94
|
- Result type coercion (NUMBER/Decimal, dates/timestamps, VARIANT, and more)
|
|
88
95
|
- Multi-partition result handling for large result sets
|
|
96
|
+
- Lazy row streams and bounded row batches, with CSV and JSONL exports
|
|
89
97
|
- Query helpers, DML, batch insert, async statement submission
|
|
90
98
|
- A small CLI for ad-hoc queries
|
|
91
99
|
- Optional pandas (`[pandas]`) and typed-row (`[pydantic]`) helpers, kept out of
|
|
@@ -131,6 +139,22 @@ CLI:
|
|
|
131
139
|
snowflake-sql-api query "SELECT current_version()"
|
|
132
140
|
```
|
|
133
141
|
|
|
142
|
+
Stream large results or export them without collecting every row:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
with SnowflakeClient.from_env() as client:
|
|
146
|
+
for batch in client.query_batches("SELECT id, amount FROM sales", batch_size=500):
|
|
147
|
+
process(batch)
|
|
148
|
+
|
|
149
|
+
with open("sales.csv", "w", encoding="utf-8", newline="") as output:
|
|
150
|
+
count = client.export_csv("SELECT id, amount FROM sales", output)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Streaming fetches completed results one server partition at a time. Memory scales
|
|
154
|
+
with the largest decoded partition plus the application batch. See
|
|
155
|
+
[streaming results and exports](docs/streaming.md) for row streams, async usage,
|
|
156
|
+
serialization, and partial-output behavior.
|
|
157
|
+
|
|
134
158
|
## Prior art and alternatives
|
|
135
159
|
|
|
136
160
|
`snowflake-sql-api` is not the first REST-based Snowflake client. If it does not
|
|
@@ -145,10 +169,10 @@ fit your needs, one of these might:
|
|
|
145
169
|
| [`rb_snowflake_client`](https://github.com/rinsed-org/rb-snowflake-client) | Ruby | Mature pure-Ruby SQL API client (streaming, connection pooling). |
|
|
146
170
|
| [`rsql`](https://github.com/theseus-rs/rsql) | Rust | Multi-database SQL CLI with a Snowflake SQL API driver. |
|
|
147
171
|
|
|
148
|
-
**How `snowflake-sql-api` differs:** pure Python over `httpx
|
|
149
|
-
the official connector (small install, fast cold start), paired
|
|
150
|
-
clients,
|
|
151
|
-
first-class behavior.
|
|
172
|
+
**How `snowflake-sql-api` differs:** pure Python over `httpx`, with no
|
|
173
|
+
dependency on the official connector (small install, fast cold start), paired
|
|
174
|
+
sync and async clients, keypair auth, type coercion, partition handling, and a
|
|
175
|
+
CLI as tested first-class behavior.
|
|
152
176
|
|
|
153
177
|
Note: the similarly named `snowflake-sql-api-async` and `snowflake-sql-api-client`
|
|
154
178
|
are separate, unaffiliated projects.
|
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
# snowflake-sql-api
|
|
2
2
|
|
|
3
|
+
[](https://github.com/hampsterx/snowflake-sql-api/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/snowflake-sql-api/)
|
|
5
|
+
[](https://pypi.org/project/snowflake-sql-api/)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://peps.python.org/pep-0561/)
|
|
8
|
+
|
|
3
9
|
A lightweight, pure-Python client for [Snowflake's SQL API v2](https://docs.snowflake.com/en/developer-guide/sql-api/index)
|
|
4
10
|
(the `POST /api/v2/statements` REST endpoint).
|
|
5
11
|
|
|
6
|
-
> **Status:
|
|
12
|
+
> **Status: pre-1.0.** The API may still change between minor versions.
|
|
7
13
|
|
|
8
14
|
## Why
|
|
9
15
|
|
|
10
16
|
The official [`snowflake-connector-python`](https://pypi.org/project/snowflake-connector-python/)
|
|
11
|
-
is ~75 MB installed (150 MB with pandas/pyarrow)
|
|
12
|
-
|
|
13
|
-
|
|
17
|
+
is ~75 MB installed (150 MB with pandas/pyarrow) and ships a compiled extension
|
|
18
|
+
that slows container builds and inflates cold starts (its import alone is ~1.5 s
|
|
19
|
+
versus ~0.3 s for this client). For serverless and AWS Lambda workloads that only
|
|
20
|
+
need to run SQL, that is a lot of weight.
|
|
14
21
|
|
|
15
22
|
`snowflake-sql-api` talks to Snowflake's SQL API over plain HTTP (`httpx`), so it
|
|
16
23
|
stays small and cold-starts quickly. Core dependencies: `httpx`, `PyJWT`,
|
|
@@ -22,6 +29,7 @@ stays small and cold-starts quickly. Core dependencies: `httpx`, `PyJWT`,
|
|
|
22
29
|
- Keypair (JWT) authentication, including encrypted private keys
|
|
23
30
|
- Result type coercion (NUMBER/Decimal, dates/timestamps, VARIANT, and more)
|
|
24
31
|
- Multi-partition result handling for large result sets
|
|
32
|
+
- Lazy row streams and bounded row batches, with CSV and JSONL exports
|
|
25
33
|
- Query helpers, DML, batch insert, async statement submission
|
|
26
34
|
- A small CLI for ad-hoc queries
|
|
27
35
|
- Optional pandas (`[pandas]`) and typed-row (`[pydantic]`) helpers, kept out of
|
|
@@ -67,6 +75,22 @@ CLI:
|
|
|
67
75
|
snowflake-sql-api query "SELECT current_version()"
|
|
68
76
|
```
|
|
69
77
|
|
|
78
|
+
Stream large results or export them without collecting every row:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
with SnowflakeClient.from_env() as client:
|
|
82
|
+
for batch in client.query_batches("SELECT id, amount FROM sales", batch_size=500):
|
|
83
|
+
process(batch)
|
|
84
|
+
|
|
85
|
+
with open("sales.csv", "w", encoding="utf-8", newline="") as output:
|
|
86
|
+
count = client.export_csv("SELECT id, amount FROM sales", output)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Streaming fetches completed results one server partition at a time. Memory scales
|
|
90
|
+
with the largest decoded partition plus the application batch. See
|
|
91
|
+
[streaming results and exports](docs/streaming.md) for row streams, async usage,
|
|
92
|
+
serialization, and partial-output behavior.
|
|
93
|
+
|
|
70
94
|
## Prior art and alternatives
|
|
71
95
|
|
|
72
96
|
`snowflake-sql-api` is not the first REST-based Snowflake client. If it does not
|
|
@@ -81,10 +105,10 @@ fit your needs, one of these might:
|
|
|
81
105
|
| [`rb_snowflake_client`](https://github.com/rinsed-org/rb-snowflake-client) | Ruby | Mature pure-Ruby SQL API client (streaming, connection pooling). |
|
|
82
106
|
| [`rsql`](https://github.com/theseus-rs/rsql) | Rust | Multi-database SQL CLI with a Snowflake SQL API driver. |
|
|
83
107
|
|
|
84
|
-
**How `snowflake-sql-api` differs:** pure Python over `httpx
|
|
85
|
-
the official connector (small install, fast cold start), paired
|
|
86
|
-
clients,
|
|
87
|
-
first-class behavior.
|
|
108
|
+
**How `snowflake-sql-api` differs:** pure Python over `httpx`, with no
|
|
109
|
+
dependency on the official connector (small install, fast cold start), paired
|
|
110
|
+
sync and async clients, keypair auth, type coercion, partition handling, and a
|
|
111
|
+
CLI as tested first-class behavior.
|
|
88
112
|
|
|
89
113
|
Note: the similarly named `snowflake-sql-api-async` and `snowflake-sql-api-client`
|
|
90
114
|
are separate, unaffiliated projects.
|
|
@@ -7,7 +7,7 @@ name = "snowflake-sql-api"
|
|
|
7
7
|
dynamic = ["version"]
|
|
8
8
|
description = "Lightweight, pure-Python client for Snowflake's SQL API v2"
|
|
9
9
|
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
11
|
license = { file = "LICENSE" }
|
|
12
12
|
authors = [{ name = "hampsterx" }]
|
|
13
13
|
keywords = ["snowflake", "sql", "rest", "api", "httpx", "serverless", "lambda"]
|
|
@@ -17,11 +17,11 @@ classifiers = [
|
|
|
17
17
|
"License :: OSI Approved :: MIT License",
|
|
18
18
|
"Operating System :: OS Independent",
|
|
19
19
|
"Programming Language :: Python :: 3",
|
|
20
|
-
"Programming Language :: Python :: 3.9",
|
|
21
|
-
"Programming Language :: Python :: 3.10",
|
|
22
20
|
"Programming Language :: Python :: 3.11",
|
|
23
21
|
"Programming Language :: Python :: 3.12",
|
|
24
22
|
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Programming Language :: Python :: 3.15",
|
|
25
25
|
"Programming Language :: Python :: Implementation :: CPython",
|
|
26
26
|
"Topic :: Database",
|
|
27
27
|
"Typing :: Typed",
|
|
@@ -41,9 +41,10 @@ dev = [
|
|
|
41
41
|
"pytest-asyncio>=0.21",
|
|
42
42
|
"respx>=0.20",
|
|
43
43
|
"ruff>=0.4",
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
|
|
44
|
+
# Held to 26.5.x so CI formats with the same black the pre-commit hook
|
|
45
|
+
# pins (rev 26.5.1 in .pre-commit-config.yaml). Move the two together by
|
|
46
|
+
# hand; Dependabot ignores black (.github/dependabot.yml).
|
|
47
|
+
"black>=26.5,<26.6",
|
|
47
48
|
"mypy>=1.8",
|
|
48
49
|
"build>=1.0",
|
|
49
50
|
"pre-commit>=3.5",
|
|
@@ -86,13 +87,13 @@ include = [
|
|
|
86
87
|
|
|
87
88
|
[tool.ruff]
|
|
88
89
|
line-length = 88
|
|
89
|
-
target-version = "
|
|
90
|
+
target-version = "py311"
|
|
90
91
|
|
|
91
92
|
[tool.ruff.lint]
|
|
92
93
|
select = ["E", "F", "W", "I", "B", "C4", "SIM"]
|
|
93
|
-
# E501 (line length) is owned by black. `UP` (pyupgrade) is
|
|
94
|
-
#
|
|
95
|
-
#
|
|
94
|
+
# E501 (line length) is owned by black. `UP` (pyupgrade) is not enabled yet: the
|
|
95
|
+
# code predates the 3.11 floor and still spells `Optional`/`List`, so turning it
|
|
96
|
+
# on is a ~250-site annotation rewrite of its own.
|
|
96
97
|
ignore = ["E501"]
|
|
97
98
|
|
|
98
99
|
[tool.ruff.lint.per-file-ignores]
|
|
@@ -100,14 +101,14 @@ ignore = ["E501"]
|
|
|
100
101
|
|
|
101
102
|
[tool.black]
|
|
102
103
|
line-length = 88
|
|
103
|
-
# Pin to
|
|
104
|
-
#
|
|
105
|
-
target-version = ["
|
|
104
|
+
# Pin to the floor's grammar: a single floor target keeps black's AST safety
|
|
105
|
+
# check parseable by the interpreter running CI.
|
|
106
|
+
target-version = ["py311"]
|
|
106
107
|
|
|
107
108
|
[tool.mypy]
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
python_version = "3.
|
|
109
|
+
# The runtime floor; the pytest matrix (3.11-3.15) imports every module under
|
|
110
|
+
# each supported interpreter.
|
|
111
|
+
python_version = "3.11"
|
|
111
112
|
files = ["snowflake_sql_api"]
|
|
112
113
|
disallow_untyped_defs = true
|
|
113
114
|
disallow_incomplete_defs = true
|
|
@@ -33,6 +33,7 @@ from .exceptions import (
|
|
|
33
33
|
SnowflakeAuthError,
|
|
34
34
|
SnowflakeConfigError,
|
|
35
35
|
SnowflakeError,
|
|
36
|
+
SnowflakeForbiddenError,
|
|
36
37
|
SnowflakeProgrammingError,
|
|
37
38
|
SnowflakeRequestError,
|
|
38
39
|
SnowflakeRetryError,
|
|
@@ -51,6 +52,7 @@ __all__ = [
|
|
|
51
52
|
"SnowflakeConfigError",
|
|
52
53
|
"SnowflakeAuthError",
|
|
53
54
|
"SnowflakeRequestError",
|
|
55
|
+
"SnowflakeForbiddenError",
|
|
54
56
|
"SnowflakeProgrammingError",
|
|
55
57
|
"SnowflakeTimeoutError",
|
|
56
58
|
"SnowflakeRetryError",
|
|
@@ -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.3'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 3)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -12,7 +12,17 @@ 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
|
+
List,
|
|
21
|
+
Optional,
|
|
22
|
+
Sequence,
|
|
23
|
+
TextIO,
|
|
24
|
+
Tuple,
|
|
25
|
+
)
|
|
16
26
|
|
|
17
27
|
import httpx
|
|
18
28
|
|
|
@@ -26,7 +36,13 @@ from .exceptions import (
|
|
|
26
36
|
SnowflakeRequestError,
|
|
27
37
|
SnowflakeTimeoutError,
|
|
28
38
|
)
|
|
29
|
-
from .
|
|
39
|
+
from .exports import (
|
|
40
|
+
CSVWriter,
|
|
41
|
+
validate_batch_size,
|
|
42
|
+
validate_export_columns,
|
|
43
|
+
write_jsonl,
|
|
44
|
+
)
|
|
45
|
+
from .pagination import fetch_all_partitions_async, iter_result_rows_async
|
|
30
46
|
from .retry import DEFAULT_RETRY_POLICY, RetryPolicy
|
|
31
47
|
from .transport import AsyncTransport, StatementResponse
|
|
32
48
|
from .types import ColumnMeta, Row, coerce_rows
|
|
@@ -201,13 +217,13 @@ class AsyncSnowflakeClient:
|
|
|
201
217
|
)
|
|
202
218
|
await asyncio.sleep(self._poll_interval)
|
|
203
219
|
|
|
204
|
-
async def
|
|
220
|
+
async def _prepare_result(
|
|
205
221
|
self,
|
|
206
222
|
response: StatementResponse,
|
|
207
223
|
*,
|
|
208
224
|
timeout: Optional[float],
|
|
209
225
|
poll: bool = True,
|
|
210
|
-
) -> Tuple[List[ColumnMeta],
|
|
226
|
+
) -> Tuple[List[ColumnMeta], str, Dict[str, Any]]:
|
|
211
227
|
if response.is_running:
|
|
212
228
|
if not poll:
|
|
213
229
|
raise ResultNotReady(statement_handle=response.statement_handle)
|
|
@@ -220,8 +236,27 @@ class AsyncSnowflakeClient:
|
|
|
220
236
|
handle = response.statement_handle
|
|
221
237
|
if handle is None:
|
|
222
238
|
raise SnowflakeRequestError("completed statement has no handle")
|
|
223
|
-
|
|
224
|
-
|
|
239
|
+
return columns, handle, body
|
|
240
|
+
|
|
241
|
+
async def _collect(
|
|
242
|
+
self,
|
|
243
|
+
response: StatementResponse,
|
|
244
|
+
*,
|
|
245
|
+
timeout: Optional[float],
|
|
246
|
+
poll: bool = True,
|
|
247
|
+
) -> Tuple[List[ColumnMeta], List[List[Any]]]:
|
|
248
|
+
columns, handle, body = await self._prepare_result(
|
|
249
|
+
response, timeout=timeout, poll=poll
|
|
250
|
+
)
|
|
251
|
+
return columns, await fetch_all_partitions_async(self._transport, handle, body)
|
|
252
|
+
|
|
253
|
+
async def _stream_result(
|
|
254
|
+
self, sql: str, params: Params
|
|
255
|
+
) -> Tuple[List[ColumnMeta], AsyncGenerator[Row, None]]:
|
|
256
|
+
columns, handle, body = await self._prepare_result(
|
|
257
|
+
await self._run(sql, params), timeout=self._statement_timeout
|
|
258
|
+
)
|
|
259
|
+
return columns, iter_result_rows_async(self._transport, handle, body, columns)
|
|
225
260
|
|
|
226
261
|
# -- query surface ----------------------------------------------------
|
|
227
262
|
|
|
@@ -233,6 +268,73 @@ class AsyncSnowflakeClient:
|
|
|
233
268
|
)
|
|
234
269
|
return coerce_rows(raw_rows, columns)
|
|
235
270
|
|
|
271
|
+
async def query_stream(
|
|
272
|
+
self, sql: str, params: Params = None
|
|
273
|
+
) -> AsyncGenerator[Row, None]:
|
|
274
|
+
"""Yield converted rows on demand; call directly with async for.
|
|
275
|
+
|
|
276
|
+
Submission starts on first iteration. Keep the client open while
|
|
277
|
+
consuming; await aclose() on a retained stream when stopping early.
|
|
278
|
+
"""
|
|
279
|
+
_, rows = await self._stream_result(sql, params)
|
|
280
|
+
try:
|
|
281
|
+
async for row in rows:
|
|
282
|
+
yield row
|
|
283
|
+
finally:
|
|
284
|
+
await rows.aclose()
|
|
285
|
+
|
|
286
|
+
async def query_batches(
|
|
287
|
+
self, sql: str, params: Params = None, *, batch_size: int = 1000
|
|
288
|
+
) -> AsyncGenerator[List[Row], None]:
|
|
289
|
+
"""Yield batches of at most batch_size rows across partition boundaries."""
|
|
290
|
+
validate_batch_size(batch_size)
|
|
291
|
+
rows = self.query_stream(sql, params)
|
|
292
|
+
try:
|
|
293
|
+
batch: List[Row] = []
|
|
294
|
+
async for row in rows:
|
|
295
|
+
batch.append(row)
|
|
296
|
+
if len(batch) == batch_size:
|
|
297
|
+
yield batch
|
|
298
|
+
batch = []
|
|
299
|
+
if batch:
|
|
300
|
+
yield batch
|
|
301
|
+
finally:
|
|
302
|
+
await rows.aclose()
|
|
303
|
+
|
|
304
|
+
async def export_csv(self, sql: str, output: TextIO, params: Params = None) -> int:
|
|
305
|
+
"""Await query I/O and write CSV to a caller-owned synchronous text sink.
|
|
306
|
+
|
|
307
|
+
Return the data-row count. Failures may leave partial output.
|
|
308
|
+
"""
|
|
309
|
+
columns, rows = await self._stream_result(sql, params)
|
|
310
|
+
try:
|
|
311
|
+
writer = CSVWriter(output, columns)
|
|
312
|
+
count = 0
|
|
313
|
+
async for row in rows:
|
|
314
|
+
writer.write(row)
|
|
315
|
+
count += 1
|
|
316
|
+
finally:
|
|
317
|
+
await rows.aclose()
|
|
318
|
+
return count
|
|
319
|
+
|
|
320
|
+
async def export_jsonl(
|
|
321
|
+
self, sql: str, output: TextIO, params: Params = None
|
|
322
|
+
) -> int:
|
|
323
|
+
"""Await query I/O and write JSONL to a caller-owned synchronous text sink.
|
|
324
|
+
|
|
325
|
+
Return the row count. Failures may leave partial output.
|
|
326
|
+
"""
|
|
327
|
+
columns, rows = await self._stream_result(sql, params)
|
|
328
|
+
try:
|
|
329
|
+
validate_export_columns(columns)
|
|
330
|
+
count = 0
|
|
331
|
+
async for row in rows:
|
|
332
|
+
write_jsonl(output, row)
|
|
333
|
+
count += 1
|
|
334
|
+
finally:
|
|
335
|
+
await rows.aclose()
|
|
336
|
+
return count
|
|
337
|
+
|
|
236
338
|
async def query_one(self, sql: str, params: Params = None) -> Optional[Row]:
|
|
237
339
|
"""Return the first row, or ``None`` if the result is empty."""
|
|
238
340
|
rows = await self.query(sql, params)
|
|
@@ -11,8 +11,19 @@ from __future__ import annotations
|
|
|
11
11
|
import os
|
|
12
12
|
import time
|
|
13
13
|
import uuid
|
|
14
|
+
from contextlib import closing
|
|
14
15
|
from pathlib import Path
|
|
15
|
-
from typing import
|
|
16
|
+
from typing import (
|
|
17
|
+
Any,
|
|
18
|
+
Callable,
|
|
19
|
+
Dict,
|
|
20
|
+
Generator,
|
|
21
|
+
List,
|
|
22
|
+
Optional,
|
|
23
|
+
Sequence,
|
|
24
|
+
TextIO,
|
|
25
|
+
Tuple,
|
|
26
|
+
)
|
|
16
27
|
|
|
17
28
|
import httpx
|
|
18
29
|
|
|
@@ -25,7 +36,13 @@ from .exceptions import (
|
|
|
25
36
|
SnowflakeRequestError,
|
|
26
37
|
SnowflakeTimeoutError,
|
|
27
38
|
)
|
|
28
|
-
from .
|
|
39
|
+
from .exports import (
|
|
40
|
+
CSVWriter,
|
|
41
|
+
validate_batch_size,
|
|
42
|
+
validate_export_columns,
|
|
43
|
+
write_jsonl,
|
|
44
|
+
)
|
|
45
|
+
from .pagination import fetch_all_partitions, iter_result_rows
|
|
29
46
|
from .retry import DEFAULT_RETRY_POLICY, RetryPolicy
|
|
30
47
|
from .transport import StatementResponse, Transport
|
|
31
48
|
from .types import ColumnMeta, Row, coerce_rows
|
|
@@ -246,13 +263,13 @@ class SnowflakeClient:
|
|
|
246
263
|
)
|
|
247
264
|
time.sleep(self._poll_interval)
|
|
248
265
|
|
|
249
|
-
def
|
|
266
|
+
def _prepare_result(
|
|
250
267
|
self,
|
|
251
268
|
response: StatementResponse,
|
|
252
269
|
*,
|
|
253
270
|
timeout: Optional[float],
|
|
254
271
|
poll: bool = True,
|
|
255
|
-
) -> Tuple[List[ColumnMeta],
|
|
272
|
+
) -> Tuple[List[ColumnMeta], str, Dict[str, Any]]:
|
|
256
273
|
if response.is_running:
|
|
257
274
|
if not poll:
|
|
258
275
|
raise ResultNotReady(statement_handle=response.statement_handle)
|
|
@@ -265,8 +282,27 @@ class SnowflakeClient:
|
|
|
265
282
|
handle = response.statement_handle
|
|
266
283
|
if handle is None:
|
|
267
284
|
raise SnowflakeRequestError("completed statement has no handle")
|
|
268
|
-
|
|
269
|
-
|
|
285
|
+
return columns, handle, body
|
|
286
|
+
|
|
287
|
+
def _collect(
|
|
288
|
+
self,
|
|
289
|
+
response: StatementResponse,
|
|
290
|
+
*,
|
|
291
|
+
timeout: Optional[float],
|
|
292
|
+
poll: bool = True,
|
|
293
|
+
) -> Tuple[List[ColumnMeta], List[List[Any]]]:
|
|
294
|
+
columns, handle, body = self._prepare_result(
|
|
295
|
+
response, timeout=timeout, poll=poll
|
|
296
|
+
)
|
|
297
|
+
return columns, fetch_all_partitions(self._transport, handle, body)
|
|
298
|
+
|
|
299
|
+
def _stream_result(
|
|
300
|
+
self, sql: str, params: Params
|
|
301
|
+
) -> Tuple[List[ColumnMeta], Generator[Row, None, None]]:
|
|
302
|
+
columns, handle, body = self._prepare_result(
|
|
303
|
+
self._run(sql, params), timeout=self._statement_timeout
|
|
304
|
+
)
|
|
305
|
+
return columns, iter_result_rows(self._transport, handle, body, columns)
|
|
270
306
|
|
|
271
307
|
# -- query surface ----------------------------------------------------
|
|
272
308
|
|
|
@@ -276,6 +312,60 @@ class SnowflakeClient:
|
|
|
276
312
|
columns, raw_rows = self._collect(response, timeout=self._statement_timeout)
|
|
277
313
|
return coerce_rows(raw_rows, columns)
|
|
278
314
|
|
|
315
|
+
def query_stream(
|
|
316
|
+
self, sql: str, params: Params = None
|
|
317
|
+
) -> Generator[Row, None, None]:
|
|
318
|
+
"""Yield converted rows, submitting on first iteration without prefetch.
|
|
319
|
+
|
|
320
|
+
Memory scales with the largest decoded server partition. Keep the client
|
|
321
|
+
open while iterating; close a retained stream when stopping early.
|
|
322
|
+
"""
|
|
323
|
+
_, rows = self._stream_result(sql, params)
|
|
324
|
+
yield from rows
|
|
325
|
+
|
|
326
|
+
def query_batches(
|
|
327
|
+
self, sql: str, params: Params = None, *, batch_size: int = 1000
|
|
328
|
+
) -> Generator[List[Row], None, None]:
|
|
329
|
+
"""Yield batches of at most batch_size rows across partition boundaries."""
|
|
330
|
+
validate_batch_size(batch_size)
|
|
331
|
+
with closing(self.query_stream(sql, params)) as rows:
|
|
332
|
+
batch: List[Row] = []
|
|
333
|
+
for row in rows:
|
|
334
|
+
batch.append(row)
|
|
335
|
+
if len(batch) == batch_size:
|
|
336
|
+
yield batch
|
|
337
|
+
batch = []
|
|
338
|
+
if batch:
|
|
339
|
+
yield batch
|
|
340
|
+
|
|
341
|
+
def export_csv(self, sql: str, output: TextIO, params: Params = None) -> int:
|
|
342
|
+
"""Stream CSV with a header to an open text sink; return the data-row count.
|
|
343
|
+
|
|
344
|
+
The caller owns the sink. Failures propagate and may leave partial output.
|
|
345
|
+
"""
|
|
346
|
+
columns, rows = self._stream_result(sql, params)
|
|
347
|
+
with closing(rows):
|
|
348
|
+
writer = CSVWriter(output, columns)
|
|
349
|
+
count = 0
|
|
350
|
+
for row in rows:
|
|
351
|
+
writer.write(row)
|
|
352
|
+
count += 1
|
|
353
|
+
return count
|
|
354
|
+
|
|
355
|
+
def export_jsonl(self, sql: str, output: TextIO, params: Params = None) -> int:
|
|
356
|
+
"""Stream JSON objects to an open text sink; return the row count.
|
|
357
|
+
|
|
358
|
+
The caller owns the sink. Failures propagate and may leave partial output.
|
|
359
|
+
"""
|
|
360
|
+
columns, rows = self._stream_result(sql, params)
|
|
361
|
+
with closing(rows):
|
|
362
|
+
validate_export_columns(columns)
|
|
363
|
+
count = 0
|
|
364
|
+
for row in rows:
|
|
365
|
+
write_jsonl(output, row)
|
|
366
|
+
count += 1
|
|
367
|
+
return count
|
|
368
|
+
|
|
279
369
|
def query_one(self, sql: str, params: Params = None) -> Optional[Row]:
|
|
280
370
|
"""Return the first row, or ``None`` if the result is empty."""
|
|
281
371
|
rows = self.query(sql, params)
|
|
@@ -14,6 +14,7 @@ __all__ = [
|
|
|
14
14
|
"SnowflakeConfigError",
|
|
15
15
|
"SnowflakeAuthError",
|
|
16
16
|
"SnowflakeRequestError",
|
|
17
|
+
"SnowflakeForbiddenError",
|
|
17
18
|
"SnowflakeProgrammingError",
|
|
18
19
|
"SnowflakeTimeoutError",
|
|
19
20
|
"SnowflakeRetryError",
|
|
@@ -62,6 +63,15 @@ class SnowflakeRequestError(SnowflakeError):
|
|
|
62
63
|
self.statement_handle = statement_handle
|
|
63
64
|
|
|
64
65
|
|
|
66
|
+
class SnowflakeForbiddenError(SnowflakeRequestError):
|
|
67
|
+
"""Snowflake refused an authenticated request (HTTP 403).
|
|
68
|
+
|
|
69
|
+
The JWT was accepted but the request is not allowed, so retrying with the
|
|
70
|
+
same identity will not help. A subclass of :class:`SnowflakeRequestError`,
|
|
71
|
+
so existing handlers for that type still catch it.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
|
|
65
75
|
class SnowflakeProgrammingError(SnowflakeRequestError):
|
|
66
76
|
"""The submitted SQL failed to compile or execute (HTTP 422).
|
|
67
77
|
|