arrowbricks 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ from ._streaming import (
2
+ HEARTBEAT,
3
+ QueryTimeout,
4
+ ReplayableArrowChunk,
5
+ await_with_heartbeat,
6
+ stream_query_json,
7
+ write_ipc_stream,
8
+ )
9
+ from .client import DatabricksClient
10
+ from .cursor import Connection, Cursor, connect
11
+
12
+ __all__ = [
13
+ "HEARTBEAT",
14
+ "Connection",
15
+ "Cursor",
16
+ "DatabricksClient",
17
+ "QueryTimeout",
18
+ "ReplayableArrowChunk",
19
+ "await_with_heartbeat",
20
+ "connect",
21
+ "stream_query_json",
22
+ "write_ipc_stream",
23
+ ]
@@ -0,0 +1,263 @@
1
+ """Chunk fetching, heartbeats, and Arrow (de)serialization -- everything that
2
+ turns a DatabricksClient's raw chunk bytes into Arrow, via arro3 directly.
3
+ Single Arrow engine, no pluggable backend: arrowbricks' whole reason to exist
4
+ is "Databricks to Arrow via arro3", so there's nothing to make pluggable.
5
+
6
+ `write_ipc_stream` always passes `compression=None` -- arro3's own default
7
+ (`compression="LZ4"`) body-compresses every record batch, which DuckDB's own
8
+ Arrow C Data Interface reader decompresses transparently but which other
9
+ Arrow IPC readers may not support at all (observed: duckdb-wasm's browser-side
10
+ decoder silently fails to parse it). Plain, uncompressed bodies are the safe
11
+ default for a library whose bytes might end up read by anything."""
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import contextlib
17
+ import io
18
+ from collections.abc import AsyncIterator, Awaitable
19
+ from typing import Any, BinaryIO, TypeVar
20
+
21
+ import arro3.core as core
22
+ import arro3.io as aio
23
+
24
+ from .client import DatabricksClient
25
+
26
+ __all__ = [
27
+ "HEARTBEAT",
28
+ "QueryTimeout",
29
+ "ReplayableArrowChunk",
30
+ "await_with_heartbeat",
31
+ "fetch_arrow_chunks_for_statement",
32
+ "fetch_arrow_chunks_with_manifest",
33
+ "stream_query_json",
34
+ "write_ipc_stream",
35
+ ]
36
+
37
+ # How often a caller waiting on a slow Databricks round-trip (warehouse cold
38
+ # start, a long-running statement) gets a HEARTBEAT -- pick something well
39
+ # under whatever idle-connection ceiling sits between your server and its
40
+ # client (e.g. many PaaS load balancers cut an idle SSE connection around
41
+ # ~230s) if you're forwarding these as keep-alive pings.
42
+ _HEARTBEAT_INTERVAL_S = 15.0
43
+
44
+
45
+ class QueryTimeout(RuntimeError):
46
+ """Raised when a query exceeds its `total_timeout_s`."""
47
+
48
+
49
+ class _Heartbeat:
50
+ __slots__ = ()
51
+
52
+ def __repr__(self) -> str:
53
+ return "HEARTBEAT"
54
+
55
+
56
+ HEARTBEAT = _Heartbeat()
57
+
58
+ T = TypeVar("T")
59
+
60
+
61
+ def write_ipc_stream(stream: Any, buf: BinaryIO) -> None:
62
+ """Writes `stream` (anything implementing `__arrow_c_stream__`, e.g. an
63
+ arro3 Table/RecordBatchReader or a ReplayableArrowChunk) as Arrow-IPC
64
+ stream bytes -- always uncompressed, see module docstring."""
65
+ aio.write_ipc_stream(stream, buf, compression=None)
66
+
67
+
68
+ class ReplayableArrowChunk:
69
+ """Wraps one Arrow IPC-stream byte chunk so it can be handed to something
70
+ that calls `__arrow_c_stream__` more than once per relation (a schema
71
+ peek, then the actual scan -- DuckDB's registration path does this, for
72
+ one). A plain parsed stream is single-use and raises on the second call,
73
+ so this re-parses from the cached bytes every call instead. The bytes are
74
+ already fully in memory (just downloaded), so re-parsing costs a cheap
75
+ second pass, not a second network fetch."""
76
+
77
+ __slots__ = ("_data", "chunk_index", "declared_row_count")
78
+
79
+ def __init__(self, data: bytes, chunk_index: int, declared_row_count: int | None = None) -> None:
80
+ self._data = data
81
+ self.chunk_index = chunk_index
82
+ self.declared_row_count = declared_row_count
83
+
84
+ def __arrow_c_stream__(self, requested_schema: object = None) -> object:
85
+ reader = aio.read_ipc_stream(io.BytesIO(self._data))
86
+ return reader.__arrow_c_stream__(requested_schema)
87
+
88
+ def nbytes(self) -> int:
89
+ return len(self._data)
90
+
91
+ def to_table(self) -> core.Table:
92
+ """Parses this chunk's bytes into an arro3 Table -- the entry point
93
+ `cursor.py`'s result-set buffering uses to work with chunks at the
94
+ Arrow level (concat/slice) rather than re-parsing bytes per access."""
95
+ return core.Table.from_arrow(self)
96
+
97
+
98
+ async def fetch_arrow_chunks_with_manifest(
99
+ client: DatabricksClient,
100
+ sql: str,
101
+ *,
102
+ catalog: str | None = None,
103
+ schema: str | None = None,
104
+ parameters: list[dict[str, Any]] | None = None,
105
+ ) -> tuple[str, dict[str, Any], AsyncIterator[ReplayableArrowChunk]]:
106
+ """Submits `sql` and returns (statement_id, manifest, chunk_iterator).
107
+ total_row_count/total_chunk_count are already in the manifest once the
108
+ statement succeeds, so callers needing a progress-bar target don't need a
109
+ separate preflight COUNT(*)."""
110
+ statement_id, manifest = await client.execute_arrow_statement(
111
+ sql, catalog=catalog, schema=schema, parameters=parameters
112
+ )
113
+ chunk_metas = manifest.get("chunks") or []
114
+ return statement_id, manifest, fetch_arrow_chunks_for_statement(client, statement_id, chunk_metas)
115
+
116
+
117
+ async def fetch_arrow_chunks_for_statement(
118
+ client: DatabricksClient, statement_id: str, chunk_metas: list[dict[str, Any]]
119
+ ) -> AsyncIterator[ReplayableArrowChunk]:
120
+ async for chunk_bytes, row_count, chunk_index in client.stream_chunks_by_index(statement_id, chunk_metas):
121
+ if chunk_bytes:
122
+ yield ReplayableArrowChunk(chunk_bytes, chunk_index, declared_row_count=row_count)
123
+
124
+
125
+ async def await_with_heartbeat(
126
+ aw: Awaitable[T], *, interval_s: float = _HEARTBEAT_INTERVAL_S, total_timeout_s: float | None = None
127
+ ) -> AsyncIterator[Any]:
128
+ """Wraps a single slow awaitable with periodic HEARTBEAT yields, so a
129
+ caller streaming this over e.g. SSE never goes silent for the whole wait.
130
+ Yields HEARTBEAT zero or more times, then yields the awaitable's real
131
+ result exactly once. Re-raises whatever `aw` raised, or QueryTimeout if
132
+ `total_timeout_s` elapses first."""
133
+ task: asyncio.Task[T] = asyncio.ensure_future(aw)
134
+ loop = asyncio.get_running_loop()
135
+ deadline = loop.time() + total_timeout_s if total_timeout_s is not None else None
136
+ try:
137
+ while not task.done():
138
+ wait_for = interval_s if deadline is None else min(interval_s, max(deadline - loop.time(), 0.0))
139
+ done, _ = await asyncio.wait({task}, timeout=wait_for)
140
+ if not done:
141
+ if deadline is not None and loop.time() >= deadline:
142
+ task.cancel()
143
+ with contextlib.suppress(asyncio.CancelledError):
144
+ await task
145
+ raise QueryTimeout(f"Query exceeded {total_timeout_s}s timeout")
146
+ yield HEARTBEAT
147
+ yield await task
148
+ finally:
149
+ if not task.done():
150
+ task.cancel()
151
+ with contextlib.suppress(asyncio.CancelledError):
152
+ await task
153
+
154
+
155
+ async def heartbeat_over_stream(
156
+ aiter: AsyncIterator[T], *, interval_s: float = _HEARTBEAT_INTERVAL_S, total_timeout_s: float | None = None
157
+ ) -> AsyncIterator[Any]:
158
+ """Like await_with_heartbeat, but for a stream of items rather than one
159
+ final result: yields HEARTBEAT whenever the wait for the *next* item
160
+ exceeds interval_s, and otherwise passes each item through as it arrives.
161
+ Used by stream_query_json (and cursor.py's execute_streamed) so a slow
162
+ chunk mid-stream -- not just a cold warehouse start before the first one
163
+ -- never lets a downstream SSE connection go silent for the whole wait."""
164
+ loop = asyncio.get_running_loop()
165
+ deadline = loop.time() + total_timeout_s if total_timeout_s is not None else None
166
+ it = aiter.__aiter__()
167
+ task: asyncio.Task[Any] | None = None
168
+ try:
169
+ while True:
170
+ if task is None:
171
+ task = asyncio.ensure_future(it.__anext__())
172
+ wait_for = interval_s if deadline is None else min(interval_s, max(deadline - loop.time(), 0.0))
173
+ done, _ = await asyncio.wait({task}, timeout=wait_for)
174
+ if not done:
175
+ if deadline is not None and loop.time() >= deadline:
176
+ task.cancel()
177
+ with contextlib.suppress(asyncio.CancelledError):
178
+ await task
179
+ raise QueryTimeout(f"Query exceeded {total_timeout_s}s timeout")
180
+ yield HEARTBEAT
181
+ continue
182
+ try:
183
+ yield task.result()
184
+ except StopAsyncIteration:
185
+ return
186
+ finally:
187
+ task = None
188
+ finally:
189
+ if task is not None and not task.done():
190
+ task.cancel()
191
+ with contextlib.suppress(asyncio.CancelledError):
192
+ await task
193
+
194
+
195
+ def windowed_sql(sql: str, *, row_limit: int | None, offset: int | None) -> str:
196
+ """Pushes LIMIT/OFFSET into the SQL submitted to Databricks -- a query
197
+ should never fetch more rows from the warehouse than the caller wants."""
198
+ if row_limit is None and not offset:
199
+ return sql
200
+ if row_limit is None:
201
+ return f"SELECT * FROM ({sql}) _q OFFSET {offset}" # noqa: S608
202
+ if offset:
203
+ return f"SELECT * FROM ({sql}) _q LIMIT {row_limit} OFFSET {offset}" # noqa: S608
204
+ return f"SELECT * FROM ({sql}) _q LIMIT {row_limit}" # noqa: S608
205
+
206
+
207
+ def _write_ndjson(chunk: ReplayableArrowChunk) -> bytes:
208
+ buf = io.BytesIO()
209
+ # explicit_nulls=True: arro3 omits null-valued keys by default -- without
210
+ # this a row's JSON shape would vary by which columns happen to be null
211
+ # in it.
212
+ aio.write_ndjson(chunk, buf, explicit_nulls=True)
213
+ return buf.getvalue()
214
+
215
+
216
+ async def stream_query_json(
217
+ client: DatabricksClient,
218
+ sql: str,
219
+ *,
220
+ params: list[dict[str, Any]] | None = None,
221
+ row_limit: int | None = None,
222
+ offset: int | None = None,
223
+ catalog: str | None = None,
224
+ schema: str | None = None,
225
+ total_timeout_s: float | None = None,
226
+ ) -> AsyncIterator[Any]:
227
+ """Yields HEARTBEAT while waiting on Databricks, then each result row as a
228
+ ready-to-send JSON string (arro3's native `write_ndjson`) -- one per SSE
229
+ frame, say.
230
+
231
+ Unlike cursor.py's fetch methods, this registers and emits each Databricks
232
+ chunk AS IT ARRIVES rather than buffering the full result first -- the
233
+ first row reaches the caller after ~one chunk's fetch time, not the whole
234
+ statement's, and at most a handful of chunks' bytes (bounded by the
235
+ client's own chunk_fetch_concurrency) are ever held in memory at once, not
236
+ O(whole result). Chunks can arrive out of order, so out-of-order arrivals
237
+ sit in a small `pending` buffer until the next expected chunk_index shows
238
+ up -- that buffer stays bounded by concurrency, it never grows to the full
239
+ result.
240
+
241
+ Note this yields a whole chunk's rows at once (write_ndjson has no
242
+ incremental/row-at-a-time mode) -- Databricks' own chunk sizing already
243
+ bounds how much that is, so the overall memory-bounded-across-chunks
244
+ guarantee above still holds, just at chunk granularity."""
245
+ sql = windowed_sql(sql, row_limit=row_limit, offset=offset)
246
+ _statement_id, _manifest, chunk_iter = await fetch_arrow_chunks_with_manifest(
247
+ client, sql, catalog=catalog, schema=schema, parameters=params
248
+ )
249
+
250
+ pending: dict[int, ReplayableArrowChunk] = {}
251
+ next_idx = 0
252
+ loop = asyncio.get_running_loop()
253
+ async for item in heartbeat_over_stream(chunk_iter, total_timeout_s=total_timeout_s):
254
+ if item is HEARTBEAT:
255
+ yield HEARTBEAT
256
+ continue
257
+ pending[item.chunk_index] = item
258
+ while next_idx in pending:
259
+ chunk = pending.pop(next_idx)
260
+ blob = await loop.run_in_executor(None, _write_ndjson, chunk)
261
+ for line in blob.splitlines():
262
+ yield line.decode()
263
+ next_idx += 1
arrowbricks/client.py ADDED
@@ -0,0 +1,374 @@
1
+ """Async client for the Databricks SQL Statement Execution API, tuned for
2
+ pulling large results out as Arrow-IPC chunks rather than running a single
3
+ small query. No dependency on Arrow at all -- that lives in `_streaming.py`
4
+ and `cursor.py`, so this client is reusable by any caller regardless of what
5
+ they do with the raw chunk bytes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import inspect
12
+ import time
13
+ from collections.abc import AsyncIterator, Awaitable, Callable
14
+ from typing import Any, TypeVar, cast
15
+
16
+ import httpx
17
+
18
+ _POLL_INTERVAL_S = 2.0
19
+ _TERMINAL_STATES = {"SUCCEEDED", "FAILED", "CANCELED", "CLOSED"}
20
+ _RETRY_ATTEMPTS = 6
21
+ _RETRY_MAX_WAIT_S = 20.0
22
+
23
+ TokenProvider = Callable[[], "str | Awaitable[str]"]
24
+
25
+ _T = TypeVar("_T")
26
+
27
+
28
+ def _is_transient_error(exc: BaseException) -> bool:
29
+ """401/403/429/5xx are treated as transient and retried -- these have been
30
+ observed in practice to resolve themselves (e.g. a just-granted permission
31
+ or a just-refreshed token not yet visible to the request that raced it)
32
+ rather than indicating a persistent problem. httpx.TimeoutException/
33
+ NetworkError are deliberately excluded: a genuinely stalled connection
34
+ should fail fast on the caller's own timeout, not be retried here."""
35
+ return isinstance(exc, httpx.HTTPStatusError) and (
36
+ exc.response.status_code in (401, 403, 408, 429) or exc.response.status_code >= 500
37
+ )
38
+
39
+
40
+ async def _retry_call(fn: Callable[[], Awaitable[_T]]) -> _T:
41
+ """Retry fn() with exponential backoff (1s, 2s, 4s, ... capped at
42
+ _RETRY_MAX_WAIT_S) on transient errors, up to _RETRY_ATTEMPTS total tries.
43
+ A hand-rolled loop instead of tenacity -- this is the only retry pattern
44
+ in the whole client, so a dependency for it isn't worth it."""
45
+ attempt = 0
46
+ while True:
47
+ try:
48
+ return await fn()
49
+ except Exception as exc:
50
+ if attempt == _RETRY_ATTEMPTS - 1 or not _is_transient_error(exc):
51
+ raise
52
+ await asyncio.sleep(min(_RETRY_MAX_WAIT_S, 2.0**attempt))
53
+ attempt += 1
54
+
55
+
56
+ def _raise_for_failed(status: dict[str, Any]) -> None:
57
+ state = status.get("state")
58
+ if state == "FAILED":
59
+ err = status.get("error", {})
60
+ raise RuntimeError(f"Databricks statement failed [{err.get('error_code')}]: {err.get('message')}")
61
+ if state == "CANCELED":
62
+ raise RuntimeError("Databricks statement was canceled")
63
+
64
+
65
+ class DatabricksClient:
66
+ """One Databricks SQL warehouse endpoint. Auth is entirely bring-your-own:
67
+ pass a static `token`, or a `token_provider` callable (sync or async) that
68
+ returns one -- this client has no opinion on *how* you get a token (a
69
+ personal access token, an OAuth M2M flow, a cloud-provider credential
70
+ chain) and no cloud-SDK dependency baked in. If your provider is expensive
71
+ to call (e.g. shells out to a CLI), cache/refresh inside it -- this client
72
+ calls it on every request and does no caching of its own."""
73
+
74
+ def __init__(
75
+ self,
76
+ host: str,
77
+ warehouse_id: str,
78
+ *,
79
+ token: str | None = None,
80
+ token_provider: TokenProvider | None = None,
81
+ http_timeout: float = 60.0,
82
+ wait_timeout: str = "30s",
83
+ chunk_fetch_concurrency: int = 6,
84
+ warehouse_start_timeout: float = 300.0,
85
+ ) -> None:
86
+ if not token and not token_provider:
87
+ raise ValueError("DatabricksClient needs either `token` or `token_provider`")
88
+ self._host = host.rstrip("/")
89
+ if not self._host.startswith("https://"):
90
+ self._host = f"https://{self._host}"
91
+ self.warehouse_id = warehouse_id
92
+ self._token = token
93
+ self._token_provider = token_provider
94
+ self.http_timeout = http_timeout
95
+ self.wait_timeout = wait_timeout
96
+ # Each concurrent fetch slot (plus its matching queued-but-unconsumed
97
+ # slot, see _fetch_chunks_with_backpressure) holds a whole chunk's raw
98
+ # bytes in plain Python memory. Keep this modest by default -- it's
99
+ # I/O-bound network fetching, so parallelism past single digits buys
100
+ # little, while every extra slot is more memory held hostage behind a
101
+ # slow consumer.
102
+ self.chunk_fetch_concurrency = chunk_fetch_concurrency
103
+ self.warehouse_start_timeout = warehouse_start_timeout
104
+
105
+ async def _bearer_token(self) -> str:
106
+ if self._token is not None:
107
+ return self._token
108
+ assert self._token_provider is not None # noqa: S101 -- enforced in __init__, not a test assertion
109
+ result = self._token_provider()
110
+ if inspect.isawaitable(result):
111
+ return await cast("Awaitable[str]", result)
112
+ return cast(str, result)
113
+
114
+ async def _headers(self, content_type: str = "application/json") -> dict[str, str]:
115
+ return {
116
+ "Authorization": f"Bearer {await self._bearer_token()}",
117
+ "Content-Type": content_type,
118
+ }
119
+
120
+ async def _authed_request(
121
+ self, client: httpx.AsyncClient, method: str, url: str, *, content_type: str = "application/json", **kwargs: Any
122
+ ) -> httpx.Response:
123
+ async def _do() -> httpx.Response:
124
+ resp = await client.request(
125
+ method, url, headers=await self._headers(content_type), timeout=self.http_timeout, **kwargs
126
+ )
127
+ resp.raise_for_status()
128
+ return resp
129
+
130
+ return await _retry_call(_do)
131
+
132
+ async def _ensure_warehouse_running(self, client: httpx.AsyncClient) -> None:
133
+ """Explicitly wait for the warehouse to reach RUNNING before submitting
134
+ a statement, rather than relying on the Statement Execution API's own
135
+ implicit auto-start. A cold warehouse's catalog credential cache needs
136
+ a moment to catch up right after startup -- submitting straight into
137
+ that window is a common source of transient, identity-scoped 403s.
138
+ Fast path: a single GET when already RUNNING, so this adds no
139
+ meaningful overhead once warm."""
140
+ url = f"{self._host}/api/2.0/sql/warehouses/{self.warehouse_id}"
141
+ resp = await self._authed_request(client, "GET", url)
142
+ state = resp.json().get("state")
143
+ if state == "RUNNING":
144
+ return
145
+
146
+ if state == "STOPPED":
147
+ await self._authed_request(client, "POST", f"{url}/start")
148
+
149
+ deadline = time.monotonic() + self.warehouse_start_timeout
150
+ while time.monotonic() < deadline:
151
+ await asyncio.sleep(_POLL_INTERVAL_S)
152
+ resp = await self._authed_request(client, "GET", url)
153
+ state = resp.json().get("state")
154
+ if state == "RUNNING":
155
+ return
156
+ # Falls through and lets the statement submission itself surface
157
+ # whatever's actually wrong -- proceeding anyway rather than raising
158
+ # here avoids a false negative if the warehouse comes up moments later.
159
+
160
+ async def _execute_statement(
161
+ self,
162
+ statement: str,
163
+ *,
164
+ format: str,
165
+ catalog: str | None,
166
+ schema: str | None,
167
+ wait_timeout: str | None,
168
+ parameters: list[dict[str, Any]] | None,
169
+ ) -> tuple[str, dict[str, Any]]:
170
+ """Submit an EXTERNAL_LINKS statement in the given result format and
171
+ poll until terminal. Returns (statement_id, manifest). `parameters`,
172
+ if given, is Databricks' own named-parameter format --
173
+ [{"name": ..., "value": ..., "type": ...}] bound against `:name`
174
+ markers in `statement`."""
175
+ body: dict[str, Any] = {
176
+ "warehouse_id": self.warehouse_id,
177
+ "statement": statement,
178
+ "disposition": "EXTERNAL_LINKS",
179
+ "format": format,
180
+ "wait_timeout": wait_timeout or self.wait_timeout,
181
+ "on_wait_timeout": "CONTINUE",
182
+ }
183
+ if catalog:
184
+ body["catalog"] = catalog
185
+ if schema:
186
+ body["schema"] = schema
187
+ if parameters:
188
+ body["parameters"] = parameters
189
+
190
+ async with httpx.AsyncClient() as client:
191
+ await self._ensure_warehouse_running(client)
192
+ resp = await self._authed_request(client, "POST", f"{self._host}/api/2.0/sql/statements", json=body)
193
+ data = resp.json()
194
+
195
+ status = data.get("status", {})
196
+ while status.get("state") not in _TERMINAL_STATES:
197
+ statement_id = data["statement_id"]
198
+ await asyncio.sleep(_POLL_INTERVAL_S)
199
+ resp = await self._authed_request(client, "GET", f"{self._host}/api/2.0/sql/statements/{statement_id}")
200
+ data = resp.json()
201
+ status = data.get("status", {})
202
+
203
+ _raise_for_failed(status)
204
+ return data["statement_id"], data.get("manifest") or {}
205
+
206
+ async def execute_arrow_statement(
207
+ self,
208
+ statement: str,
209
+ *,
210
+ catalog: str | None = None,
211
+ schema: str | None = None,
212
+ wait_timeout: str | None = None,
213
+ parameters: list[dict[str, Any]] | None = None,
214
+ ) -> tuple[str, dict[str, Any]]:
215
+ """Like _execute_statement, fixed to ARROW_STREAM -- for callers that
216
+ ingest the result as Arrow (see _streaming.py/cursor.py)."""
217
+ return await self._execute_statement(
218
+ statement,
219
+ format="ARROW_STREAM",
220
+ catalog=catalog,
221
+ schema=schema,
222
+ wait_timeout=wait_timeout,
223
+ parameters=parameters,
224
+ )
225
+
226
+ async def execute_json_statement(
227
+ self,
228
+ statement: str,
229
+ *,
230
+ catalog: str | None = None,
231
+ schema: str | None = None,
232
+ wait_timeout: str | None = None,
233
+ parameters: list[dict[str, Any]] | None = None,
234
+ ) -> tuple[str, dict[str, Any]]:
235
+ """Like _execute_statement, fixed to JSON_ARRAY -- each fetched
236
+ chunk's bytes (see stream_chunks_by_index) are then a plain JSON array
237
+ of rows, for callers that want Python values without an Arrow parse."""
238
+ return await self._execute_statement(
239
+ statement,
240
+ format="JSON_ARRAY",
241
+ catalog=catalog,
242
+ schema=schema,
243
+ wait_timeout=wait_timeout,
244
+ parameters=parameters,
245
+ )
246
+
247
+ async def upload_volume_file(self, volume_path: str, data: bytes) -> None:
248
+ """Uploads `data` to a Unity Catalog volume path via the Files API,
249
+ overwriting anything already there. `volume_path` is caller-supplied
250
+ in full (e.g. `/Volumes/my_catalog/my_schema/my_volume/some/file.parquet`)
251
+ -- this package has no knowledge of any specific catalog/schema/volume."""
252
+ async with httpx.AsyncClient() as client:
253
+ await self._authed_request(
254
+ client,
255
+ "PUT",
256
+ f"{self._host}/api/2.0/fs/files{volume_path}",
257
+ params={"overwrite": "true"},
258
+ content_type="application/octet-stream",
259
+ content=data,
260
+ )
261
+
262
+ async def delete_volume_file(self, volume_path: str) -> None:
263
+ """Deletes a file at `volume_path` (see upload_volume_file). A 404 is
264
+ treated as success -- the file is already gone, which is fine for
265
+ idempotent staging cleanup."""
266
+ async with httpx.AsyncClient() as client:
267
+ try:
268
+ await self._authed_request(client, "DELETE", f"{self._host}/api/2.0/fs/files{volume_path}")
269
+ except httpx.HTTPStatusError as exc:
270
+ if exc.response.status_code != 404:
271
+ raise
272
+
273
+ async def stream_chunks_by_index(
274
+ self, statement_id: str, chunk_metas: list[dict[str, Any]]
275
+ ) -> AsyncIterator[tuple[bytes, int | None, int]]:
276
+ """Fetch already-known chunks for a completed statement (see
277
+ execute_arrow_statement/execute_json_statement), with real
278
+ backpressure -- see _fetch_chunks_with_backpressure. Each yielded
279
+ (blob, row_count, chunk_index) is an independent, self-contained
280
+ result blob plus its row count from the manifest (`None` if the
281
+ manifest didn't carry one) and its own chunk_index, so a caller that
282
+ cares about the original row order (e.g. a query with ORDER BY) can
283
+ restore it even though chunks can complete out of order."""
284
+ async with httpx.AsyncClient() as client:
285
+ async for blob, row_count, chunk_index in self._fetch_chunks_with_backpressure(
286
+ client, statement_id, chunk_metas
287
+ ):
288
+ yield blob, row_count, chunk_index
289
+
290
+ async def _fetch_link_bytes(self, client: httpx.AsyncClient, url: str) -> bytes:
291
+ async def _do() -> bytes:
292
+ resp = await client.get(url, timeout=self.http_timeout)
293
+ resp.raise_for_status()
294
+ return resp.content
295
+
296
+ return await _retry_call(_do)
297
+
298
+ async def _fetch_chunk_index(self, client: httpx.AsyncClient, statement_id: str, chunk_index: int) -> list[bytes]:
299
+ """Resolve one chunk index to its external link(s) (usually exactly
300
+ one) and download the bytes. Each chunk index can be requested
301
+ independently -- that's what makes concurrent fetching possible."""
302
+ resp = await self._authed_request(
303
+ client, "GET", f"{self._host}/api/2.0/sql/statements/{statement_id}/result/chunks/{chunk_index}"
304
+ )
305
+ links = resp.json().get("external_links") or []
306
+ return await asyncio.gather(*(self._fetch_link_bytes(client, link["external_link"]) for link in links))
307
+
308
+ async def _fetch_chunks_with_backpressure(
309
+ self,
310
+ client: httpx.AsyncClient,
311
+ statement_id: str,
312
+ chunk_metas: list[dict[str, Any]],
313
+ ) -> AsyncIterator[tuple[bytes, int | None, int]]:
314
+ """Fetch every chunk concurrently (bounded), instead of one round trip
315
+ at a time -- for a result with hundreds of chunks, that sequential
316
+ chain dominates total latency. Chunks complete out of order; ordering
317
+ (if the caller's SQL needs it) is restored downstream, not here.
318
+
319
+ This is a bounded *worker pool*, not `asyncio.as_completed` over every
320
+ chunk task scheduled up front -- that shape bounds how many downloads
321
+ run *simultaneously* but not how many completed-and-downloaded chunks
322
+ pile up waiting for a slow consumer, since a slot frees the instant
323
+ its own download finishes, whether or not anything downstream has
324
+ consumed that chunk yet. If the consumer is slower than the network --
325
+ plausible, one async task processing vs. several concurrent
326
+ downloads -- unconsumed bytes still pile up to O(whole result)
327
+ regardless of the concurrency bound. Here, a worker blocks on
328
+ `out.put()` (a bounded queue) until the consumer actually takes the
329
+ previous chunk, so a fetch slot is only freed once its chunk has been
330
+ handed off -- backpressure reaches all the way to the download, and
331
+ peak resident chunks stay at ~concurrency + queue size, not
332
+ O(whole result)."""
333
+ concurrency = self.chunk_fetch_concurrency
334
+ work: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
335
+ for meta in chunk_metas:
336
+ work.put_nowait(meta)
337
+ out: asyncio.Queue[tuple[bytes, int | None, int] | None] = asyncio.Queue(maxsize=concurrency)
338
+
339
+ async def worker() -> None:
340
+ while True:
341
+ try:
342
+ meta = work.get_nowait()
343
+ except asyncio.QueueEmpty:
344
+ return
345
+ for blob in await self._fetch_chunk_index(client, statement_id, meta["chunk_index"]):
346
+ await out.put((blob, meta.get("row_count"), meta["chunk_index"])) # blocks here = backpressure
347
+
348
+ workers = [asyncio.create_task(worker()) for _ in range(min(concurrency, len(chunk_metas) or 1))]
349
+
350
+ async def close_when_done() -> None:
351
+ # return_exceptions=True, not the default False: with the default,
352
+ # a failing worker makes gather raise *immediately* and this
353
+ # coroutine never reaches `out.put(None)` -- the consumer's
354
+ # `await out.get()` below then blocks forever. Collect every
355
+ # worker's outcome first, unblock the consumer unconditionally,
356
+ # then re-raise whichever failed.
357
+ results = await asyncio.gather(*workers, return_exceptions=True)
358
+ await out.put(None)
359
+ for result in results:
360
+ if isinstance(result, BaseException):
361
+ raise result
362
+
363
+ closer = asyncio.create_task(close_when_done())
364
+ try:
365
+ while True:
366
+ item = await out.get()
367
+ if item is None:
368
+ break
369
+ yield item
370
+ await closer # propagate a worker failure that only surfaced after the last successful yield
371
+ finally:
372
+ closer.cancel()
373
+ for w in workers:
374
+ w.cancel()
arrowbricks/cursor.py ADDED
@@ -0,0 +1,261 @@
1
+ """A Cursor/Connection surface shaped like databricks-sql-python's (execute,
2
+ fetchone/fetchmany/fetchall, fetchall_arrow/fetchmany_arrow) -- familiar DB-API
3
+ ergonomics on top of the Statement Execution API + arro3, for callers who want
4
+ to pull rows/Arrow incrementally rather than get everything back at once.
5
+
6
+ Unlike a real DB-API cursor, this is async throughout (`execute`, `fetchone`,
7
+ etc. are all coroutines) since the underlying client is -- there's no
8
+ synchronous escape hatch, matching every other function in this package.
9
+
10
+ Chunks are fetched from Databricks lazily, only as fetchone/fetchmany/fetchall
11
+ actually need more rows -- a `fetchmany(100)` loop over a 700k-row result
12
+ never pulls more chunks than it's consumed. Chunks can arrive out of order
13
+ over the network (see client.py); a `pending` buffer holds early arrivals
14
+ until the next expected chunk_index shows up, same reasoning as
15
+ _streaming.py's stream_query_json."""
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import AsyncIterator
20
+ from typing import Any
21
+
22
+ import arro3.core as core
23
+
24
+ from ._streaming import (
25
+ HEARTBEAT,
26
+ ReplayableArrowChunk,
27
+ await_with_heartbeat,
28
+ fetch_arrow_chunks_with_manifest,
29
+ windowed_sql,
30
+ )
31
+ from .client import DatabricksClient
32
+
33
+ __all__ = ["Connection", "Cursor", "connect"]
34
+
35
+ Row = tuple[Any, ...]
36
+ Description = tuple[str, str | None, None, None, None, None, None]
37
+
38
+
39
+ def _empty_table(schema: core.Schema | None) -> core.Table:
40
+ return core.Table.from_batches([], schema=schema) if schema is not None else core.Table.from_pydict({})
41
+
42
+
43
+ def _description_from_manifest(manifest: dict[str, Any]) -> list[Description]:
44
+ columns = manifest.get("schema", {}).get("columns") or []
45
+ return [(c.get("name"), c.get("type_name"), None, None, None, None, None) for c in columns]
46
+
47
+
48
+ class _ResultSet:
49
+ """Buffers already-fetched-but-not-yet-returned rows at the Arrow level
50
+ (an arro3 Table), so fetchall_arrow/fetchmany_arrow stay zero-copy and
51
+ fetchone/fetchmany/fetchall just materialize whatever slice they need.
52
+ Not part of the public API -- reached only via Cursor."""
53
+
54
+ def __init__(self, schema: core.Schema | None, chunk_aiter: AsyncIterator[ReplayableArrowChunk]) -> None:
55
+ self.schema = schema
56
+ self._chunk_aiter = chunk_aiter
57
+ self._pending: dict[int, ReplayableArrowChunk] = {}
58
+ self._next_idx = 0
59
+ self._exhausted = False
60
+ self._buffer: core.Table | None = None
61
+ self.rownumber = 0
62
+
63
+ async def _pull_one_chunk_table(self) -> core.Table | None:
64
+ """Returns the next expected chunk's Table, in order -- checking
65
+ `_pending` FIRST, since a single earlier call can have pulled several
66
+ chunks off `_chunk_aiter` before the one it actually needed showed up
67
+ (arrival order is completion order, not chunk_index order), leaving
68
+ the rest already-fetched-and-buffered here. Only touches the network
69
+ (`_chunk_aiter.__anext__()`) once `_pending` has nothing more to give."""
70
+ while True:
71
+ if self._next_idx in self._pending:
72
+ ready = self._pending.pop(self._next_idx)
73
+ self._next_idx += 1
74
+ return ready.to_table()
75
+ if self._exhausted:
76
+ return None
77
+ try:
78
+ chunk = await self._chunk_aiter.__anext__()
79
+ except StopAsyncIteration:
80
+ self._exhausted = True
81
+ continue
82
+ self._pending[chunk.chunk_index] = chunk
83
+
84
+ async def _ensure_buffer(self, want: int) -> None:
85
+ while (self._buffer is None or self._buffer.num_rows < want) and not self._exhausted:
86
+ table = await self._pull_one_chunk_table()
87
+ if table is None:
88
+ break
89
+ if self.schema is None:
90
+ self.schema = table.schema
91
+ self._buffer = (
92
+ table
93
+ if self._buffer is None
94
+ else core.Table.from_batches(self._buffer.to_batches() + table.to_batches(), schema=self._buffer.schema)
95
+ )
96
+
97
+ async def fetchmany_arrow(self, size: int) -> core.Table:
98
+ await self._ensure_buffer(size)
99
+ if self._buffer is None or self._buffer.num_rows == 0:
100
+ return _empty_table(self.schema)
101
+ n = min(size, self._buffer.num_rows)
102
+ out = self._buffer.slice(0, n)
103
+ rest = self._buffer.slice(n)
104
+ self._buffer = rest if rest.num_rows else None
105
+ self.rownumber += n
106
+ return out
107
+
108
+ async def fetchall_arrow(self) -> core.Table:
109
+ while not self._exhausted:
110
+ await self._ensure_buffer((self._buffer.num_rows if self._buffer is not None else 0) + 1)
111
+ out = self._buffer if self._buffer is not None else _empty_table(self.schema)
112
+ self._buffer = None
113
+ self.rownumber += out.num_rows
114
+ return out
115
+
116
+ @staticmethod
117
+ def _table_to_rows(table: core.Table) -> list[Row]:
118
+ if table.num_rows == 0:
119
+ return []
120
+ columns = [table.column(i).combine_chunks().to_pylist() for i in range(table.num_columns)]
121
+ return list(zip(*columns, strict=True))
122
+
123
+ async def fetchone(self) -> Row | None:
124
+ rows = self._table_to_rows(await self.fetchmany_arrow(1))
125
+ return rows[0] if rows else None
126
+
127
+ async def fetchmany(self, size: int) -> list[Row]:
128
+ return self._table_to_rows(await self.fetchmany_arrow(size))
129
+
130
+ async def fetchall(self) -> list[Row]:
131
+ return self._table_to_rows(await self.fetchall_arrow())
132
+
133
+
134
+ class Cursor:
135
+ """One statement's worth of state: `execute()` (or `execute_streamed()`)
136
+ submits and waits for it, then fetchone/fetchmany/fetchall/fetchall_arrow/
137
+ fetchmany_arrow pull its result. Reusable across statements -- each
138
+ `execute()` call replaces the previous result set."""
139
+
140
+ def __init__(self, client: DatabricksClient) -> None:
141
+ self._client = client
142
+ self._result: _ResultSet | None = None
143
+ self.description: list[Description] | None = None
144
+
145
+ def execute_streamed(
146
+ self,
147
+ sql: str,
148
+ parameters: list[dict[str, Any]] | None = None,
149
+ *,
150
+ row_limit: int | None = None,
151
+ offset: int | None = None,
152
+ catalog: str | None = None,
153
+ schema: str | None = None,
154
+ total_timeout_s: float | None = None,
155
+ ) -> AsyncIterator[Any]:
156
+ """Like execute(), but yields HEARTBEAT while waiting on Databricks
157
+ instead of blocking silently -- for bridging e.g. an SSE connection
158
+ during a possible multi-minute cold warehouse start. Yields HEARTBEAT
159
+ zero or more times, then this same Cursor once ready to fetch."""
160
+ sql = windowed_sql(sql, row_limit=row_limit, offset=offset)
161
+
162
+ async def _gen() -> AsyncIterator[Any]:
163
+ fetch = fetch_arrow_chunks_with_manifest(
164
+ self._client, sql, catalog=catalog, schema=schema, parameters=parameters
165
+ )
166
+ async for item in await_with_heartbeat(fetch, total_timeout_s=total_timeout_s):
167
+ if item is HEARTBEAT:
168
+ yield HEARTBEAT
169
+ continue
170
+ _statement_id, manifest, chunk_iter = item
171
+ self.description = _description_from_manifest(manifest)
172
+ self._result = _ResultSet(schema=None, chunk_aiter=chunk_iter.__aiter__())
173
+ yield self
174
+
175
+ return _gen()
176
+
177
+ async def execute(
178
+ self,
179
+ sql: str,
180
+ parameters: list[dict[str, Any]] | None = None,
181
+ *,
182
+ row_limit: int | None = None,
183
+ offset: int | None = None,
184
+ catalog: str | None = None,
185
+ schema: str | None = None,
186
+ total_timeout_s: float | None = None,
187
+ ) -> Cursor:
188
+ """Submits `sql` and waits for it to complete -- a plain blocking
189
+ await, like a real DB-API cursor's execute(). `parameters`, if given,
190
+ is Databricks' own named-parameter format --
191
+ [{"name": ..., "value": ..., "type": ...}] bound against `:name`
192
+ markers in `sql`, not DB-API's `?`/`%s` placeholder style."""
193
+ async for _ in self.execute_streamed(
194
+ sql,
195
+ parameters,
196
+ row_limit=row_limit,
197
+ offset=offset,
198
+ catalog=catalog,
199
+ schema=schema,
200
+ total_timeout_s=total_timeout_s,
201
+ ):
202
+ pass
203
+ return self
204
+
205
+ def _require_result(self) -> _ResultSet:
206
+ if self._result is None:
207
+ raise RuntimeError("no active result set -- call execute() first")
208
+ return self._result
209
+
210
+ async def fetchone(self) -> Row | None:
211
+ return await self._require_result().fetchone()
212
+
213
+ async def fetchmany(self, size: int) -> list[Row]:
214
+ return await self._require_result().fetchmany(size)
215
+
216
+ async def fetchall(self) -> list[Row]:
217
+ return await self._require_result().fetchall()
218
+
219
+ async def fetchmany_arrow(self, size: int) -> core.Table:
220
+ return await self._require_result().fetchmany_arrow(size)
221
+
222
+ async def fetchall_arrow(self) -> core.Table:
223
+ return await self._require_result().fetchall_arrow()
224
+
225
+ def __aiter__(self) -> Cursor:
226
+ return self
227
+
228
+ async def __anext__(self) -> Row:
229
+ row = await self.fetchone()
230
+ if row is None:
231
+ raise StopAsyncIteration
232
+ return row
233
+
234
+
235
+ class Connection:
236
+ """One Databricks SQL warehouse endpoint -- see DatabricksClient for the
237
+ constructor args (auth, timeouts, chunk_fetch_concurrency). Statements are
238
+ independent REST calls with no server-side session state, so `cursor()`
239
+ can be called as many times as you like; `close()` is a no-op kept for
240
+ context-manager parity with a real DB-API connection."""
241
+
242
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
243
+ self._client = DatabricksClient(*args, **kwargs)
244
+
245
+ def cursor(self) -> Cursor:
246
+ return Cursor(self._client)
247
+
248
+ async def close(self) -> None:
249
+ pass
250
+
251
+ async def __aenter__(self) -> Connection:
252
+ return self
253
+
254
+ async def __aexit__(self, *exc_info: object) -> None:
255
+ await self.close()
256
+
257
+
258
+ def connect(*args: Any, **kwargs: Any) -> Connection:
259
+ """Same signature as DatabricksClient -- `connect(host, warehouse_id,
260
+ token=..., ...)`."""
261
+ return Connection(*args, **kwargs)
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: arrowbricks
3
+ Version: 0.1.0
4
+ Summary: Runs SQL against a Databricks SQL warehouse via the Statement Execution API and hands you the result as Arrow -- a DB-API-ish Cursor (fetchone/fetchmany/fetchall/fetchall_arrow) or NDJSON streaming. Single Arrow engine (arro3), no DuckDB.
5
+ Project-URL: Repository, https://github.com/bmsuisse/arrowbricks
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: arro3-core>=0.8
10
+ Requires-Dist: arro3-io>=0.8
11
+ Requires-Dist: httpx>=0.27
12
+ Description-Content-Type: text/markdown
13
+
14
+ # arrowbricks
15
+
16
+ Runs SQL against a Databricks SQL warehouse via the Statement Execution API and hands you the result as Arrow -- a `Cursor` shaped like [`databricks-sql-python`](https://github.com/databricks/databricks-sql-python)'s (`execute`, `fetchone`/`fetchmany`/`fetchall`, `fetchall_arrow`/`fetchmany_arrow`), or `stream_query_json` for streaming NDJSON. One Arrow engine ([arro3](https://github.com/kylebarron/arro3)), no DuckDB, no pandas/pyarrow.
17
+
18
+ - Single responsibility: Databricks to Arrow via arro3. No embedded query engine -- that's [duckbricks](https://github.com/bmsuisse/duckbricks), built on top of this.
19
+ - Bring-your-own-auth -- a static token or your own token-refresh callable. No cloud-SDK dependency baked in.
20
+ - Result-order preserved even though chunks can complete out of order over the network.
21
+ - Chunks are fetched lazily as `fetchone`/`fetchmany`/`fetchall` actually need them, not all upfront.
22
+ - Heartbeats between slow chunks (`execute_streamed`/`stream_query_json`), so a caller streaming this over e.g. SSE never goes silent during a cold warehouse start.
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ pip install arrowbricks
28
+ ```
29
+
30
+ Dependencies: `httpx` + `arro3-core` + `arro3-io`. That's the whole tree.
31
+
32
+ ## Quickstart
33
+
34
+ ```python
35
+ import asyncio
36
+ from arrowbricks import connect
37
+
38
+
39
+ async def main():
40
+ conn = connect(
41
+ host="adb-1234567890.1.azuredatabricks.net",
42
+ warehouse_id="abcd1234efgh5678",
43
+ token="dapi...", # or token_provider=... -- see Auth below
44
+ )
45
+ cursor = conn.cursor()
46
+
47
+ await cursor.execute("SELECT * FROM my_catalog.my_schema.my_table LIMIT 100")
48
+ async for row in cursor:
49
+ print(row)
50
+
51
+ await cursor.execute("SELECT * FROM my_catalog.my_schema.my_table LIMIT 100")
52
+ table = await cursor.fetchall_arrow() # an arro3 Table
53
+
54
+
55
+ asyncio.run(main())
56
+ ```
57
+
58
+ For streaming NDJSON (e.g. a FastAPI SSE endpoint, first row out as soon as its chunk arrives):
59
+
60
+ ```python
61
+ from arrowbricks import HEARTBEAT, DatabricksClient, stream_query_json
62
+
63
+ client = DatabricksClient(host=..., warehouse_id=..., token=...)
64
+
65
+ async for item in stream_query_json(client, "SELECT * FROM my_catalog.my_schema.big_table"):
66
+ if item is HEARTBEAT:
67
+ continue # forward as an SSE keep-alive comment, e.g.
68
+ print(item) # one ready-to-send JSON string per row
69
+ ```
70
+
71
+ ## Why not `databricks-sql-connector`?
72
+
73
+ The [official driver](https://github.com/databricks/databricks-sql-python) is the right choice if you need full DB-API 2.0 compatibility over Databricks' Thrift/ODBC-style protocol. If you just want a query result as Arrow/JSON in your own async app, it drags in a lot for that: `pandas`, `thrift`, `openpyxl`, `pybreaker`, `pyjwt`, `oauthlib`, `lz4`, `requests`, `urllib3` as hard dependencies. arrowbricks talks to the plain REST Statement Execution API instead, and its whole dependency tree is `httpx` + `arro3-core` + `arro3-io`. The `Cursor` API is deliberately shaped like the official driver's so switching between them is mostly a constructor change, but arrowbricks is async throughout (`execute`, `fetchone`, etc. are all coroutines) -- there's no sync escape hatch.
74
+
75
+ ## Why not `duckbricks`?
76
+
77
+ [duckbricks](https://github.com/bmsuisse/duckbricks) does the same Databricks-to-Arrow work, then goes further: it uses a real embedded DuckDB engine to materialize results into your own DuckDB connection/table (`feed_select_to_duckdb_table`), or push a DuckDB query's result *up* to Databricks (`feed_duckdb_table_to_databricks`). If you need that -- a real local SQL engine sitting on top, not just "run this query, get Arrow/JSON back" -- use duckbricks; it depends on arrowbricks for the Databricks/Arrow half. If you don't need DuckDB at all, arrowbricks alone is the smaller, single-responsibility half.
78
+
79
+ ## Auth
80
+
81
+ `connect`/`DatabricksClient` take either:
82
+
83
+ - `token: str` -- a static personal access token or pre-issued OAuth token, or
84
+ - `token_provider` -- a callable (sync or async) returning a token string, called on every request.
85
+
86
+ arrowbricks has no opinion on *how* you get a token and no cloud-SDK dependency of its own. If your provider is expensive to call, cache/refresh inside it -- arrowbricks does no caching on your behalf.
87
+
88
+ ```python
89
+ conn = connect(host=..., warehouse_id=..., token_provider=my_token_provider)
90
+ ```
91
+
92
+ ## API
93
+
94
+ - `connect(host, warehouse_id, *, token=None, token_provider=None, ...) -> Connection`
95
+ - `Connection.cursor() -> Cursor`
96
+ - `Cursor.execute(sql, parameters=None, *, row_limit=None, offset=None, catalog=None, schema=None, total_timeout_s=None) -> Cursor` -- submits and waits for the statement, like a real DB-API cursor. `parameters`, if given, is Databricks' own named-parameter format -- `[{"name": ..., "value": ..., "type": ...}]` bound against `:name` markers in `sql`.
97
+ - `Cursor.execute_streamed(...)` -- same args, but an async generator yielding `HEARTBEAT` while waiting on a slow cold start, then the ready `Cursor` -- for bridging e.g. an SSE connection.
98
+ - `Cursor.fetchone() -> tuple | None`, `Cursor.fetchmany(size) -> list[tuple]`, `Cursor.fetchall() -> list[tuple]`
99
+ - `Cursor.fetchmany_arrow(size) -> arro3.core.Table`, `Cursor.fetchall_arrow() -> arro3.core.Table`
100
+ - `Cursor` is an async iterator, yielding one row (tuple) at a time.
101
+ - `Cursor.description` -- DB-API-style `[(name, type_name, None, None, None, None, None), ...]` after `execute()`.
102
+ - `stream_query_json(client, sql, **kwargs)` -- yields `HEARTBEAT`, then each row as a JSON string, as soon as its chunk arrives. Timestamps come out as full ISO-8601, every column key is always present (`"col":null` for a null value, never an omitted key).
103
+ - `DatabricksClient(host, warehouse_id, *, token=None, token_provider=None, ...)` -- the lower-level client `Connection` wraps. `client.execute_json_statement(sql, ...)` for plain JSON rows with no Arrow parse at all; `client.upload_volume_file(volume_path, data)`/`client.delete_volume_file(volume_path)` for the Files API.
104
+ - `write_ipc_stream(table_or_chunk, buf)` -- thin wrapper around `arro3.io.write_ipc_stream` that always writes uncompressed bodies (see below).
105
+
106
+ `Cursor.execute`/`execute_streamed`/`stream_query_json` all accept `catalog`, `schema`, `row_limit`, `offset`, and `total_timeout_s`.
107
+
108
+ ## A note on Arrow IPC compression
109
+
110
+ `write_ipc_stream` (and everything in this package that serializes Arrow-IPC bytes) always writes **uncompressed** bodies. arro3's own default (`compression="LZ4"`) is transparently decompressed by DuckDB's Arrow reader, but not necessarily by every other Arrow IPC reader -- notably, `duckdb-wasm`'s browser-side decoder silently fails to parse LZ4-compressed bodies. If you're producing bytes that might be consumed by something other than a Python DuckDB connection, this default matters.
111
+
112
+ ## License
113
+
114
+ MIT
@@ -0,0 +1,8 @@
1
+ arrowbricks/__init__.py,sha256=ztesGsBdZnd88gwezxhpay2ZpfiJIZt85BCenkySFEE,471
2
+ arrowbricks/_streaming.py,sha256=FkOYLqLUANvYTMiNISUUkQ_qcnXlAGErd1J_ogsookM,11217
3
+ arrowbricks/client.py,sha256=QZWEpZ_qGazFyq1nbCs5MDMnKmoCkudyQGv7nYNyqas,16950
4
+ arrowbricks/cursor.py,sha256=ql5rpkufyHY81s0UpduLLf8S6bjogzVDxNRQ7kcrwpU,10306
5
+ arrowbricks-0.1.0.dist-info/METADATA,sha256=O90txWepq1W__nkNN28qo4VkzrMOSQQ0F24a3573J2s,7340
6
+ arrowbricks-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
7
+ arrowbricks-0.1.0.dist-info/licenses/LICENSE,sha256=OGvcpI5L_-dM9pJ30WhYStEa2cIhvRo5GZ2i5_YPl9k,1070
8
+ arrowbricks-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BMS Suisse AG
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.