arrowbricks 0.1.0__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.
@@ -0,0 +1,22 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.11", "3.13", "3.14"]
14
+ steps:
15
+ - uses: actions/checkout@v7
16
+ - uses: astral-sh/setup-uv@v9.0.0
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+ - run: uv sync --all-extras
20
+ - run: uv run ruff check .
21
+ - run: uv run ty check src tests
22
+ - run: uv run pytest -q
@@ -0,0 +1,32 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*.*.*"]
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v7
12
+ - uses: astral-sh/setup-uv@v9.0.0
13
+ with:
14
+ python-version: "3.14"
15
+ - run: uv sync --all-extras
16
+ - run: uv run ruff check .
17
+ - run: uv run ty check src tests
18
+ - run: uv run pytest -q
19
+
20
+ publish:
21
+ needs: test
22
+ runs-on: ubuntu-latest
23
+ environment: pypi
24
+ permissions:
25
+ id-token: write # PyPI trusted publishing (OIDC) -- no stored API token
26
+ steps:
27
+ - uses: actions/checkout@v7
28
+ - uses: astral-sh/setup-uv@v9.0.0
29
+ with:
30
+ python-version: "3.14"
31
+ - run: uv build
32
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,218 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ # Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ # poetry.lock
109
+ # poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ # pdm.lock
116
+ # pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ # pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Environments
151
+ .env
152
+ .envrc
153
+ .venv
154
+ env/
155
+ venv/
156
+ ENV/
157
+ env.bak/
158
+ venv.bak/
159
+
160
+ # Spyder project settings
161
+ .spyderproject
162
+ .spyproject
163
+
164
+ # Rope project settings
165
+ .ropeproject
166
+
167
+ # mkdocs documentation
168
+ /site
169
+
170
+ # mypy
171
+ .mypy_cache/
172
+ .dmypy.json
173
+ dmypy.json
174
+
175
+ # Pyre type checker
176
+ .pyre/
177
+
178
+ # pytype static type analyzer
179
+ .pytype/
180
+
181
+ # Cython debug symbols
182
+ cython_debug/
183
+
184
+ # PyCharm
185
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
186
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
187
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
188
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
189
+ # .idea/
190
+
191
+ # Abstra
192
+ # Abstra is an AI-powered process automation framework.
193
+ # Ignore directories containing user credentials, local state, and settings.
194
+ # Learn more at https://abstra.io/docs
195
+ .abstra/
196
+
197
+ # Visual Studio Code
198
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
199
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
200
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
201
+ # you could uncomment the following to ignore the entire vscode folder
202
+ # .vscode/
203
+ # Temporary file for partial code execution
204
+ tempCodeRunnerFile.py
205
+
206
+ # Ruff stuff:
207
+ .ruff_cache/
208
+
209
+ # PyPI configuration file
210
+ .pypirc
211
+
212
+ # Marimo
213
+ marimo/_static/
214
+ marimo/_lsp/
215
+ __marimo__/
216
+
217
+ # Streamlit
218
+ .streamlit/secrets.toml
@@ -0,0 +1,44 @@
1
+ # arrowbricks
2
+
3
+ Runs SQL against a Databricks SQL warehouse via the Statement Execution API and hands the result back as Arrow (a `Cursor` shaped like `databricks-sql-python`'s) or streaming NDJSON. See `README.md` for the user-facing API; this file is about working *on* the package.
4
+
5
+ ## Layout
6
+
7
+ - `src/arrowbricks/client.py` -- pure REST client (auth, statement submission/polling, backpressure-bounded concurrent chunk download). No Arrow dependency at all, intentionally: someone who only wants `execute_json_statement` or `upload_volume_file`/`delete_volume_file` shouldn't need arro3 pulled in conceptually either, even though it's a hard dependency of the package as a whole. Retries are a small hand-rolled `_retry_call` loop, not a dependency (see "Design invariants" below).
8
+ - `src/arrowbricks/_streaming.py` -- Arrow (de)serialization via arro3, always: `ReplayableArrowChunk`, `write_ipc_stream` (always uncompressed, see below), heartbeat helpers, chunk fetching, and `stream_query_json` (arro3's `write_ndjson`). One Arrow engine, no pluggable backend -- unlike duckbricks, there's nothing here to make pluggable; arro3 *is* the whole point.
9
+ - `src/arrowbricks/cursor.py` -- `Connection`/`Cursor`, the DB-API-ish surface (`execute`/`execute_streamed`, `fetchone`/`fetchmany`/`fetchall`, `fetchall_arrow`/`fetchmany_arrow`). `_ResultSet` buffers at the Arrow-Table level (not materialized Python rows) so the Arrow-native fetch methods stay zero-copy; row-based fetches materialize lazily off that buffer.
10
+ - `tests/` -- respx mocks the Databricks REST endpoints (warehouse status, statement submit, chunk-link resolution, external-link byte download); no real warehouse or credentials needed to run the suite.
11
+
12
+ ## Commands
13
+
14
+ ```bash
15
+ uv sync --all-extras
16
+ uv run pytest -q
17
+ uv run ruff check .
18
+ uv run ty check src tests
19
+ ```
20
+
21
+ One-time setup per clone: `prek install` (needs `uv tool install prek` first if not already on PATH).
22
+
23
+ ## Design invariants -- don't casually undo these
24
+
25
+ - **No cloud-SDK dependency.** Auth is `token: str` or `token_provider: Callable[[], str | Awaitable[str]]`. Do not add `azure-identity`/`boto3`/etc. as a real dependency -- that belongs in the caller's app.
26
+ - **No hardcoded catalog/schema.** `catalog`/`schema` default to `None` everywhere. This package has zero knowledge of any specific Databricks workspace's naming.
27
+ - **Chunk order is not fetch order.** `DatabricksClient` fetches chunks concurrently (bounded, with backpressure) and they can complete out of order. `_ResultSet`/`stream_query_json` both hold a `pending: dict[int, chunk]` reorder buffer keyed by `chunk_index`, releasing in order as the next expected index shows up. If you touch either, keep a test proving order survives out-of-order arrival (see `test_fetchall_preserves_order_despite_out_of_order_chunks`, `test_stream_query_json_preserves_order_despite_out_of_order_chunks`).
28
+ - **Chunks are fetched lazily, not all upfront.** `_ResultSet` only pulls the next chunk from `_chunk_aiter` when the caller's `fetchone`/`fetchmany`/`fetchall` actually needs more rows than are already buffered. Don't "simplify" this into draining the whole chunk iterator inside `execute()` -- that defeats the point of a paginated cursor.
29
+ - **No silent row caps.** There's no `ABSOLUTE_ROW_LIMIT`-style ceiling baked in. If a caller wants one, that's `row_limit`, which they pass explicitly.
30
+ - **No retry dependency.** `client.py`'s `_retry_call` is a ~10-line hand-rolled exponential-backoff loop, replacing tenacity on purpose -- it's the only retry pattern in the whole client, so a dependency for it wasn't worth it.
31
+ - **One Arrow engine, no pluggable backend.** Unlike duckbricks (which supports nanoarrow *or* arro3 *or* bring-your-own), arrowbricks is arro3-only by design -- that's the entire "single responsibility" pitch. Don't add a backend-abstraction layer back in; if a caller needs a different Arrow engine, that's duckbricks' `set_arrow_backend()`, not this package.
32
+ - **`write_ipc_stream` (and everything built on it) always writes uncompressed Arrow-IPC bodies.** `aio.write_ipc_stream(..., compression=None)` explicitly, everywhere. arro3's own default (`compression="LZ4"`) is transparently decompressed by DuckDB's Arrow reader but not necessarily by other Arrow IPC readers -- `duckdb-wasm`'s browser-side decoder silently fails to parse LZ4-compressed bodies (this was a real bug in duckbricks 0.3.0, fixed in 0.3.1 -- see its CHANGELOG/git history). Never remove the explicit `compression=None`.
33
+
34
+ ## Testing
35
+
36
+ Mock the Databricks endpoints with `respx` (see `tests/conftest.py`'s `mock_warehouse` fixture) rather than hitting a real warehouse. The fixture builds real Arrow-IPC chunk bytes via arro3 directly, so tests exercise the actual Arrow IPC round trip, not a stand-in. Pass `reverse_arrival=True` to force genuine out-of-order chunk completion when a test needs to prove ordering survives it.
37
+
38
+ ## Releasing
39
+
40
+ 1. Bump `version` in `pyproject.toml`.
41
+ 2. `git tag vX.Y.Z && git push origin vX.Y.Z`.
42
+ 3. `.github/workflows/release.yml` runs the test job, then builds and publishes to PyPI via trusted publishing (OIDC) -- no stored token.
43
+
44
+ One-time, outside this repo: register this GitHub repo + `release.yml` workflow as a **trusted publisher** on the `arrowbricks` PyPI project (PyPI project settings -> Publishing). Without that, the `publish` job's OIDC exchange fails even though tests pass.
@@ -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.
@@ -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,101 @@
1
+ # arrowbricks
2
+
3
+ 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.
4
+
5
+ - Single responsibility: Databricks to Arrow via arro3. No embedded query engine -- that's [duckbricks](https://github.com/bmsuisse/duckbricks), built on top of this.
6
+ - Bring-your-own-auth -- a static token or your own token-refresh callable. No cloud-SDK dependency baked in.
7
+ - Result-order preserved even though chunks can complete out of order over the network.
8
+ - Chunks are fetched lazily as `fetchone`/`fetchmany`/`fetchall` actually need them, not all upfront.
9
+ - 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.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install arrowbricks
15
+ ```
16
+
17
+ Dependencies: `httpx` + `arro3-core` + `arro3-io`. That's the whole tree.
18
+
19
+ ## Quickstart
20
+
21
+ ```python
22
+ import asyncio
23
+ from arrowbricks import connect
24
+
25
+
26
+ async def main():
27
+ conn = connect(
28
+ host="adb-1234567890.1.azuredatabricks.net",
29
+ warehouse_id="abcd1234efgh5678",
30
+ token="dapi...", # or token_provider=... -- see Auth below
31
+ )
32
+ cursor = conn.cursor()
33
+
34
+ await cursor.execute("SELECT * FROM my_catalog.my_schema.my_table LIMIT 100")
35
+ async for row in cursor:
36
+ print(row)
37
+
38
+ await cursor.execute("SELECT * FROM my_catalog.my_schema.my_table LIMIT 100")
39
+ table = await cursor.fetchall_arrow() # an arro3 Table
40
+
41
+
42
+ asyncio.run(main())
43
+ ```
44
+
45
+ For streaming NDJSON (e.g. a FastAPI SSE endpoint, first row out as soon as its chunk arrives):
46
+
47
+ ```python
48
+ from arrowbricks import HEARTBEAT, DatabricksClient, stream_query_json
49
+
50
+ client = DatabricksClient(host=..., warehouse_id=..., token=...)
51
+
52
+ async for item in stream_query_json(client, "SELECT * FROM my_catalog.my_schema.big_table"):
53
+ if item is HEARTBEAT:
54
+ continue # forward as an SSE keep-alive comment, e.g.
55
+ print(item) # one ready-to-send JSON string per row
56
+ ```
57
+
58
+ ## Why not `databricks-sql-connector`?
59
+
60
+ 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.
61
+
62
+ ## Why not `duckbricks`?
63
+
64
+ [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.
65
+
66
+ ## Auth
67
+
68
+ `connect`/`DatabricksClient` take either:
69
+
70
+ - `token: str` -- a static personal access token or pre-issued OAuth token, or
71
+ - `token_provider` -- a callable (sync or async) returning a token string, called on every request.
72
+
73
+ 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.
74
+
75
+ ```python
76
+ conn = connect(host=..., warehouse_id=..., token_provider=my_token_provider)
77
+ ```
78
+
79
+ ## API
80
+
81
+ - `connect(host, warehouse_id, *, token=None, token_provider=None, ...) -> Connection`
82
+ - `Connection.cursor() -> Cursor`
83
+ - `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`.
84
+ - `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.
85
+ - `Cursor.fetchone() -> tuple | None`, `Cursor.fetchmany(size) -> list[tuple]`, `Cursor.fetchall() -> list[tuple]`
86
+ - `Cursor.fetchmany_arrow(size) -> arro3.core.Table`, `Cursor.fetchall_arrow() -> arro3.core.Table`
87
+ - `Cursor` is an async iterator, yielding one row (tuple) at a time.
88
+ - `Cursor.description` -- DB-API-style `[(name, type_name, None, None, None, None, None), ...]` after `execute()`.
89
+ - `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).
90
+ - `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.
91
+ - `write_ipc_stream(table_or_chunk, buf)` -- thin wrapper around `arro3.io.write_ipc_stream` that always writes uncompressed bodies (see below).
92
+
93
+ `Cursor.execute`/`execute_streamed`/`stream_query_json` all accept `catalog`, `schema`, `row_limit`, `offset`, and `total_timeout_s`.
94
+
95
+ ## A note on Arrow IPC compression
96
+
97
+ `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.
98
+
99
+ ## License
100
+
101
+ MIT
@@ -0,0 +1,30 @@
1
+ # prek.toml — pre-commit hook configuration
2
+ # Install git hook: prek install
3
+ # Format all files: prek run --all-files
4
+
5
+ [[repos]]
6
+ repo = "https://github.com/astral-sh/ruff-pre-commit"
7
+ rev = "v0.15.1"
8
+ hooks = [
9
+ { id = "ruff-check", args = ["--fix"] },
10
+ { id = "ruff-format" },
11
+ ]
12
+
13
+ [[repos]]
14
+ repo = "local"
15
+ hooks = [
16
+ { id = "ty", name = "ty check", language = "system", entry = "uv run --frozen ty check src tests", pass_filenames = false, files = '\.py$' },
17
+ ]
18
+
19
+ [[repos]]
20
+ repo = "https://github.com/pre-commit/pre-commit-hooks"
21
+ rev = "v5.0.0"
22
+ hooks = [
23
+ { id = "trailing-whitespace" },
24
+ { id = "end-of-file-fixer" },
25
+ { id = "check-yaml" },
26
+ { id = "check-toml" },
27
+ { id = "check-added-large-files", args = ["--maxkb=1000"] },
28
+ { id = "check-merge-conflict" },
29
+ { id = "detect-private-key" },
30
+ ]
@@ -0,0 +1,44 @@
1
+ [project]
2
+ name = "arrowbricks"
3
+ version = "0.1.0"
4
+ description = "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
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.11"
8
+ dependencies = [
9
+ "httpx>=0.27",
10
+ "arro3-core>=0.8",
11
+ "arro3-io>=0.8",
12
+ ]
13
+
14
+ [project.urls]
15
+ Repository = "https://github.com/bmsuisse/arrowbricks"
16
+
17
+ [build-system]
18
+ requires = ["hatchling"]
19
+ build-backend = "hatchling.build"
20
+
21
+ [tool.hatch.build.targets.wheel]
22
+ packages = ["src/arrowbricks"]
23
+
24
+ [dependency-groups]
25
+ dev = [
26
+ "pytest>=8.0",
27
+ "pytest-asyncio>=0.24",
28
+ "respx>=0.21",
29
+ "ruff>=0.6",
30
+ "ty>=0.0.65",
31
+ ]
32
+
33
+ [tool.pytest.ini_options]
34
+ asyncio_mode = "auto"
35
+
36
+ [tool.ruff]
37
+ line-length = 120
38
+ target-version = "py311"
39
+
40
+ [tool.ruff.lint]
41
+ select = ["E", "F", "I", "UP", "S", "B"]
42
+
43
+ [tool.ruff.lint.per-file-ignores]
44
+ "tests/*" = ["S101", "S106"]
@@ -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
+ ]