thunderduck-sqlalchemy 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,153 @@
1
+ Metadata-Version: 2.3
2
+ Name: thunderduck-sqlalchemy
3
+ Version: 0.1.0
4
+ Summary: SQLAlchemy dialect and DBAPI for thunderduck.io — query your lakehouse from Superset, Airflow, pandas and friends.
5
+ Keywords: sqlalchemy,duckdb,iceberg,lakehouse,thunderduck,superset
6
+ Author: YILDIRIM ADIGUZEL
7
+ Author-email: YILDIRIM ADIGUZEL <yadiguzel@gmail.com>
8
+ License: Apache-2.0
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Topic :: Database
15
+ Classifier: Topic :: Database :: Front-Ends
16
+ Requires-Dist: httpx>=0.27.0
17
+ Requires-Dist: sqlalchemy>=1.4
18
+ Requires-Python: >=3.10
19
+ Project-URL: Homepage, https://thunderduck.io
20
+ Project-URL: Source, https://github.com/212data/thunderduck
21
+ Description-Content-Type: text/markdown
22
+
23
+ # thunderduck-sqlalchemy
24
+
25
+ A SQLAlchemy dialect and PEP 249 DBAPI for [thunderduck](https://thunderduck.io).
26
+ Query your lakehouse from anything that speaks SQLAlchemy — Superset, Airflow,
27
+ Dagster, Redash, pandas, Jupyter, Streamlit.
28
+
29
+ ```bash
30
+ pip install thunderduck-sqlalchemy
31
+ ```
32
+
33
+ Works with SQLAlchemy 1.4 and 2.x, on Python 3.10+ — so it installs cleanly
34
+ into Apache Superset (which pins SQLAlchemy 1.4) as well as modern Airflow,
35
+ Dagster and pandas environments.
36
+
37
+ ## Connect
38
+
39
+ Create an API token in the thunderduck console (**Settings → API Tokens**; it
40
+ starts with `tdk_` and is shown once), then:
41
+
42
+ ```python
43
+ from sqlalchemy import create_engine, text
44
+ from sqlalchemy.pool import NullPool
45
+
46
+ engine = create_engine(
47
+ "thunderduck://:tdk_your_token@api.thunderduck.io/",
48
+ poolclass=NullPool,
49
+ )
50
+
51
+ with engine.connect() as conn:
52
+ for row in conn.execute(text('SELECT * FROM "lego"."public"."lego_sets" LIMIT 10')):
53
+ print(row)
54
+ ```
55
+
56
+ pandas works directly:
57
+
58
+ ```python
59
+ import pandas as pd
60
+
61
+ df = pd.read_sql('SELECT * FROM "thunder_duck_demo"."iris"', engine)
62
+ ```
63
+
64
+ ### URL options
65
+
66
+ | Option | Default | Meaning |
67
+ | --- | --- | --- |
68
+ | password / `?token=` | — | Required. Your `tdk_…` API token. |
69
+ | `?ssl=false` | `true` | Use plain HTTP. For in-cluster use against the console-api Service. |
70
+ | `?poll_ms=` | `1000` | How often to poll for completion. |
71
+ | `?timeout_ms=` | `300000` | How long to wait for a query. |
72
+ | `?arraysize=` | `1000` | Rows fetched per page. |
73
+
74
+ ## Table names
75
+
76
+ thunderduck names are `catalog.schema.table` (flat-file catalogs) or
77
+ `catalog.table` (Iceberg/Nessie catalogs). SQLAlchemy models two levels, so
78
+ **the schema is the whole dotted prefix**:
79
+
80
+ ```python
81
+ from sqlalchemy import MetaData, Table
82
+
83
+ md = MetaData()
84
+ sets = Table("lego_sets", md, schema="lego.public", autoload_with=engine) # 3-level
85
+ iris = Table("iris", md, schema="thunder_duck_demo", autoload_with=engine) # 2-level
86
+ ```
87
+
88
+ Reflection (`inspect(engine).get_schema_names()` etc.) reads thunderduck's
89
+ catalog API rather than issuing SQL, so browsing schemas is fast and free.
90
+
91
+ ## Apache Superset
92
+
93
+ Installing the package is all that is needed — it registers a Superset DB
94
+ engine spec automatically. Add a database of type **Other** with:
95
+
96
+ ```
97
+ thunderduck://:tdk_your_token@api.thunderduck.io/
98
+ ```
99
+
100
+ Superset's stop button genuinely cancels the running query.
101
+
102
+ ## How it works, and what follows from that
103
+
104
+ Every statement is submitted to thunderduck's REST API, which runs it as an
105
+ isolated Kubernetes Job; results are then paged back over HTTP. That model has
106
+ consequences worth knowing up front:
107
+
108
+ - **Latency.** Expect seconds, not milliseconds, before the first row. Built
109
+ for analytics and BI, not for tight interactive loops.
110
+ - **Read-only.** No DML, no DDL, no transactions. `commit()` and `rollback()`
111
+ are no-ops.
112
+ - **Use `NullPool`.** A connection holds no server-side state, so pooling buys
113
+ nothing. The driver also never issues a `SELECT 1` health check, because
114
+ that would start a whole Job.
115
+ - **Parameters are rendered client-side.** thunderduck's API accepts SQL only,
116
+ so this driver renders bound parameters into the statement, escaping them
117
+ strictly and refusing types it does not recognise. Always pass untrusted
118
+ values as parameters — never build SQL by string concatenation. The
119
+ paramstyle is `pyformat`, so — as with psycopg2 — a literal percent sign in
120
+ hand-written SQL must be escaped as `%%` when you pass parameters.
121
+ - **Row ceiling.** The server caps how many rows one page returns and how many
122
+ a result set stores. Very large result sets are truncated; aggregate in SQL
123
+ rather than pulling raw rows.
124
+
125
+ ## Also available
126
+
127
+ For JVM tools (DBeaver, DataGrip) there is a JDBC driver — see the
128
+ `jdbc-driver/` directory of the thunderduck repository.
129
+
130
+ ## Development
131
+
132
+ ```bash
133
+ uv sync --dev
134
+ uv run pytest
135
+ uv run ruff check . && uv run ruff format --check .
136
+ ```
137
+
138
+ One gotcha: the committed `uv.lock` resolves SQLAlchemy 2.x, so a plain
139
+ `uv run pytest` only exercises **one** of the two supported majors. Apache
140
+ Superset pins `sqlalchemy<2`, so check that combination too before releasing:
141
+
142
+ ```bash
143
+ uv venv --python 3.10 .sa14
144
+ uv pip install --python .sa14 -e . "sqlalchemy<2" pytest
145
+ .sa14/bin/python -c "import sqlalchemy; print('SQLALCHEMY', sqlalchemy.__version__)"
146
+ .sa14/bin/pytest -q
147
+ ```
148
+
149
+ CI runs both legs on every push.
150
+
151
+ ## License
152
+
153
+ Apache-2.0
@@ -0,0 +1,131 @@
1
+ # thunderduck-sqlalchemy
2
+
3
+ A SQLAlchemy dialect and PEP 249 DBAPI for [thunderduck](https://thunderduck.io).
4
+ Query your lakehouse from anything that speaks SQLAlchemy — Superset, Airflow,
5
+ Dagster, Redash, pandas, Jupyter, Streamlit.
6
+
7
+ ```bash
8
+ pip install thunderduck-sqlalchemy
9
+ ```
10
+
11
+ Works with SQLAlchemy 1.4 and 2.x, on Python 3.10+ — so it installs cleanly
12
+ into Apache Superset (which pins SQLAlchemy 1.4) as well as modern Airflow,
13
+ Dagster and pandas environments.
14
+
15
+ ## Connect
16
+
17
+ Create an API token in the thunderduck console (**Settings → API Tokens**; it
18
+ starts with `tdk_` and is shown once), then:
19
+
20
+ ```python
21
+ from sqlalchemy import create_engine, text
22
+ from sqlalchemy.pool import NullPool
23
+
24
+ engine = create_engine(
25
+ "thunderduck://:tdk_your_token@api.thunderduck.io/",
26
+ poolclass=NullPool,
27
+ )
28
+
29
+ with engine.connect() as conn:
30
+ for row in conn.execute(text('SELECT * FROM "lego"."public"."lego_sets" LIMIT 10')):
31
+ print(row)
32
+ ```
33
+
34
+ pandas works directly:
35
+
36
+ ```python
37
+ import pandas as pd
38
+
39
+ df = pd.read_sql('SELECT * FROM "thunder_duck_demo"."iris"', engine)
40
+ ```
41
+
42
+ ### URL options
43
+
44
+ | Option | Default | Meaning |
45
+ | --- | --- | --- |
46
+ | password / `?token=` | — | Required. Your `tdk_…` API token. |
47
+ | `?ssl=false` | `true` | Use plain HTTP. For in-cluster use against the console-api Service. |
48
+ | `?poll_ms=` | `1000` | How often to poll for completion. |
49
+ | `?timeout_ms=` | `300000` | How long to wait for a query. |
50
+ | `?arraysize=` | `1000` | Rows fetched per page. |
51
+
52
+ ## Table names
53
+
54
+ thunderduck names are `catalog.schema.table` (flat-file catalogs) or
55
+ `catalog.table` (Iceberg/Nessie catalogs). SQLAlchemy models two levels, so
56
+ **the schema is the whole dotted prefix**:
57
+
58
+ ```python
59
+ from sqlalchemy import MetaData, Table
60
+
61
+ md = MetaData()
62
+ sets = Table("lego_sets", md, schema="lego.public", autoload_with=engine) # 3-level
63
+ iris = Table("iris", md, schema="thunder_duck_demo", autoload_with=engine) # 2-level
64
+ ```
65
+
66
+ Reflection (`inspect(engine).get_schema_names()` etc.) reads thunderduck's
67
+ catalog API rather than issuing SQL, so browsing schemas is fast and free.
68
+
69
+ ## Apache Superset
70
+
71
+ Installing the package is all that is needed — it registers a Superset DB
72
+ engine spec automatically. Add a database of type **Other** with:
73
+
74
+ ```
75
+ thunderduck://:tdk_your_token@api.thunderduck.io/
76
+ ```
77
+
78
+ Superset's stop button genuinely cancels the running query.
79
+
80
+ ## How it works, and what follows from that
81
+
82
+ Every statement is submitted to thunderduck's REST API, which runs it as an
83
+ isolated Kubernetes Job; results are then paged back over HTTP. That model has
84
+ consequences worth knowing up front:
85
+
86
+ - **Latency.** Expect seconds, not milliseconds, before the first row. Built
87
+ for analytics and BI, not for tight interactive loops.
88
+ - **Read-only.** No DML, no DDL, no transactions. `commit()` and `rollback()`
89
+ are no-ops.
90
+ - **Use `NullPool`.** A connection holds no server-side state, so pooling buys
91
+ nothing. The driver also never issues a `SELECT 1` health check, because
92
+ that would start a whole Job.
93
+ - **Parameters are rendered client-side.** thunderduck's API accepts SQL only,
94
+ so this driver renders bound parameters into the statement, escaping them
95
+ strictly and refusing types it does not recognise. Always pass untrusted
96
+ values as parameters — never build SQL by string concatenation. The
97
+ paramstyle is `pyformat`, so — as with psycopg2 — a literal percent sign in
98
+ hand-written SQL must be escaped as `%%` when you pass parameters.
99
+ - **Row ceiling.** The server caps how many rows one page returns and how many
100
+ a result set stores. Very large result sets are truncated; aggregate in SQL
101
+ rather than pulling raw rows.
102
+
103
+ ## Also available
104
+
105
+ For JVM tools (DBeaver, DataGrip) there is a JDBC driver — see the
106
+ `jdbc-driver/` directory of the thunderduck repository.
107
+
108
+ ## Development
109
+
110
+ ```bash
111
+ uv sync --dev
112
+ uv run pytest
113
+ uv run ruff check . && uv run ruff format --check .
114
+ ```
115
+
116
+ One gotcha: the committed `uv.lock` resolves SQLAlchemy 2.x, so a plain
117
+ `uv run pytest` only exercises **one** of the two supported majors. Apache
118
+ Superset pins `sqlalchemy<2`, so check that combination too before releasing:
119
+
120
+ ```bash
121
+ uv venv --python 3.10 .sa14
122
+ uv pip install --python .sa14 -e . "sqlalchemy<2" pytest
123
+ .sa14/bin/python -c "import sqlalchemy; print('SQLALCHEMY', sqlalchemy.__version__)"
124
+ .sa14/bin/pytest -q
125
+ ```
126
+
127
+ CI runs both legs on every push.
128
+
129
+ ## License
130
+
131
+ Apache-2.0
@@ -0,0 +1,70 @@
1
+ [project]
2
+ name = "thunderduck-sqlalchemy"
3
+ version = "0.1.0"
4
+ description = "SQLAlchemy dialect and DBAPI for thunderduck.io — query your lakehouse from Superset, Airflow, pandas and friends."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ keywords = [
8
+ "sqlalchemy",
9
+ "duckdb",
10
+ "iceberg",
11
+ "lakehouse",
12
+ "thunderduck",
13
+ "superset",
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Topic :: Database",
22
+ "Topic :: Database :: Front-Ends",
23
+ ]
24
+ dependencies = [
25
+ "httpx>=0.27.0",
26
+ "sqlalchemy>=1.4",
27
+ ]
28
+
29
+ [[project.authors]]
30
+ name = "YILDIRIM ADIGUZEL"
31
+ email = "yadiguzel@gmail.com"
32
+
33
+ [project.license]
34
+ text = "Apache-2.0"
35
+
36
+ [project.urls]
37
+ Homepage = "https://thunderduck.io"
38
+ Source = "https://github.com/212data/thunderduck"
39
+
40
+ [project.entry-points."sqlalchemy.dialects"]
41
+ thunderduck = "thunderduck_sqlalchemy.dialect:ThunderduckDialect"
42
+ "thunderduck.rest" = "thunderduck_sqlalchemy.dialect:ThunderduckDialect"
43
+
44
+ [project.entry-points."superset.db_engine_specs"]
45
+ thunderduck = "thunderduck_sqlalchemy.superset_spec:ThunderduckEngineSpec"
46
+
47
+ [dependency-groups]
48
+ dev = [
49
+ "pytest>=8.0.0",
50
+ "ruff>=0.8.0",
51
+ ]
52
+
53
+ [build-system]
54
+ requires = ["uv_build>=0.12.1,<0.13.0"]
55
+ build-backend = "uv_build"
56
+
57
+ [tool.pytest.ini_options]
58
+ testpaths = ["tests"]
59
+
60
+ [tool.ruff]
61
+ line-length = 100
62
+ target-version = "py310"
63
+
64
+ [tool.ruff.lint]
65
+ select = [
66
+ "E",
67
+ "F",
68
+ "I",
69
+ "UP",
70
+ ]
@@ -0,0 +1,62 @@
1
+ [project]
2
+ name = "thunderduck-sqlalchemy"
3
+ version = "0.1.0"
4
+ description = "SQLAlchemy dialect and DBAPI for thunderduck.io — query your lakehouse from Superset, Airflow, pandas and friends."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "YILDIRIM ADIGUZEL", email = "yadiguzel@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.10"
10
+ license = { text = "Apache-2.0" }
11
+ keywords = ["sqlalchemy", "duckdb", "iceberg", "lakehouse", "thunderduck", "superset"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Intended Audience :: Developers",
15
+ "Programming Language :: Python :: 3.10",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Topic :: Database",
19
+ "Topic :: Database :: Front-Ends",
20
+ ]
21
+ # sqlalchemy>=1.4, NOT >=2.0: apache-superset 6.1.0 pins sqlalchemy<2, so a 2.0
22
+ # floor would make this package uninstallable in the Superset image. See the
23
+ # plan's Global Constraints.
24
+ dependencies = [
25
+ "httpx>=0.27.0",
26
+ "sqlalchemy>=1.4",
27
+ ]
28
+
29
+ # Deliberately NO [project.optional-dependencies]: an `apache-superset` extra
30
+ # cannot be resolved alongside this package (its sqlalchemy<2 pin conflicts),
31
+ # and Superset supplies itself at runtime anyway.
32
+
33
+ [project.urls]
34
+ Homepage = "https://thunderduck.io"
35
+ Source = "https://github.com/212data/thunderduck"
36
+
37
+ [project.entry-points."sqlalchemy.dialects"]
38
+ thunderduck = "thunderduck_sqlalchemy.dialect:ThunderduckDialect"
39
+ "thunderduck.rest" = "thunderduck_sqlalchemy.dialect:ThunderduckDialect"
40
+
41
+ [project.entry-points."superset.db_engine_specs"]
42
+ thunderduck = "thunderduck_sqlalchemy.superset_spec:ThunderduckEngineSpec"
43
+
44
+ [dependency-groups]
45
+ dev = [
46
+ "pytest>=8.0.0",
47
+ "ruff>=0.8.0",
48
+ ]
49
+
50
+ [build-system]
51
+ requires = ["uv_build>=0.12.1,<0.13.0"]
52
+ build-backend = "uv_build"
53
+
54
+ [tool.pytest.ini_options]
55
+ testpaths = ["tests"]
56
+
57
+ [tool.ruff]
58
+ line-length = 100
59
+ target-version = "py310"
60
+
61
+ [tool.ruff.lint]
62
+ select = ["E", "F", "I", "UP"]
@@ -0,0 +1,13 @@
1
+ """SQLAlchemy dialect and DBAPI for thunderduck.io.
2
+
3
+ Read-only by design: every statement is submitted to console-api, which runs
4
+ it as a Kubernetes Job, and results are paged back over HTTP. See README.md
5
+ for the connection URL format and the caveats that follow from that model.
6
+ """
7
+
8
+ from .dbapi import connect
9
+ from .dialect import ThunderduckDialect
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ __all__ = ["__version__", "connect", "ThunderduckDialect"]
@@ -0,0 +1,76 @@
1
+ """Flatten console-api's `/catalog/schema` tree into `(schema, table)` pairs.
2
+
3
+ ## Two catalog shapes, one rule
4
+
5
+ thunderduck names are not uniformly three-part. Nessie catalogs are 2-level
6
+ (`catalog.table`) and put their tables directly under the catalog; flat-file
7
+ catalogs are 3-level (`catalog.schema.table`) and put them under named
8
+ namespaces. SQLAlchemy and Superset both model exactly two levels.
9
+
10
+ The rule that reconciles them: **the composite `schema` is the whole dotted
11
+ prefix and the `table` is the leaf.** So `iris` in the nessie catalog
12
+ `thunder_duck_demo` has schema `"thunder_duck_demo"`, while `lego_sets` has
13
+ schema `"lego.public"`. The dialect's identifier preparer later splits that
14
+ prefix back apart and quotes each segment, so both compile correctly.
15
+
16
+ A catalog carrying an `error` (console-api could not introspect it) is skipped
17
+ rather than raised on: one unreachable catalog must not break reflection of
18
+ every other one.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass, field
24
+ from typing import Any
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class TableRef:
29
+ schema: str
30
+ table: str
31
+ kind: str = "table"
32
+ columns: list[dict[str, str]] = field(default_factory=list)
33
+
34
+
35
+ def flatten(tree: dict[str, Any] | None) -> list[TableRef]:
36
+ refs: list[TableRef] = []
37
+ for catalog in (tree or {}).get("catalogs") or []:
38
+ if catalog.get("error"):
39
+ continue
40
+ catalog_name = catalog.get("name")
41
+ if not catalog_name:
42
+ continue
43
+ for table in catalog.get("tables") or []:
44
+ ref = _make_ref(catalog_name, table)
45
+ if ref:
46
+ refs.append(ref)
47
+ for namespace in catalog.get("namespaces") or []:
48
+ namespace_name = namespace.get("name")
49
+ if not namespace_name:
50
+ continue
51
+ schema = f"{catalog_name}.{namespace_name}"
52
+ for table in namespace.get("tables") or []:
53
+ ref = _make_ref(schema, table)
54
+ if ref:
55
+ refs.append(ref)
56
+ return refs
57
+
58
+
59
+ def _make_ref(schema: str, table: dict[str, Any]) -> TableRef | None:
60
+ name = table.get("name")
61
+ if not name:
62
+ return None
63
+ kind = str(table.get("kind") or "table").lower()
64
+ columns = [
65
+ {"name": str(c.get("name")), "type": str(c.get("type") or "")}
66
+ for c in (table.get("columns") or [])
67
+ if c.get("name")
68
+ ]
69
+ return TableRef(schema=schema, table=str(name), kind=kind, columns=columns)
70
+
71
+
72
+ def split_schema(schema: str) -> list[str]:
73
+ """Split a composite schema into its segments (`"lego.public"` -> 2)."""
74
+ if not schema:
75
+ return []
76
+ return schema.split(".")