thunderduck-sqlalchemy 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,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(".")
@@ -0,0 +1,216 @@
1
+ """HTTP transport for console-api.
2
+
3
+ This is the only module that speaks HTTP. It deliberately knows nothing about
4
+ cursors or SQLAlchemy: it submits SQL, reports execution state, pages results
5
+ and cancels runs. The Java JDBC driver's `ApiClient.java` is the reference
6
+ implementation of the same protocol.
7
+
8
+ Two design notes:
9
+
10
+ * **Polling, not streaming.** Submitting returns immediately with an execution
11
+ id; the run happens in a Kubernetes Job. `await_execution` polls
12
+ `GET /executions/{id}` until a terminal status. console-api also offers an
13
+ SSE endpoint (`POST /queries/stream`) which would cut first-row latency;
14
+ that is a deliberate follow-up, not used here.
15
+ * **The token never appears in a URL or an error message.** It travels only in
16
+ the `Authorization` header, and error construction never interpolates it.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import time
22
+ from dataclasses import dataclass
23
+ from typing import Any
24
+
25
+ import httpx
26
+
27
+ from . import exceptions as exc
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Execution:
32
+ """State of one query run (a row of `query_executions`)."""
33
+
34
+ uuid: str
35
+ status: str
36
+ error_message: str | None = None
37
+ result_location: str | None = None
38
+ result_row_count: int | None = None
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class ResultPage:
43
+ """One page of a stored result set."""
44
+
45
+ columns: list[str]
46
+ column_types: list[str]
47
+ rows: list[list[Any]]
48
+ total: int
49
+ offset: int
50
+
51
+
52
+ class NoStoredResult(Exception):
53
+ """The execution stored no result set (a non-SELECT statement).
54
+
55
+ Deliberately *not* a PEP 249 error: `dbapi.Cursor` catches it and presents
56
+ an empty result set, which is what a DBAPI caller expects after running
57
+ something that returns no rows.
58
+ """
59
+
60
+
61
+ #: Statuses that mean the run is over, one way or another.
62
+ TERMINAL_STATUSES = frozenset({"success", "error", "cancelled"})
63
+
64
+
65
+ class ThunderduckClient:
66
+ def __init__(
67
+ self,
68
+ base_url: str,
69
+ token: str,
70
+ *,
71
+ poll_interval: float = 1.0,
72
+ timeout: float = 300.0,
73
+ transport: httpx.BaseTransport | None = None,
74
+ ) -> None:
75
+ self._token = token
76
+ self._poll_interval = poll_interval
77
+ self._timeout = timeout
78
+ self._http = httpx.Client(
79
+ base_url=base_url,
80
+ transport=transport,
81
+ timeout=httpx.Timeout(30.0),
82
+ headers={
83
+ "Authorization": f"Bearer {token}",
84
+ "Accept": "application/json",
85
+ },
86
+ )
87
+
88
+ # ---- public API ----
89
+
90
+ def submit(self, sql: str) -> str:
91
+ payload = self._request("POST", "/queries", json={"sql": sql})
92
+ exec_id = payload.get("id")
93
+ if not exec_id:
94
+ raise exc.InternalError("console-api did not return an execution id")
95
+ return str(exec_id)
96
+
97
+ def get_execution(self, exec_id: str) -> Execution:
98
+ payload = self._request("GET", f"/executions/{exec_id}")
99
+ return Execution(
100
+ uuid=str(payload.get("uuid", exec_id)),
101
+ status=str(payload.get("status", "")),
102
+ error_message=payload.get("error_message"),
103
+ result_location=payload.get("result_location"),
104
+ result_row_count=payload.get("result_row_count"),
105
+ )
106
+
107
+ def await_execution(self, exec_id: str) -> Execution:
108
+ """Poll until the run reaches a terminal status, then return it.
109
+
110
+ Raises on `error` and `cancelled` so callers only ever see a
111
+ successful `Execution` come back.
112
+ """
113
+ deadline = time.monotonic() + self._timeout
114
+ while True:
115
+ execution = self.get_execution(exec_id)
116
+ if execution.status in TERMINAL_STATUSES:
117
+ if execution.status == "error":
118
+ raise exc.ProgrammingError(
119
+ execution.error_message or "query failed without a message"
120
+ )
121
+ if execution.status == "cancelled":
122
+ raise exc.OperationalError("query was cancelled")
123
+ return execution
124
+ if time.monotonic() >= deadline:
125
+ raise exc.OperationalError(
126
+ f"query timed out after {self._timeout}s (still {execution.status})"
127
+ )
128
+ if self._poll_interval:
129
+ time.sleep(self._poll_interval)
130
+
131
+ def fetch_page(self, exec_id: str, limit: int, offset: int) -> ResultPage:
132
+ payload = self._request(
133
+ "GET",
134
+ f"/executions/{exec_id}/result",
135
+ params={"limit": limit, "offset": offset},
136
+ no_result_is_signal=True,
137
+ )
138
+ return ResultPage(
139
+ columns=list(payload.get("columns") or []),
140
+ column_types=list(payload.get("column_types") or []),
141
+ rows=[list(r) for r in (payload.get("rows") or [])],
142
+ total=int(payload.get("total") or 0),
143
+ offset=int(payload.get("offset") or offset),
144
+ )
145
+
146
+ def cancel(self, exec_id: str) -> None:
147
+ """Best-effort cancel; a finished or unknown run is not an error.
148
+
149
+ "Best effort" covers the run's *state* (already finished, never
150
+ existed) — not the credentials. A 401/403 means the token itself was
151
+ rejected, which must not be swallowed: doing so would make a stop
152
+ button appear to work while quietly hiding a rejected token.
153
+ """
154
+ try:
155
+ self._request("POST", f"/executions/{exec_id}/cancel")
156
+ except exc.InterfaceError:
157
+ raise
158
+ except exc.Error:
159
+ pass
160
+
161
+ def catalog_schema(self) -> dict[str, Any]:
162
+ return self._request("GET", "/catalog/schema")
163
+
164
+ def close(self) -> None:
165
+ self._http.close()
166
+
167
+ # ---- internals ----
168
+
169
+ def _request(
170
+ self,
171
+ method: str,
172
+ path: str,
173
+ *,
174
+ json: Any = None,
175
+ params: Any = None,
176
+ no_result_is_signal: bool = False,
177
+ ) -> dict[str, Any]:
178
+ try:
179
+ response = self._http.request(method, path, json=json, params=params)
180
+ except httpx.HTTPError as err:
181
+ # Never chain the original: httpx puts the full URL in its message,
182
+ # and a caller may have embedded a token in a query string.
183
+ raise exc.OperationalError(
184
+ f"could not reach thunderduck: {type(err).__name__}"
185
+ ) from None
186
+
187
+ if response.is_success:
188
+ if not response.content:
189
+ return {}
190
+ try:
191
+ return response.json()
192
+ except ValueError:
193
+ raise exc.InternalError("console-api returned a non-JSON success body") from None
194
+
195
+ detail = self._detail(response)
196
+ status = response.status_code
197
+ if status == 401 or status == 403:
198
+ raise exc.InterfaceError(f"authentication failed (check the API token): {detail}")
199
+ if status == 404 and no_result_is_signal:
200
+ raise NoStoredResult(detail)
201
+ if status == 409:
202
+ raise exc.OperationalError(detail)
203
+ if status == 400 or status == 422:
204
+ raise exc.ProgrammingError(detail)
205
+ raise exc.OperationalError(f"console-api returned {status}: {detail}")
206
+
207
+ @staticmethod
208
+ def _detail(response: httpx.Response) -> str:
209
+ """FastAPI puts the human-readable message in a JSON `detail` field."""
210
+ try:
211
+ body = response.json()
212
+ except ValueError:
213
+ return response.text.strip()[:200] or f"HTTP {response.status_code}"
214
+ if isinstance(body, dict) and "detail" in body:
215
+ return str(body["detail"])
216
+ return str(body)[:200]
@@ -0,0 +1,306 @@
1
+ """PEP 249 interface over the thunderduck REST API.
2
+
3
+ ## What a "connection" is here
4
+
5
+ Nothing on the server. console-api is stateless per request, so a
6
+ `Connection` is just a configured HTTP client. That has three consequences
7
+ worth knowing:
8
+
9
+ * There is no transaction. `commit()` and `rollback()` are no-ops and the
10
+ dialect reports read-only.
11
+ * Opening a connection costs nothing, so `NullPool` is the recommended
12
+ SQLAlchemy pool and pre-ping is pointless (a `SELECT 1` would start a
13
+ Kubernetes Job -- see `dialect.do_ping`).
14
+ * A `Cursor` owns one execution. `cancel()` reaches the run through its
15
+ execution id, which is what the Superset engine spec uses to make the stop
16
+ button work.
17
+
18
+ ## Row paging
19
+
20
+ `execute()` waits for the run to finish, then fetches the first page. The
21
+ server clamps `limit` to its own maximum (`config.result_max_rows()`), so the
22
+ cursor must keep requesting pages by `offset` until it has `total` rows --
23
+ never assume the requested `limit` was honoured.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import logging
29
+ from typing import Any
30
+
31
+ import httpx
32
+
33
+ from . import exceptions as exc
34
+ from .client import NoStoredResult, ThunderduckClient
35
+ from .exceptions import (
36
+ DatabaseError,
37
+ DataError,
38
+ Error,
39
+ IntegrityError,
40
+ InterfaceError,
41
+ InternalError,
42
+ NotSupportedError,
43
+ OperationalError,
44
+ ProgrammingError,
45
+ )
46
+ from .types import render_sql, sqlalchemy_type
47
+
48
+ _log = logging.getLogger(__name__)
49
+
50
+ apilevel = "2.0"
51
+ #: 1 == "threads may share the module, but not connections".
52
+ threadsafety = 1
53
+ paramstyle = "pyformat"
54
+
55
+ __all__ = [
56
+ "apilevel",
57
+ "threadsafety",
58
+ "paramstyle",
59
+ "connect",
60
+ "Connection",
61
+ "Cursor",
62
+ "Error",
63
+ "InterfaceError",
64
+ "DatabaseError",
65
+ "DataError",
66
+ "OperationalError",
67
+ "IntegrityError",
68
+ "InternalError",
69
+ "ProgrammingError",
70
+ "NotSupportedError",
71
+ ]
72
+
73
+ DEFAULT_ARRAYSIZE = 1000
74
+
75
+
76
+ def connect(
77
+ *,
78
+ host: str,
79
+ token: str,
80
+ port: int | None = None,
81
+ use_ssl: bool = True,
82
+ poll_interval: float = 1.0,
83
+ timeout: float = 300.0,
84
+ arraysize: int = DEFAULT_ARRAYSIZE,
85
+ transport: httpx.BaseTransport | None = None,
86
+ ) -> Connection:
87
+ if not host:
88
+ raise exc.InterfaceError("a thunderduck host is required")
89
+ if not token:
90
+ raise exc.InterfaceError(
91
+ "an API token is required -- pass it as the URL password or ?token=…"
92
+ )
93
+ scheme = "https" if use_ssl else "http"
94
+ netloc = f"{host}:{port}" if port else host
95
+ client = ThunderduckClient(
96
+ base_url=f"{scheme}://{netloc}",
97
+ token=token,
98
+ poll_interval=poll_interval,
99
+ timeout=timeout,
100
+ transport=transport,
101
+ )
102
+ return Connection(client, arraysize=arraysize)
103
+
104
+
105
+ class Connection:
106
+ def __init__(self, client: ThunderduckClient, *, arraysize: int = DEFAULT_ARRAYSIZE) -> None:
107
+ self.client = client
108
+ self._arraysize = arraysize
109
+ self._closed = False
110
+
111
+ def cursor(self) -> Cursor:
112
+ if self._closed:
113
+ raise exc.ProgrammingError("connection is closed")
114
+ return Cursor(self.client, arraysize=self._arraysize)
115
+
116
+ def close(self) -> None:
117
+ if not self._closed:
118
+ self._closed = True
119
+ self.client.close()
120
+
121
+ def commit(self) -> None:
122
+ """No-op: thunderduck has no transactions (read-only driver)."""
123
+
124
+ def rollback(self) -> None:
125
+ """No-op: see `commit`."""
126
+
127
+ def __enter__(self) -> Connection:
128
+ return self
129
+
130
+ def __exit__(self, *_exc: object) -> None:
131
+ self.close()
132
+
133
+
134
+ class Cursor:
135
+ def __init__(self, client: ThunderduckClient, *, arraysize: int = DEFAULT_ARRAYSIZE) -> None:
136
+ self._client = client
137
+ self.arraysize = arraysize
138
+ self._closed = False
139
+ self._exec_id: str | None = None
140
+ self._description: list[tuple] | None = None
141
+ self._rowcount = -1
142
+ self._buffer: list[tuple] = []
143
+ self._buffer_pos = 0
144
+ self._next_offset = 0
145
+ self._total = 0
146
+ self._exhausted = True
147
+
148
+ # ---- introspection ----
149
+
150
+ @property
151
+ def description(self) -> list[tuple] | None:
152
+ return self._description
153
+
154
+ @property
155
+ def rowcount(self) -> int:
156
+ return self._rowcount
157
+
158
+ @property
159
+ def execution_id(self) -> str | None:
160
+ """The thunderduck execution uuid of the last `execute`, if any."""
161
+ return self._exec_id
162
+
163
+ # ---- execution ----
164
+
165
+ def execute(self, operation: str, parameters: Any = None) -> Cursor:
166
+ self._check_open()
167
+ self._reset()
168
+ sql = render_sql(operation, parameters)
169
+ self._exec_id = self._client.submit(sql)
170
+ self._client.await_execution(self._exec_id)
171
+ self._load_first_page()
172
+ return self
173
+
174
+ def executemany(self, operation: str, seq_of_parameters: Any) -> None:
175
+ raise exc.NotSupportedError(
176
+ "executemany is not supported: thunderduck is read-only and every "
177
+ "statement is a separate Kubernetes Job"
178
+ )
179
+
180
+ def cancel(self) -> None:
181
+ """Best-effort cancel of the in-flight (or last) execution."""
182
+ if self._exec_id:
183
+ self._client.cancel(self._exec_id)
184
+
185
+ # ---- fetching ----
186
+
187
+ def fetchone(self) -> tuple | None:
188
+ self._check_executed()
189
+ if not self._ensure_buffered():
190
+ return None
191
+ row = self._buffer[self._buffer_pos]
192
+ self._buffer_pos += 1
193
+ return row
194
+
195
+ def fetchmany(self, size: int | None = None) -> list[tuple]:
196
+ self._check_executed()
197
+ want = self.arraysize if size is None else size
198
+ out: list[tuple] = []
199
+ while len(out) < want:
200
+ row = self.fetchone()
201
+ if row is None:
202
+ break
203
+ out.append(row)
204
+ return out
205
+
206
+ def fetchall(self) -> list[tuple]:
207
+ self._check_executed()
208
+ out: list[tuple] = []
209
+ while True:
210
+ row = self.fetchone()
211
+ if row is None:
212
+ return out
213
+ out.append(row)
214
+
215
+ def close(self) -> None:
216
+ self._closed = True
217
+
218
+ def __iter__(self):
219
+ while True:
220
+ row = self.fetchone()
221
+ if row is None:
222
+ return
223
+ yield row
224
+
225
+ # ---- internals ----
226
+
227
+ def _reset(self) -> None:
228
+ self._description = None
229
+ self._rowcount = -1
230
+ self._buffer = []
231
+ self._buffer_pos = 0
232
+ self._next_offset = 0
233
+ self._total = 0
234
+ self._exhausted = True
235
+
236
+ def _load_first_page(self) -> None:
237
+ assert self._exec_id is not None
238
+ try:
239
+ page = self._client.fetch_page(self._exec_id, limit=self.arraysize, offset=0)
240
+ except NoStoredResult:
241
+ # A non-SELECT statement: no result set, and that is fine.
242
+ self._description = None
243
+ self._rowcount = -1
244
+ self._buffer = []
245
+ self._exhausted = True
246
+ return
247
+ self._description = [
248
+ (
249
+ name,
250
+ sqlalchemy_type(type_name),
251
+ None, # display_size
252
+ None, # internal_size
253
+ None, # precision
254
+ None, # scale
255
+ True, # null_ok -- /catalog/schema carries no nullability
256
+ )
257
+ for name, type_name in zip(
258
+ page.columns,
259
+ list(page.column_types) + [""] * (len(page.columns) - len(page.column_types)),
260
+ strict=False,
261
+ )
262
+ ]
263
+ self._total = page.total
264
+ self._rowcount = page.total
265
+ self._buffer = [tuple(r) for r in page.rows]
266
+ self._buffer_pos = 0
267
+ self._next_offset = len(page.rows)
268
+ self._exhausted = self._next_offset >= self._total or not page.rows
269
+
270
+ def _ensure_buffered(self) -> bool:
271
+ """True if a row is available at `_buffer_pos`, paging if needed."""
272
+ if self._buffer_pos < len(self._buffer):
273
+ return True
274
+ if self._exhausted:
275
+ return False
276
+ assert self._exec_id is not None
277
+ page = self._client.fetch_page(
278
+ self._exec_id, limit=self.arraysize, offset=self._next_offset
279
+ )
280
+ if not page.rows:
281
+ # The result set claims more rows than it can serve. Never silently
282
+ # return a short result -- a truncated dashboard looks correct.
283
+ _log.warning(
284
+ "thunderduck result set is short: served %d of %d claimed rows "
285
+ "for execution %s; the stored result may have been truncated",
286
+ self._next_offset,
287
+ self._total,
288
+ self._exec_id,
289
+ )
290
+ self._exhausted = True
291
+ return False
292
+ self._buffer = [tuple(r) for r in page.rows]
293
+ self._buffer_pos = 0
294
+ self._next_offset += len(page.rows)
295
+ self._total = page.total or self._total
296
+ self._exhausted = self._next_offset >= self._total
297
+ return True
298
+
299
+ def _check_open(self) -> None:
300
+ if self._closed:
301
+ raise exc.ProgrammingError("cursor is closed")
302
+
303
+ def _check_executed(self) -> None:
304
+ self._check_open()
305
+ if self._exec_id is None:
306
+ raise exc.ProgrammingError("no query has been executed on this cursor")