Skip to content

Commit db1c8bb

Browse files
committed
feat: adopt hotdata 0.4.1 + ruff cleanup
Bump the `hotdata` SDK dependency to 0.4.1 (refresh `uv.lock`) and adopt the new 0.4.x surface: - New error-handling exports: `HotdataError`, `HotdataTerminalError`, `HotdataTransientError`, `classify_sdk_error`, and `ManagedDatabaseClient` (`errors.py`, `managed_client.py`). - Test fixtures updated for 0.4.1: `QueryResponse` now requires `preview_row_count`/`truncated`; contract test `__all__` list updated. - Includes the pending ruff lint/format cleanup (import sorting, simplifications, unused-import removal). Verified: full suite passes — 53 passed.
1 parent efa6d54 commit db1c8bb

13 files changed

Lines changed: 640 additions & 55 deletions

hotdata_runtime/__init__.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Hotdata runtime primitives for notebook and app integrations."""
1+
"""Hotdata runtime primitives for notebook, app, and adapter integrations."""
22

33
from importlib.metadata import PackageNotFoundError, version
44

@@ -16,6 +16,7 @@
1616
is_parquet_path,
1717
)
1818
from hotdata_runtime.env import (
19+
WorkspaceSelection,
1920
default_api_key,
2021
default_host,
2122
default_session_id,
@@ -24,9 +25,15 @@
2425
normalize_host,
2526
pick_workspace,
2627
resolve_workspace_selection,
27-
WorkspaceSelection,
28+
)
29+
from hotdata_runtime.errors import (
30+
HotdataError,
31+
HotdataTerminalError,
32+
HotdataTransientError,
33+
classify_sdk_error,
2834
)
2935
from hotdata_runtime.health import workspace_health_lines
36+
from hotdata_runtime.managed_client import ManagedDatabaseClient
3037
from hotdata_runtime.result import QueryResult
3138

3239
try:
@@ -35,25 +42,30 @@
3542
__version__ = "0.0.0+unknown"
3643

3744
__all__ = [
38-
"__version__",
3945
"DEFAULT_SCHEMA",
4046
"HotdataClient",
47+
"HotdataError",
48+
"HotdataTerminalError",
49+
"HotdataTransientError",
50+
"ManagedDatabaseClient",
51+
"classify_sdk_error",
4152
"LoadManagedTableResult",
4253
"ManagedDatabase",
4354
"ManagedTable",
4455
"QueryResult",
45-
"is_parquet_path",
46-
"workspace_health_lines",
56+
"ResultSummary",
57+
"RunHistoryItem",
58+
"WorkspaceSelection",
59+
"__version__",
4760
"default_api_key",
4861
"default_host",
4962
"default_session_id",
5063
"explicit_workspace_id",
5164
"from_env",
65+
"is_parquet_path",
5266
"list_workspaces",
5367
"normalize_host",
5468
"pick_workspace",
5569
"resolve_workspace_selection",
56-
"ResultSummary",
57-
"RunHistoryItem",
58-
"WorkspaceSelection",
70+
"workspace_health_lines",
5971
]

hotdata_runtime/client.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
from __future__ import annotations
22

3-
from dataclasses import asdict, dataclass
43
import time
5-
from typing import Any, Iterator
6-
7-
from urllib3.exceptions import HTTPError as Urllib3HTTPError
8-
from urllib3.exceptions import ProtocolError
4+
from collections.abc import Iterator
5+
from dataclasses import asdict, dataclass
6+
from typing import Any
97

108
from hotdata import ApiClient, Configuration
119
from hotdata.api.connections_api import ConnectionsApi
@@ -24,14 +22,9 @@
2422
from hotdata.models.query_request import QueryRequest
2523
from hotdata.models.query_response import QueryResponse
2624
from hotdata.models.table_info import TableInfo
25+
from urllib3.exceptions import HTTPError as Urllib3HTTPError
26+
from urllib3.exceptions import ProtocolError
2727

28-
from hotdata_runtime.env import (
29-
default_api_key,
30-
default_host,
31-
default_session_id,
32-
normalize_host,
33-
pick_workspace,
34-
)
3528
from hotdata_runtime.databases import (
3629
DEFAULT_SCHEMA,
3730
LoadManagedTableResult,
@@ -41,6 +34,13 @@
4134
is_parquet_path,
4235
managed_database_from_detail,
4336
)
37+
from hotdata_runtime.env import (
38+
default_api_key,
39+
default_host,
40+
default_session_id,
41+
normalize_host,
42+
pick_workspace,
43+
)
4444
from hotdata_runtime.http import default_http_retries
4545
from hotdata_runtime.result import QueryResult
4646

hotdata_runtime/errors.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from __future__ import annotations
2+
3+
from hotdata.rest import ApiException
4+
5+
6+
class HotdataError(RuntimeError):
7+
pass
8+
9+
10+
class HotdataTransientError(HotdataError):
11+
pass
12+
13+
14+
class HotdataTerminalError(HotdataError):
15+
pass
16+
17+
18+
def classify_sdk_error(error: Exception) -> HotdataError:
19+
if isinstance(error, TimeoutError):
20+
return HotdataTransientError(str(error))
21+
if isinstance(error, ConnectionError):
22+
return HotdataTransientError(str(error))
23+
if isinstance(error, ApiException):
24+
status_code = int(error.status or 0)
25+
message = f"{status_code}: {error.reason or 'unknown error'}"
26+
if status_code in (408, 409, 425, 429):
27+
return HotdataTransientError(message)
28+
if 500 <= status_code <= 599:
29+
return HotdataTransientError(message)
30+
return HotdataTerminalError(message)
31+
return HotdataTerminalError(str(error))

hotdata_runtime/managed_client.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
"""Retry-wrapped managed-database client shared by Hotdata adapter packages.
2+
3+
Both hotdata-airflow and hotdata-dlt-destination import this module so that
4+
the higher-level client logic (retries, Arrow queries, table management) lives
5+
in one place rather than being duplicated per adapter.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import time
11+
from collections.abc import Callable
12+
from typing import Any, TypeVar
13+
14+
import pyarrow as pa
15+
from hotdata.api.query_api import QueryApi
16+
from hotdata.api.query_runs_api import QueryRunsApi
17+
from hotdata.api.results_api import ResultsApi
18+
from hotdata.arrow import ResultsApi as ArrowResultsApi
19+
from hotdata.models.async_query_response import AsyncQueryResponse
20+
from hotdata.models.query_request import QueryRequest
21+
from hotdata.models.query_response import QueryResponse
22+
from hotdata_runtime.client import HotdataClient as RuntimeClient
23+
from hotdata_runtime.databases import LoadManagedTableResult, ManagedDatabase
24+
from hotdata_runtime.errors import (
25+
HotdataTerminalError,
26+
HotdataTransientError,
27+
classify_sdk_error,
28+
)
29+
30+
T = TypeVar("T")
31+
32+
33+
class ManagedDatabaseClient:
34+
"""Managed-database client with bounded retries over hotdata-runtime.
35+
36+
This is the shared client used by Hotdata adapter packages (Airflow,
37+
dlt, etc.). It wraps the lower-level RuntimeClient with retry logic,
38+
Arrow-based result fetching, and convenience helpers for the managed
39+
database lifecycle.
40+
"""
41+
42+
def __init__(
43+
self,
44+
*,
45+
api_key: str,
46+
workspace_id: str,
47+
api_base_url: str,
48+
max_retries: int,
49+
retry_backoff_seconds: float,
50+
) -> None:
51+
self._max_retries = max_retries
52+
self._retry_backoff_seconds = retry_backoff_seconds
53+
self._runtime = RuntimeClient(
54+
api_key,
55+
workspace_id,
56+
host=api_base_url.rstrip("/"),
57+
)
58+
59+
def close(self) -> None:
60+
self._runtime.close()
61+
62+
def ensure_managed_database(
63+
self,
64+
name: str,
65+
*,
66+
schema: str,
67+
tables: list[str],
68+
create_if_missing: bool,
69+
) -> ManagedDatabase:
70+
def operation() -> ManagedDatabase:
71+
try:
72+
return self._runtime.resolve_managed_database(name)
73+
except KeyError:
74+
if not create_if_missing:
75+
raise
76+
return self._runtime.create_managed_database(
77+
description=name,
78+
schema=schema,
79+
tables=sorted(set(tables)),
80+
)
81+
82+
return self._request_with_retry(operation)
83+
84+
def table_is_synced(self, database: str, table: str, *, schema: str) -> bool:
85+
for managed_table in self._runtime.list_managed_tables(database, schema=schema):
86+
if managed_table.table == table:
87+
return managed_table.synced
88+
return False
89+
90+
def fetch_table(self, *, database: str, schema: str, table: str) -> pa.Table | None:
91+
def operation() -> pa.Table | None:
92+
if not self.table_is_synced(database, table, schema=schema):
93+
return None
94+
db = self._runtime.resolve_managed_database(database)
95+
sql = f'SELECT * FROM "default"."{schema}"."{table}"'
96+
result_id = self._query_database_scoped(sql, database_id=db.id)
97+
if result_id is None:
98+
return None
99+
return ArrowResultsApi(self._runtime.api).get_result_arrow(result_id)
100+
101+
return self._request_with_retry(operation)
102+
103+
_QUERY_TIMEOUT_SECONDS = 300.0
104+
105+
def _query_database_scoped(self, sql: str, *, database_id: str) -> str | None:
106+
raw = QueryApi(self._runtime.api).query(
107+
QueryRequest(sql=sql),
108+
x_database_id=database_id,
109+
)
110+
if isinstance(raw, QueryResponse):
111+
return raw.result_id
112+
113+
if isinstance(raw, AsyncQueryResponse):
114+
runs = QueryRunsApi(self._runtime.api)
115+
deadline = time.monotonic() + self._QUERY_TIMEOUT_SECONDS
116+
result_id: str | None = None
117+
while time.monotonic() < deadline:
118+
run = runs.get_query_run(raw.query_run_id)
119+
if run.status == "succeeded":
120+
result_id = run.result_id
121+
break
122+
if run.status in ("failed", "cancelled"):
123+
raise RuntimeError(run.error_message or f"Query {run.status}")
124+
time.sleep(0.5)
125+
else:
126+
raise TimeoutError(
127+
f"Managed database query timed out after {self._QUERY_TIMEOUT_SECONDS}s"
128+
)
129+
return self._wait_result_ready(result_id)
130+
131+
return None
132+
133+
def _wait_result_ready(self, result_id: str | None) -> str | None:
134+
if result_id is None:
135+
return None
136+
results = ResultsApi(self._runtime.api)
137+
deadline = time.monotonic() + self._QUERY_TIMEOUT_SECONDS
138+
while time.monotonic() < deadline:
139+
r = results.get_result(result_id)
140+
if r.status == "ready":
141+
return result_id
142+
if r.status in ("failed", "cancelled"):
143+
raise RuntimeError(r.error_message or f"Result {r.status}")
144+
time.sleep(0.3)
145+
raise TimeoutError(f"Result {result_id} not ready after {self._QUERY_TIMEOUT_SECONDS}s")
146+
147+
def fetch_table_rows(self, *, database: str, schema: str, table: str) -> list[dict[str, Any]]:
148+
result = self.fetch_table(database=database, schema=schema, table=table)
149+
return result.to_pylist() if result is not None else []
150+
151+
def upload_parquet(self, path: str) -> str:
152+
return self._request_with_retry(lambda: self._runtime.upload_parquet(path))
153+
154+
def load_managed_table(
155+
self,
156+
database: str,
157+
table: str,
158+
*,
159+
schema: str,
160+
upload_id: str,
161+
) -> LoadManagedTableResult:
162+
return self._request_with_retry(
163+
lambda: self._runtime.load_managed_table(
164+
database,
165+
table,
166+
schema=schema,
167+
upload_id=upload_id,
168+
)
169+
)
170+
171+
_MAX_BACKOFF_SECONDS = 30.0
172+
173+
def _request_with_retry(self, operation: Callable[[], T]) -> T:
174+
for attempt in range(1, self._max_retries + 1):
175+
try:
176+
return operation()
177+
except Exception as error:
178+
mapped_error = classify_sdk_error(error.__cause__ or error)
179+
if isinstance(mapped_error, HotdataTransientError) and attempt < self._max_retries:
180+
backoff = min(self._retry_backoff_seconds * attempt, self._MAX_BACKOFF_SECONDS)
181+
time.sleep(backoff)
182+
continue
183+
raise mapped_error from error
184+
raise RuntimeError("No retry attempts configured")

hotdata_runtime/py.typed

Whitespace-only changes.

hotdata_runtime/result.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def to_records(
2626
max_rows: int | None = None,
2727
) -> list[dict[str, Any]]:
2828
rows = self.rows if max_rows is None else self.rows[:max_rows]
29-
return [dict(zip(self.columns, row)) for row in rows]
29+
return [dict(zip(self.columns, row, strict=True)) for row in rows]
3030

3131
def metadata_dict(self) -> dict[str, Any]:
3232
return {

0 commit comments

Comments
 (0)