snowflake-sql-api 0.1.1__tar.gz → 0.1.2__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.2}/PKG-INFO +7 -6
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/README.md +4 -3
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/pyproject.toml +6 -3
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/snowflake_sql_api/__init__.py +2 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/snowflake_sql_api/_version.py +2 -2
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/snowflake_sql_api/exceptions.py +10 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/snowflake_sql_api/transport.py +14 -4
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/tests/test_package.py +3 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/tests/test_regression.py +59 -3
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/tests/test_transport.py +23 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/.gitignore +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/CONTRIBUTING.md +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/LICENSE +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/snowflake_sql_api/aclient.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/snowflake_sql_api/auth.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/snowflake_sql_api/bindings.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/snowflake_sql_api/cli.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/snowflake_sql_api/client.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/snowflake_sql_api/escaping.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/snowflake_sql_api/pagination.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/snowflake_sql_api/py.typed +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/snowflake_sql_api/retry.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/snowflake_sql_api/row_mapping.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/snowflake_sql_api/testing.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/snowflake_sql_api/types.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/tests/__init__.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/tests/conftest.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/tests/smoke/__init__.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/tests/smoke/test_smoke.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/tests/support.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/tests/test_aclient.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/tests/test_auth.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/tests/test_bindings.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/tests/test_cli.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/tests/test_client.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/tests/test_escaping.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/tests/test_import_footprint.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/tests/test_retry.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/tests/test_testing.py +0 -0
- {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.2}/tests/test_types.py +0 -0
|
@@ -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.2
|
|
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
|
|
@@ -47,7 +47,7 @@ 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<25.
|
|
50
|
+
Requires-Dist: black<25.10,>=24.0; 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'
|
|
@@ -72,9 +72,10 @@ A lightweight, pure-Python client for [Snowflake's SQL API v2](https://docs.snow
|
|
|
72
72
|
## Why
|
|
73
73
|
|
|
74
74
|
The official [`snowflake-connector-python`](https://pypi.org/project/snowflake-connector-python/)
|
|
75
|
-
is ~75 MB installed (150 MB with pandas/pyarrow)
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
is ~75 MB installed (150 MB with pandas/pyarrow) and ships a compiled extension
|
|
76
|
+
that slows container builds and inflates cold starts (its import alone is ~1.5 s
|
|
77
|
+
versus ~0.3 s for this client). For serverless and AWS Lambda workloads that only
|
|
78
|
+
need to run SQL, that is a lot of weight.
|
|
78
79
|
|
|
79
80
|
`snowflake-sql-api` talks to Snowflake's SQL API over plain HTTP (`httpx`), so it
|
|
80
81
|
stays small and cold-starts quickly. Core dependencies: `httpx`, `PyJWT`,
|
|
@@ -8,9 +8,10 @@ A lightweight, pure-Python client for [Snowflake's SQL API v2](https://docs.snow
|
|
|
8
8
|
## Why
|
|
9
9
|
|
|
10
10
|
The official [`snowflake-connector-python`](https://pypi.org/project/snowflake-connector-python/)
|
|
11
|
-
is ~75 MB installed (150 MB with pandas/pyarrow)
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
is ~75 MB installed (150 MB with pandas/pyarrow) and ships a compiled extension
|
|
12
|
+
that slows container builds and inflates cold starts (its import alone is ~1.5 s
|
|
13
|
+
versus ~0.3 s for this client). For serverless and AWS Lambda workloads that only
|
|
14
|
+
need to run SQL, that is a lot of weight.
|
|
14
15
|
|
|
15
16
|
`snowflake-sql-api` talks to Snowflake's SQL API over plain HTTP (`httpx`), so it
|
|
16
17
|
stays small and cold-starts quickly. Core dependencies: `httpx`, `PyJWT`,
|
|
@@ -41,9 +41,12 @@ dev = [
|
|
|
41
41
|
"pytest-asyncio>=0.21",
|
|
42
42
|
"respx>=0.20",
|
|
43
43
|
"ruff>=0.4",
|
|
44
|
-
# Capped below 25.10
|
|
45
|
-
#
|
|
46
|
-
|
|
44
|
+
# Capped below 25.10 so CI formats with the same black the pre-commit hook
|
|
45
|
+
# pins (rev 25.9.0 in .pre-commit-config.yaml). Move the two together by
|
|
46
|
+
# hand; Dependabot ignores black (.github/dependabot.yml). Keep the cap at
|
|
47
|
+
# or below 25.12 while Python 3.9 is supported: 25.12.0+ require 3.10, so
|
|
48
|
+
# a higher cap resolves a different black per interpreter.
|
|
49
|
+
"black>=24.0,<25.10",
|
|
47
50
|
"mypy>=1.8",
|
|
48
51
|
"build>=1.0",
|
|
49
52
|
"pre-commit>=3.5",
|
|
@@ -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.2'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 2)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -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
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
"""HTTP transport against ``POST /api/v2/statements``.
|
|
2
2
|
|
|
3
3
|
Owns the actual ``httpx`` calls (sync and async), status-code interpretation
|
|
4
|
-
(200 success, 202 long-running/async, 422 execution failure, 401 auth,
|
|
5
|
-
retryable), the retry loop, and ``requestId`` reuse for
|
|
6
|
-
retried submit re-POSTs the identical body with the same
|
|
7
|
-
``retry=true`` so Snowflake deduplicates instead of
|
|
4
|
+
(200 success, 202 long-running/async, 422 execution failure, 401 auth,
|
|
5
|
+
403 forbidden, 429/5xx retryable), the retry loop, and ``requestId`` reuse for
|
|
6
|
+
idempotent retries. A retried submit re-POSTs the identical body with the same
|
|
7
|
+
``requestId`` and ``retry=true`` so Snowflake deduplicates instead of
|
|
8
|
+
double-applying DML.
|
|
8
9
|
|
|
9
10
|
Higher layers (``client``/``aclient``) talk to the transport, not to ``httpx``.
|
|
10
11
|
"""
|
|
@@ -19,6 +20,7 @@ import httpx
|
|
|
19
20
|
|
|
20
21
|
from .exceptions import (
|
|
21
22
|
SnowflakeAuthError,
|
|
23
|
+
SnowflakeForbiddenError,
|
|
22
24
|
SnowflakeProgrammingError,
|
|
23
25
|
SnowflakeRequestError,
|
|
24
26
|
SnowflakeRetryError,
|
|
@@ -84,6 +86,14 @@ def _interpret(response: httpx.Response) -> StatementResponse:
|
|
|
84
86
|
)
|
|
85
87
|
if status == 401:
|
|
86
88
|
raise SnowflakeAuthError(body.get("message", "authentication failed"))
|
|
89
|
+
if status == 403:
|
|
90
|
+
raise SnowflakeForbiddenError(
|
|
91
|
+
body.get("message", "request forbidden"),
|
|
92
|
+
status_code=status,
|
|
93
|
+
code=body.get("code"),
|
|
94
|
+
sql_state=body.get("sqlState"),
|
|
95
|
+
statement_handle=body.get("statementHandle"),
|
|
96
|
+
)
|
|
87
97
|
raise SnowflakeRequestError(
|
|
88
98
|
body.get("message", f"HTTP {status}"),
|
|
89
99
|
status_code=status,
|
|
@@ -10,6 +10,7 @@ import snowflake_sql_api as sf
|
|
|
10
10
|
from snowflake_sql_api.exceptions import (
|
|
11
11
|
ResultNotReady,
|
|
12
12
|
SnowflakeError,
|
|
13
|
+
SnowflakeForbiddenError,
|
|
13
14
|
SnowflakeProgrammingError,
|
|
14
15
|
SnowflakeRequestError,
|
|
15
16
|
)
|
|
@@ -39,6 +40,8 @@ def test_exception_hierarchy() -> None:
|
|
|
39
40
|
assert issubclass(ResultNotReady, SnowflakeError)
|
|
40
41
|
# A programming error is a request error (HTTP 422 specialization).
|
|
41
42
|
assert issubclass(SnowflakeProgrammingError, SnowflakeRequestError)
|
|
43
|
+
# So is a forbidden error (HTTP 403 specialization), keeping 403 non-breaking.
|
|
44
|
+
assert issubclass(SnowflakeForbiddenError, SnowflakeRequestError)
|
|
42
45
|
|
|
43
46
|
|
|
44
47
|
def test_request_error_carries_envelope_fields() -> None:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Named regression tests for the four spike-discovered bugs.
|
|
1
|
+
"""Named regression tests for the four spike-discovered bugs, plus later fixes.
|
|
2
2
|
|
|
3
3
|
Each fixed bug gets a test marked ``regression`` so ``pytest -k regression``
|
|
4
4
|
proves they stay fixed. Bug #2 (on_query in the streaming path) lands with
|
|
@@ -11,9 +11,13 @@ import httpx
|
|
|
11
11
|
import pytest
|
|
12
12
|
import respx
|
|
13
13
|
|
|
14
|
-
from snowflake_sql_api import SnowflakeClient
|
|
14
|
+
from snowflake_sql_api import AsyncSnowflakeClient, SnowflakeClient
|
|
15
15
|
from snowflake_sql_api.auth import normalize_account_locator
|
|
16
|
-
from snowflake_sql_api.exceptions import
|
|
16
|
+
from snowflake_sql_api.exceptions import (
|
|
17
|
+
ResultNotReady,
|
|
18
|
+
SnowflakeForbiddenError,
|
|
19
|
+
SnowflakeRequestError,
|
|
20
|
+
)
|
|
17
21
|
|
|
18
22
|
from .support import ACCOUNT, STATEMENTS_URL, USER, ok_body, running_body, statement_url
|
|
19
23
|
|
|
@@ -21,6 +25,13 @@ pytestmark = pytest.mark.regression
|
|
|
21
25
|
|
|
22
26
|
INT_COL = [{"name": "N", "type": "fixed", "scale": 0}]
|
|
23
27
|
|
|
28
|
+
FORBIDDEN_BODY = {
|
|
29
|
+
"code": "TEST403",
|
|
30
|
+
"message": "SQL API is not enabled for this account",
|
|
31
|
+
"sqlState": "TEST1",
|
|
32
|
+
"statementHandle": "forbidden-handle",
|
|
33
|
+
}
|
|
34
|
+
|
|
24
35
|
|
|
25
36
|
def test_regression_bug1_jwt_account_locator_strips_region() -> None:
|
|
26
37
|
"""Bug #1: the JWT claim account must drop the region/cloud suffix.
|
|
@@ -85,3 +96,48 @@ def test_regression_bug4_fetches_all_partitions(private_key_pem: bytes) -> None:
|
|
|
85
96
|
client.close()
|
|
86
97
|
# Partition 0 (inline) + partitions 1 and 2, in order, none dropped.
|
|
87
98
|
assert [r["N"] for r in rows] == [0, 1, 2, 3]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _assert_forbidden(exc: SnowflakeForbiddenError) -> None:
|
|
102
|
+
# Still a SnowflakeRequestError, so existing handlers keep catching 403.
|
|
103
|
+
assert isinstance(exc, SnowflakeRequestError)
|
|
104
|
+
assert exc.status_code == 403
|
|
105
|
+
assert exc.code == "TEST403"
|
|
106
|
+
assert exc.sql_state == "TEST1"
|
|
107
|
+
assert exc.statement_handle == "forbidden-handle"
|
|
108
|
+
assert str(exc) == "SQL API is not enabled for this account"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@respx.mock
|
|
112
|
+
def test_regression_403_raises_forbidden_error(private_key_pem: bytes) -> None:
|
|
113
|
+
"""HTTP 403 maps to SnowflakeForbiddenError, not the generic request error.
|
|
114
|
+
|
|
115
|
+
An authenticated-but-refused request must be distinguishable from a
|
|
116
|
+
malformed one without parsing ``status_code``.
|
|
117
|
+
"""
|
|
118
|
+
route = respx.post(STATEMENTS_URL).mock(
|
|
119
|
+
return_value=httpx.Response(403, json=FORBIDDEN_BODY)
|
|
120
|
+
)
|
|
121
|
+
client = SnowflakeClient(ACCOUNT, USER, private_key=private_key_pem)
|
|
122
|
+
with pytest.raises(SnowflakeForbiddenError) as info:
|
|
123
|
+
client.query("SELECT 1")
|
|
124
|
+
client.close()
|
|
125
|
+
_assert_forbidden(info.value)
|
|
126
|
+
# 403 is not retryable: one request, no retry loop.
|
|
127
|
+
assert route.call_count == 1
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@respx.mock
|
|
131
|
+
async def test_regression_403_raises_forbidden_error_async(
|
|
132
|
+
private_key_pem: bytes,
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Async parity for the 403 mapping."""
|
|
135
|
+
route = respx.post(STATEMENTS_URL).mock(
|
|
136
|
+
return_value=httpx.Response(403, json=FORBIDDEN_BODY)
|
|
137
|
+
)
|
|
138
|
+
client = AsyncSnowflakeClient(ACCOUNT, USER, private_key=private_key_pem)
|
|
139
|
+
with pytest.raises(SnowflakeForbiddenError) as info:
|
|
140
|
+
await client.query("SELECT 1")
|
|
141
|
+
await client.aclose()
|
|
142
|
+
_assert_forbidden(info.value)
|
|
143
|
+
assert route.call_count == 1
|
|
@@ -9,6 +9,7 @@ import respx
|
|
|
9
9
|
from snowflake_sql_api.auth import KeypairAuthenticator
|
|
10
10
|
from snowflake_sql_api.exceptions import (
|
|
11
11
|
SnowflakeAuthError,
|
|
12
|
+
SnowflakeForbiddenError,
|
|
12
13
|
SnowflakeProgrammingError,
|
|
13
14
|
SnowflakeRequestError,
|
|
14
15
|
SnowflakeRetryError,
|
|
@@ -148,6 +149,28 @@ def test_retry_exhaustion_raises_retry_error(private_key_pem: bytes) -> None:
|
|
|
148
149
|
assert route.call_count == 3 # max_attempts
|
|
149
150
|
|
|
150
151
|
|
|
152
|
+
@respx.mock
|
|
153
|
+
def test_custom_policy_retrying_403_chains_plain_request_error(
|
|
154
|
+
private_key_pem: bytes,
|
|
155
|
+
) -> None:
|
|
156
|
+
# Retried statuses never reach _interpret, so an exhausted 403 retry carries
|
|
157
|
+
# the generic SnowflakeRequestError as its cause, like every other status.
|
|
158
|
+
route = respx.post(STATEMENTS_URL).mock(
|
|
159
|
+
return_value=httpx.Response(403, json={"message": "forbidden"})
|
|
160
|
+
)
|
|
161
|
+
retry_403 = RetryPolicy(
|
|
162
|
+
max_attempts=2, base_backoff=0.0, jitter=False, retry_statuses=frozenset({403})
|
|
163
|
+
)
|
|
164
|
+
transport = make_transport(private_key_pem, retry=retry_403)
|
|
165
|
+
with pytest.raises(SnowflakeRetryError) as info:
|
|
166
|
+
transport.submit({"statement": "x"}, request_id="r1")
|
|
167
|
+
cause = info.value.__cause__
|
|
168
|
+
assert isinstance(cause, SnowflakeRequestError)
|
|
169
|
+
assert not isinstance(cause, SnowflakeForbiddenError)
|
|
170
|
+
assert cause.status_code == 403
|
|
171
|
+
assert route.call_count == 2
|
|
172
|
+
|
|
173
|
+
|
|
151
174
|
@respx.mock
|
|
152
175
|
def test_network_error_is_retried(private_key_pem: bytes) -> None:
|
|
153
176
|
route = respx.post(STATEMENTS_URL).mock(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|