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.
Files changed (45) hide show
  1. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/CONTRIBUTING.md +1 -1
  2. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/PKG-INFO +38 -14
  3. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/README.md +32 -8
  4. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/pyproject.toml +17 -16
  5. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/snowflake_sql_api/__init__.py +2 -0
  6. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/snowflake_sql_api/_version.py +2 -2
  7. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/snowflake_sql_api/aclient.py +108 -6
  8. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/snowflake_sql_api/client.py +96 -6
  9. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/snowflake_sql_api/exceptions.py +10 -0
  10. snowflake_sql_api-0.1.3/snowflake_sql_api/exports.py +71 -0
  11. snowflake_sql_api-0.1.3/snowflake_sql_api/pagination.py +131 -0
  12. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/snowflake_sql_api/transport.py +14 -4
  13. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/snowflake_sql_api/types.py +10 -6
  14. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/conftest.py +6 -0
  15. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/smoke/test_smoke.py +37 -0
  16. snowflake_sql_api-0.1.3/tests/streaming_support.py +73 -0
  17. snowflake_sql_api-0.1.3/tests/test_exports.py +221 -0
  18. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/test_import_footprint.py +1 -3
  19. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/test_package.py +3 -0
  20. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/test_regression.py +61 -5
  21. snowflake_sql_api-0.1.3/tests/test_streaming.py +279 -0
  22. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/test_testing.py +0 -2
  23. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/test_transport.py +23 -0
  24. snowflake_sql_api-0.1.1/snowflake_sql_api/pagination.py +0 -53
  25. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/.gitignore +0 -0
  26. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/LICENSE +0 -0
  27. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/snowflake_sql_api/auth.py +0 -0
  28. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/snowflake_sql_api/bindings.py +0 -0
  29. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/snowflake_sql_api/cli.py +0 -0
  30. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/snowflake_sql_api/escaping.py +0 -0
  31. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/snowflake_sql_api/py.typed +0 -0
  32. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/snowflake_sql_api/retry.py +0 -0
  33. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/snowflake_sql_api/row_mapping.py +0 -0
  34. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/snowflake_sql_api/testing.py +0 -0
  35. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/__init__.py +0 -0
  36. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/smoke/__init__.py +0 -0
  37. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/support.py +0 -0
  38. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/test_aclient.py +0 -0
  39. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/test_auth.py +0 -0
  40. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/test_bindings.py +0 -0
  41. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/test_cli.py +0 -0
  42. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/test_client.py +0 -0
  43. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/test_escaping.py +0 -0
  44. {snowflake_sql_api-0.1.1 → snowflake_sql_api-0.1.3}/tests/test_retry.py +0 -0
  45. {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.9-3.13.
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.4
1
+ Metadata-Version: 2.5
2
2
  Name: snowflake-sql-api
3
- Version: 0.1.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.9
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<25.12,>=24.0; extra == 'dev'
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
+ [![CI](https://github.com/hampsterx/snowflake-sql-api/actions/workflows/ci.yml/badge.svg)](https://github.com/hampsterx/snowflake-sql-api/actions/workflows/ci.yml)
68
+ [![PyPI](https://img.shields.io/pypi/v/snowflake-sql-api)](https://pypi.org/project/snowflake-sql-api/)
69
+ [![Python versions](https://img.shields.io/pypi/pyversions/snowflake-sql-api)](https://pypi.org/project/snowflake-sql-api/)
70
+ [![License: MIT](https://img.shields.io/pypi/l/snowflake-sql-api)](LICENSE)
71
+ [![Typed](https://img.shields.io/badge/typing-typed-blue)](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: in active development, not yet released.** The API may change before `v0.1.0`.
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), has an 8-20 s cold start, and
76
- ships a compiled extension that slows container builds. For serverless and AWS
77
- Lambda workloads that only need to run SQL, that is a lot of weight.
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` with no dependency on
149
- the official connector (small install, fast cold start), paired sync and async
150
- clients, and keypair auth, type coercion, partition handling, and a CLI as tested
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
+ [![CI](https://github.com/hampsterx/snowflake-sql-api/actions/workflows/ci.yml/badge.svg)](https://github.com/hampsterx/snowflake-sql-api/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/snowflake-sql-api)](https://pypi.org/project/snowflake-sql-api/)
5
+ [![Python versions](https://img.shields.io/pypi/pyversions/snowflake-sql-api)](https://pypi.org/project/snowflake-sql-api/)
6
+ [![License: MIT](https://img.shields.io/pypi/l/snowflake-sql-api)](LICENSE)
7
+ [![Typed](https://img.shields.io/badge/typing-typed-blue)](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: in active development, not yet released.** The API may change before `v0.1.0`.
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), has an 8-20 s cold start, and
12
- ships a compiled extension that slows container builds. For serverless and AWS
13
- Lambda workloads that only need to run SQL, that is a lot of weight.
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` with no dependency on
85
- the official connector (small install, fast cold start), paired sync and async
86
- clients, and keypair auth, type coercion, partition handling, and a CLI as tested
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.9"
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
- # Capped below 25.10: 25.12.0+ drop Python 3.9, which the package still
45
- # supports. Keeps CI and the pinned pre-commit hook on the same black.
46
- "black>=24.0,<25.12",
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 = "py39"
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 omitted: it would
94
- # rewrite runtime type aliases like `Optional[Sequence[Any]]` to `X | None`,
95
- # which raises at import time on Python 3.9 (PEP 604 on typing generics is 3.10+).
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 py39 grammar: our code uses no newer syntax, and a single floor target
104
- # keeps black's AST safety check parseable by the interpreter running CI.
105
- target-version = ["py39"]
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
- # Lowest target this mypy accepts; true 3.9 runtime compat is gated by the
109
- # pytest matrix (3.9-3.13), which imports every module under each interpreter.
110
- python_version = "3.10"
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.1'
22
- __version_tuple__ = version_tuple = (0, 1, 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 Any, Callable, Dict, List, Optional, Sequence, Tuple
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 .pagination import fetch_all_partitions_async
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 _collect(
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], List[List[Any]]]:
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
- raw_rows = await fetch_all_partitions_async(self._transport, handle, body)
224
- return columns, raw_rows
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 Any, Callable, Dict, List, Optional, Sequence, Tuple
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 .pagination import fetch_all_partitions
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 _collect(
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], List[List[Any]]]:
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
- raw_rows = fetch_all_partitions(self._transport, handle, body)
269
- return columns, raw_rows
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