hotdata-framework 0.4.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.
- hotdata_framework/__init__.py +71 -0
- hotdata_framework/client.py +524 -0
- hotdata_framework/databases.py +61 -0
- hotdata_framework/env.py +81 -0
- hotdata_framework/errors.py +31 -0
- hotdata_framework/health.py +27 -0
- hotdata_framework/http.py +17 -0
- hotdata_framework/managed_client.py +184 -0
- hotdata_framework/py.typed +0 -0
- hotdata_framework/result.py +75 -0
- hotdata_framework-0.4.0.dist-info/METADATA +69 -0
- hotdata_framework-0.4.0.dist-info/RECORD +13 -0
- hotdata_framework-0.4.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Hotdata runtime primitives for notebook, app, and adapter integrations."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from hotdata_framework.client import (
|
|
6
|
+
HotdataClient,
|
|
7
|
+
ResultSummary,
|
|
8
|
+
RunHistoryItem,
|
|
9
|
+
from_env,
|
|
10
|
+
)
|
|
11
|
+
from hotdata_framework.databases import (
|
|
12
|
+
DEFAULT_SCHEMA,
|
|
13
|
+
LoadManagedTableResult,
|
|
14
|
+
ManagedDatabase,
|
|
15
|
+
ManagedTable,
|
|
16
|
+
is_parquet_path,
|
|
17
|
+
)
|
|
18
|
+
from hotdata_framework.env import (
|
|
19
|
+
WorkspaceSelection,
|
|
20
|
+
default_api_key,
|
|
21
|
+
default_host,
|
|
22
|
+
default_session_id,
|
|
23
|
+
explicit_workspace_id,
|
|
24
|
+
list_workspaces,
|
|
25
|
+
normalize_host,
|
|
26
|
+
pick_workspace,
|
|
27
|
+
resolve_workspace_selection,
|
|
28
|
+
)
|
|
29
|
+
from hotdata_framework.errors import (
|
|
30
|
+
HotdataError,
|
|
31
|
+
HotdataTerminalError,
|
|
32
|
+
HotdataTransientError,
|
|
33
|
+
classify_sdk_error,
|
|
34
|
+
)
|
|
35
|
+
from hotdata_framework.health import workspace_health_lines
|
|
36
|
+
from hotdata_framework.managed_client import ManagedDatabaseClient
|
|
37
|
+
from hotdata_framework.result import QueryResult
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
__version__ = version("hotdata-framework")
|
|
41
|
+
except PackageNotFoundError:
|
|
42
|
+
__version__ = "0.0.0+unknown"
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"DEFAULT_SCHEMA",
|
|
46
|
+
"HotdataClient",
|
|
47
|
+
"HotdataError",
|
|
48
|
+
"HotdataTerminalError",
|
|
49
|
+
"HotdataTransientError",
|
|
50
|
+
"LoadManagedTableResult",
|
|
51
|
+
"ManagedDatabase",
|
|
52
|
+
"ManagedDatabaseClient",
|
|
53
|
+
"ManagedTable",
|
|
54
|
+
"QueryResult",
|
|
55
|
+
"ResultSummary",
|
|
56
|
+
"RunHistoryItem",
|
|
57
|
+
"WorkspaceSelection",
|
|
58
|
+
"__version__",
|
|
59
|
+
"classify_sdk_error",
|
|
60
|
+
"default_api_key",
|
|
61
|
+
"default_host",
|
|
62
|
+
"default_session_id",
|
|
63
|
+
"explicit_workspace_id",
|
|
64
|
+
"from_env",
|
|
65
|
+
"is_parquet_path",
|
|
66
|
+
"list_workspaces",
|
|
67
|
+
"normalize_host",
|
|
68
|
+
"pick_workspace",
|
|
69
|
+
"resolve_workspace_selection",
|
|
70
|
+
"workspace_health_lines",
|
|
71
|
+
]
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from hotdata import ApiClient, Configuration
|
|
9
|
+
from hotdata.api.connections_api import ConnectionsApi
|
|
10
|
+
from hotdata.api.databases_api import DatabasesApi
|
|
11
|
+
from hotdata.api.information_schema_api import InformationSchemaApi
|
|
12
|
+
from hotdata.api.query_api import QueryApi
|
|
13
|
+
from hotdata.api.query_runs_api import QueryRunsApi
|
|
14
|
+
from hotdata.api.results_api import ResultsApi
|
|
15
|
+
from hotdata.api.uploads_api import UploadsApi
|
|
16
|
+
from hotdata.exceptions import ApiException
|
|
17
|
+
from hotdata.models.async_query_response import AsyncQueryResponse
|
|
18
|
+
from hotdata.models.create_database_request import CreateDatabaseRequest
|
|
19
|
+
from hotdata.models.database_default_schema_decl import DatabaseDefaultSchemaDecl
|
|
20
|
+
from hotdata.models.database_default_table_decl import DatabaseDefaultTableDecl
|
|
21
|
+
from hotdata.models.load_managed_table_request import LoadManagedTableRequest
|
|
22
|
+
from hotdata.models.query_request import QueryRequest
|
|
23
|
+
from hotdata.models.query_response import QueryResponse
|
|
24
|
+
from hotdata.models.table_info import TableInfo
|
|
25
|
+
from urllib3.exceptions import HTTPError as Urllib3HTTPError
|
|
26
|
+
from urllib3.exceptions import ProtocolError
|
|
27
|
+
|
|
28
|
+
from hotdata_framework.databases import (
|
|
29
|
+
DEFAULT_SCHEMA,
|
|
30
|
+
LoadManagedTableResult,
|
|
31
|
+
ManagedDatabase,
|
|
32
|
+
ManagedTable,
|
|
33
|
+
api_error_message,
|
|
34
|
+
is_parquet_path,
|
|
35
|
+
managed_database_from_detail,
|
|
36
|
+
)
|
|
37
|
+
from hotdata_framework.env import (
|
|
38
|
+
default_api_key,
|
|
39
|
+
default_host,
|
|
40
|
+
default_session_id,
|
|
41
|
+
normalize_host,
|
|
42
|
+
pick_workspace,
|
|
43
|
+
)
|
|
44
|
+
from hotdata_framework.http import default_http_retries
|
|
45
|
+
from hotdata_framework.result import QueryResult
|
|
46
|
+
|
|
47
|
+
_TERMINAL = frozenset({"succeeded", "failed", "cancelled"})
|
|
48
|
+
_RESULT_FAILURE = frozenset({"failed", "cancelled"})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class ResultSummary:
|
|
53
|
+
result_id: str
|
|
54
|
+
status: str
|
|
55
|
+
created_at: str | None
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> dict[str, Any]:
|
|
58
|
+
return asdict(self)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class RunHistoryItem:
|
|
63
|
+
query_run_id: str
|
|
64
|
+
status: str
|
|
65
|
+
created_at: str | None
|
|
66
|
+
execution_time_ms: int | None
|
|
67
|
+
result_id: str | None
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> dict[str, Any]:
|
|
70
|
+
return asdict(self)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class HotdataClient:
|
|
74
|
+
"""Thin wrapper around the Hotdata Python SDK with query polling helpers."""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
api_key: str,
|
|
79
|
+
workspace_id: str,
|
|
80
|
+
*,
|
|
81
|
+
host: str | None = None,
|
|
82
|
+
session_id: str | None = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
self._host = normalize_host(host) if host else default_host()
|
|
85
|
+
self._api_key = api_key
|
|
86
|
+
self._workspace_id = workspace_id
|
|
87
|
+
self._session_id = session_id
|
|
88
|
+
self._config = Configuration(
|
|
89
|
+
host=self._host,
|
|
90
|
+
api_key=api_key,
|
|
91
|
+
workspace_id=workspace_id,
|
|
92
|
+
session_id=session_id,
|
|
93
|
+
retries=default_http_retries(),
|
|
94
|
+
)
|
|
95
|
+
self._api = ApiClient(self._config)
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def from_env(cls) -> HotdataClient:
|
|
99
|
+
api_key = default_api_key()
|
|
100
|
+
if not api_key:
|
|
101
|
+
raise RuntimeError("HOTDATA_API_KEY must be set.")
|
|
102
|
+
host = default_host()
|
|
103
|
+
session = default_session_id()
|
|
104
|
+
workspace_id = pick_workspace(api_key, host, session)
|
|
105
|
+
return cls(api_key, workspace_id, host=host, session_id=session)
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def workspace_id(self) -> str:
|
|
109
|
+
return self._workspace_id
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def host(self) -> str:
|
|
113
|
+
return self._host
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def session_id(self) -> str | None:
|
|
117
|
+
return self._session_id
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def api(self) -> ApiClient:
|
|
121
|
+
return self._api
|
|
122
|
+
|
|
123
|
+
def close(self) -> None:
|
|
124
|
+
self._api.close()
|
|
125
|
+
|
|
126
|
+
def __enter__(self) -> HotdataClient:
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
def __exit__(self, *args: object) -> None:
|
|
130
|
+
self.close()
|
|
131
|
+
|
|
132
|
+
def connections(self) -> ConnectionsApi:
|
|
133
|
+
return ConnectionsApi(self._api)
|
|
134
|
+
|
|
135
|
+
def _databases_api(self) -> DatabasesApi:
|
|
136
|
+
return DatabasesApi(self._api)
|
|
137
|
+
|
|
138
|
+
def _information_schema(self) -> InformationSchemaApi:
|
|
139
|
+
return InformationSchemaApi(self._api)
|
|
140
|
+
|
|
141
|
+
def _query_api(self) -> QueryApi:
|
|
142
|
+
return QueryApi(self._api)
|
|
143
|
+
|
|
144
|
+
def _query_runs_api(self) -> QueryRunsApi:
|
|
145
|
+
return QueryRunsApi(self._api)
|
|
146
|
+
|
|
147
|
+
def _results_api(self) -> ResultsApi:
|
|
148
|
+
return ResultsApi(self._api)
|
|
149
|
+
|
|
150
|
+
def query_runs(self) -> QueryRunsApi:
|
|
151
|
+
return self._query_runs_api()
|
|
152
|
+
|
|
153
|
+
def results(self) -> ResultsApi:
|
|
154
|
+
return self._results_api()
|
|
155
|
+
|
|
156
|
+
def uploads(self) -> UploadsApi:
|
|
157
|
+
return UploadsApi(self._api)
|
|
158
|
+
|
|
159
|
+
def list_managed_databases(self) -> list[ManagedDatabase]:
|
|
160
|
+
listing = self._databases_api().list_databases()
|
|
161
|
+
result: list[ManagedDatabase] = []
|
|
162
|
+
for summary in listing.databases:
|
|
163
|
+
try:
|
|
164
|
+
detail = self._databases_api().get_database(summary.id)
|
|
165
|
+
result.append(managed_database_from_detail(detail))
|
|
166
|
+
except ApiException:
|
|
167
|
+
pass
|
|
168
|
+
return result
|
|
169
|
+
|
|
170
|
+
def resolve_managed_database(self, name_or_id: str) -> ManagedDatabase:
|
|
171
|
+
# Try direct ID lookup first
|
|
172
|
+
try:
|
|
173
|
+
detail = self._databases_api().get_database(name_or_id)
|
|
174
|
+
return managed_database_from_detail(detail)
|
|
175
|
+
except ApiException as e:
|
|
176
|
+
if e.status != 404:
|
|
177
|
+
raise RuntimeError(api_error_message(e)) from e
|
|
178
|
+
|
|
179
|
+
# Fall back to description-based lookup
|
|
180
|
+
listing = self._databases_api().list_databases()
|
|
181
|
+
match_id: str | None = None
|
|
182
|
+
for db in listing.databases:
|
|
183
|
+
if db.name == name_or_id:
|
|
184
|
+
match_id = db.id
|
|
185
|
+
break
|
|
186
|
+
if match_id is None:
|
|
187
|
+
raise KeyError(f"No database named or with id {name_or_id!r}")
|
|
188
|
+
try:
|
|
189
|
+
detail = self._databases_api().get_database(match_id)
|
|
190
|
+
except ApiException as e:
|
|
191
|
+
raise RuntimeError(api_error_message(e)) from e
|
|
192
|
+
return managed_database_from_detail(detail)
|
|
193
|
+
|
|
194
|
+
def create_managed_database(
|
|
195
|
+
self,
|
|
196
|
+
description: str | None = None,
|
|
197
|
+
*,
|
|
198
|
+
schema: str = DEFAULT_SCHEMA,
|
|
199
|
+
tables: list[str] | None = None,
|
|
200
|
+
expires_at: str | None = None,
|
|
201
|
+
) -> ManagedDatabase:
|
|
202
|
+
schemas = None
|
|
203
|
+
if tables:
|
|
204
|
+
schemas = [
|
|
205
|
+
DatabaseDefaultSchemaDecl(
|
|
206
|
+
name=schema,
|
|
207
|
+
tables=[DatabaseDefaultTableDecl(name=t) for t in tables],
|
|
208
|
+
)
|
|
209
|
+
]
|
|
210
|
+
request = CreateDatabaseRequest(
|
|
211
|
+
name=description,
|
|
212
|
+
schemas=schemas,
|
|
213
|
+
expires_at=expires_at,
|
|
214
|
+
)
|
|
215
|
+
try:
|
|
216
|
+
created = self._databases_api().create_database(request)
|
|
217
|
+
except ApiException as e:
|
|
218
|
+
raise RuntimeError(api_error_message(e)) from e
|
|
219
|
+
return managed_database_from_detail(created)
|
|
220
|
+
|
|
221
|
+
def delete_managed_database(self, name_or_id: str) -> None:
|
|
222
|
+
db = self.resolve_managed_database(name_or_id)
|
|
223
|
+
try:
|
|
224
|
+
self._databases_api().delete_database(db.id)
|
|
225
|
+
except ApiException as e:
|
|
226
|
+
raise RuntimeError(api_error_message(e)) from e
|
|
227
|
+
|
|
228
|
+
def list_managed_tables(
|
|
229
|
+
self,
|
|
230
|
+
database: str,
|
|
231
|
+
*,
|
|
232
|
+
schema: str | None = None,
|
|
233
|
+
) -> list[ManagedTable]:
|
|
234
|
+
db = self.resolve_managed_database(database)
|
|
235
|
+
rows: list[ManagedTable] = []
|
|
236
|
+
for t in self.iter_tables(connection_id=db.default_connection_id):
|
|
237
|
+
if schema is not None and t.var_schema != schema:
|
|
238
|
+
continue
|
|
239
|
+
rows.append(
|
|
240
|
+
ManagedTable(
|
|
241
|
+
full_name=f"{db.id}.{t.var_schema}.{t.table}",
|
|
242
|
+
schema=t.var_schema,
|
|
243
|
+
table=t.table,
|
|
244
|
+
synced=t.synced,
|
|
245
|
+
last_sync=t.last_sync,
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
rows.sort(key=lambda row: (row.schema, row.table))
|
|
249
|
+
return rows
|
|
250
|
+
|
|
251
|
+
def upload_parquet(self, path: str) -> str:
|
|
252
|
+
if not is_parquet_path(path):
|
|
253
|
+
raise ValueError(f"Managed table loads require a parquet file (got {path!r})")
|
|
254
|
+
with open(path, "rb") as f:
|
|
255
|
+
data = f.read()
|
|
256
|
+
try:
|
|
257
|
+
uploaded = self.uploads().upload_file(
|
|
258
|
+
data,
|
|
259
|
+
_content_type="application/octet-stream",
|
|
260
|
+
)
|
|
261
|
+
except ApiException as e:
|
|
262
|
+
raise RuntimeError(api_error_message(e)) from e
|
|
263
|
+
return uploaded.id
|
|
264
|
+
|
|
265
|
+
def load_managed_table(
|
|
266
|
+
self,
|
|
267
|
+
database: str,
|
|
268
|
+
table: str,
|
|
269
|
+
*,
|
|
270
|
+
schema: str = DEFAULT_SCHEMA,
|
|
271
|
+
upload_id: str | None = None,
|
|
272
|
+
file: str | None = None,
|
|
273
|
+
) -> LoadManagedTableResult:
|
|
274
|
+
if (upload_id is None) == (file is None):
|
|
275
|
+
raise ValueError("Exactly one of upload_id or file is required")
|
|
276
|
+
db = self.resolve_managed_database(database)
|
|
277
|
+
if upload_id is not None:
|
|
278
|
+
resolved_upload_id = upload_id
|
|
279
|
+
else:
|
|
280
|
+
assert file is not None
|
|
281
|
+
resolved_upload_id = self.upload_parquet(file)
|
|
282
|
+
request = LoadManagedTableRequest(
|
|
283
|
+
mode="replace",
|
|
284
|
+
upload_id=resolved_upload_id,
|
|
285
|
+
)
|
|
286
|
+
try:
|
|
287
|
+
loaded = self.connections().load_managed_table(
|
|
288
|
+
db.default_connection_id,
|
|
289
|
+
schema,
|
|
290
|
+
table,
|
|
291
|
+
request,
|
|
292
|
+
)
|
|
293
|
+
except ApiException as e:
|
|
294
|
+
raise RuntimeError(api_error_message(e)) from e
|
|
295
|
+
return LoadManagedTableResult(
|
|
296
|
+
connection_id=loaded.connection_id,
|
|
297
|
+
schema_name=loaded.schema_name,
|
|
298
|
+
table_name=loaded.table_name,
|
|
299
|
+
row_count=loaded.row_count,
|
|
300
|
+
full_name=f"{db.id}.{loaded.schema_name}.{loaded.table_name}",
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
def delete_managed_table(
|
|
304
|
+
self,
|
|
305
|
+
database: str,
|
|
306
|
+
table: str,
|
|
307
|
+
*,
|
|
308
|
+
schema: str = DEFAULT_SCHEMA,
|
|
309
|
+
) -> None:
|
|
310
|
+
db = self.resolve_managed_database(database)
|
|
311
|
+
try:
|
|
312
|
+
self.connections().delete_managed_table(db.default_connection_id, schema, table)
|
|
313
|
+
except ApiException as e:
|
|
314
|
+
raise RuntimeError(api_error_message(e)) from e
|
|
315
|
+
|
|
316
|
+
def list_recent_results(
|
|
317
|
+
self,
|
|
318
|
+
*,
|
|
319
|
+
limit: int = 50,
|
|
320
|
+
offset: int = 0,
|
|
321
|
+
) -> list[ResultSummary]:
|
|
322
|
+
listing = self.results().list_results(limit=limit, offset=offset)
|
|
323
|
+
return [
|
|
324
|
+
ResultSummary(
|
|
325
|
+
result_id=r.id,
|
|
326
|
+
status=r.status,
|
|
327
|
+
created_at=r.created_at,
|
|
328
|
+
)
|
|
329
|
+
for r in listing.results
|
|
330
|
+
]
|
|
331
|
+
|
|
332
|
+
def list_run_history(
|
|
333
|
+
self,
|
|
334
|
+
*,
|
|
335
|
+
limit: int = 20,
|
|
336
|
+
) -> list[RunHistoryItem]:
|
|
337
|
+
listing = self.query_runs().list_query_runs(limit=limit)
|
|
338
|
+
return [
|
|
339
|
+
RunHistoryItem(
|
|
340
|
+
query_run_id=r.id,
|
|
341
|
+
status=r.status,
|
|
342
|
+
created_at=r.created_at,
|
|
343
|
+
execution_time_ms=r.execution_time_ms,
|
|
344
|
+
result_id=r.result_id,
|
|
345
|
+
)
|
|
346
|
+
for r in listing.query_runs
|
|
347
|
+
]
|
|
348
|
+
|
|
349
|
+
def iter_tables(
|
|
350
|
+
self,
|
|
351
|
+
*,
|
|
352
|
+
connection_id: str | None = None,
|
|
353
|
+
include_columns: bool = False,
|
|
354
|
+
page_size: int = 200,
|
|
355
|
+
) -> Iterator[TableInfo]:
|
|
356
|
+
cursor: str | None = None
|
|
357
|
+
while True:
|
|
358
|
+
resp = self._information_schema().information_schema(
|
|
359
|
+
connection_id=connection_id,
|
|
360
|
+
include_columns=include_columns,
|
|
361
|
+
limit=page_size,
|
|
362
|
+
cursor=cursor,
|
|
363
|
+
)
|
|
364
|
+
yield from resp.tables
|
|
365
|
+
if not resp.has_more or not resp.next_cursor:
|
|
366
|
+
break
|
|
367
|
+
cursor = resp.next_cursor
|
|
368
|
+
|
|
369
|
+
def qualified_table_name(self, t: TableInfo) -> str:
|
|
370
|
+
return f"{t.connection}.{t.var_schema}.{t.table}"
|
|
371
|
+
|
|
372
|
+
def list_qualified_table_names(
|
|
373
|
+
self, *, limit: int = 5000, connection_id: str | None = None
|
|
374
|
+
) -> list[str]:
|
|
375
|
+
out: list[str] = []
|
|
376
|
+
for t in self.iter_tables(connection_id=connection_id):
|
|
377
|
+
out.append(self.qualified_table_name(t))
|
|
378
|
+
if len(out) >= limit:
|
|
379
|
+
break
|
|
380
|
+
return sorted(out)
|
|
381
|
+
|
|
382
|
+
def connection_id_by_name(self) -> dict[str, str]:
|
|
383
|
+
listing = self.connections().list_connections()
|
|
384
|
+
id_map: dict[str, str] = {}
|
|
385
|
+
duplicate_names: set[str] = set()
|
|
386
|
+
for c in listing.connections:
|
|
387
|
+
if c.name in id_map and id_map[c.name] != c.id:
|
|
388
|
+
duplicate_names.add(c.name)
|
|
389
|
+
id_map[c.name] = c.id
|
|
390
|
+
if duplicate_names:
|
|
391
|
+
names = ", ".join(sorted(duplicate_names))
|
|
392
|
+
raise RuntimeError(
|
|
393
|
+
f"Duplicate connection names found: {names}. Use an explicit connection_id."
|
|
394
|
+
)
|
|
395
|
+
return id_map
|
|
396
|
+
|
|
397
|
+
def columns_for_qualified(
|
|
398
|
+
self,
|
|
399
|
+
qualified: str,
|
|
400
|
+
*,
|
|
401
|
+
connection_id: str | None = None,
|
|
402
|
+
) -> list[TableInfo]:
|
|
403
|
+
parts = qualified.split(".")
|
|
404
|
+
if len(parts) < 3:
|
|
405
|
+
raise ValueError(f"Expected connection.schema.table, got {qualified!r}")
|
|
406
|
+
conn_name, schema_name, table_name = (
|
|
407
|
+
parts[0],
|
|
408
|
+
parts[1],
|
|
409
|
+
".".join(parts[2:]),
|
|
410
|
+
)
|
|
411
|
+
conn_id = connection_id
|
|
412
|
+
if conn_id is None:
|
|
413
|
+
id_map = self.connection_id_by_name()
|
|
414
|
+
conn_id = id_map.get(conn_name)
|
|
415
|
+
if not conn_id:
|
|
416
|
+
raise KeyError(f"Unknown connection {conn_name!r}")
|
|
417
|
+
resp = self._information_schema().information_schema(
|
|
418
|
+
connection_id=conn_id,
|
|
419
|
+
var_schema=schema_name,
|
|
420
|
+
table=table_name,
|
|
421
|
+
include_columns=True,
|
|
422
|
+
limit=10,
|
|
423
|
+
)
|
|
424
|
+
if not resp.tables:
|
|
425
|
+
return []
|
|
426
|
+
first = resp.tables[0]
|
|
427
|
+
return first.columns or []
|
|
428
|
+
|
|
429
|
+
def _poll_query_run(
|
|
430
|
+
self,
|
|
431
|
+
query_run_id: str,
|
|
432
|
+
*,
|
|
433
|
+
timeout_s: float = 300.0,
|
|
434
|
+
interval_s: float = 0.5,
|
|
435
|
+
):
|
|
436
|
+
runs = self._query_runs_api()
|
|
437
|
+
deadline = time.monotonic() + timeout_s
|
|
438
|
+
last = None
|
|
439
|
+
while time.monotonic() < deadline:
|
|
440
|
+
last = runs.get_query_run(query_run_id)
|
|
441
|
+
if last.status in _TERMINAL:
|
|
442
|
+
return last
|
|
443
|
+
time.sleep(interval_s)
|
|
444
|
+
raise TimeoutError(
|
|
445
|
+
f"Query run {query_run_id} did not finish within {timeout_s}s "
|
|
446
|
+
f"(last status: {getattr(last, 'status', None)})"
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
def _wait_result_ready(
|
|
450
|
+
self,
|
|
451
|
+
result_id: str,
|
|
452
|
+
*,
|
|
453
|
+
timeout_s: float = 300.0,
|
|
454
|
+
interval_s: float = 0.5,
|
|
455
|
+
):
|
|
456
|
+
results = self._results_api()
|
|
457
|
+
deadline = time.monotonic() + timeout_s
|
|
458
|
+
last = None
|
|
459
|
+
while time.monotonic() < deadline:
|
|
460
|
+
last = results.get_result(result_id)
|
|
461
|
+
if last.status == "ready":
|
|
462
|
+
return last
|
|
463
|
+
if last.status in _RESULT_FAILURE:
|
|
464
|
+
raise RuntimeError(last.error_message or f"Result {last.status}")
|
|
465
|
+
time.sleep(interval_s)
|
|
466
|
+
raise TimeoutError(
|
|
467
|
+
f"Result {result_id} not ready within {timeout_s}s "
|
|
468
|
+
f"(last status: {getattr(last, 'status', None)})"
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
def execute_sql(self, sql: str, *, database: str | None = None) -> QueryResult:
|
|
472
|
+
"""Execute SQL and return a :class:`QueryResult`.
|
|
473
|
+
|
|
474
|
+
Pass ``database`` to scope the query to a managed database. The name
|
|
475
|
+
is resolved to a database ID once before the retry loop, and the
|
|
476
|
+
``X-Database-Id`` header is sent with every attempt. Inside a managed
|
|
477
|
+
database the built-in catalog is always ``"default"``, so table
|
|
478
|
+
references should use ``"default"."<schema>"."<table>"``.
|
|
479
|
+
"""
|
|
480
|
+
database_id = self.resolve_managed_database(database).id if database else None
|
|
481
|
+
last_err: BaseException | None = None
|
|
482
|
+
for attempt in range(3):
|
|
483
|
+
try:
|
|
484
|
+
return self._execute_sql_once(sql, database_id=database_id)
|
|
485
|
+
except (ProtocolError, ConnectionResetError, Urllib3HTTPError) as e:
|
|
486
|
+
last_err = e
|
|
487
|
+
if attempt == 2:
|
|
488
|
+
raise
|
|
489
|
+
time.sleep(0.2 * (2**attempt))
|
|
490
|
+
raise last_err # pragma: no cover
|
|
491
|
+
|
|
492
|
+
def _execute_sql_once(self, sql: str, *, database_id: str | None = None) -> QueryResult:
|
|
493
|
+
q = self._query_api()
|
|
494
|
+
try:
|
|
495
|
+
if database_id:
|
|
496
|
+
raw = q.query(QueryRequest(sql=sql), x_database_id=database_id)
|
|
497
|
+
else:
|
|
498
|
+
raw = q.query(QueryRequest(sql=sql))
|
|
499
|
+
except ApiException as e:
|
|
500
|
+
raise RuntimeError(e.reason or str(e)) from e
|
|
501
|
+
|
|
502
|
+
if isinstance(raw, AsyncQueryResponse):
|
|
503
|
+
run = self._poll_query_run(raw.query_run_id)
|
|
504
|
+
if run.status != "succeeded":
|
|
505
|
+
raise RuntimeError(run.error_message or f"Query failed ({run.status})")
|
|
506
|
+
if run.result_id:
|
|
507
|
+
persisted = self._wait_result_ready(run.result_id)
|
|
508
|
+
return QueryResult.from_get_result(persisted)
|
|
509
|
+
raise RuntimeError("Query succeeded but no result_id was returned.")
|
|
510
|
+
|
|
511
|
+
if isinstance(raw, QueryResponse):
|
|
512
|
+
return QueryResult.from_query_response(raw)
|
|
513
|
+
|
|
514
|
+
raise RuntimeError(f"Unexpected query response type: {type(raw)!r}")
|
|
515
|
+
|
|
516
|
+
def get_result(self, result_id: str) -> QueryResult:
|
|
517
|
+
r = self._results_api().get_result(result_id)
|
|
518
|
+
if r.status != "ready":
|
|
519
|
+
r = self._wait_result_ready(result_id)
|
|
520
|
+
return QueryResult.from_get_result(r)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def from_env() -> HotdataClient:
|
|
524
|
+
return HotdataClient.from_env()
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Managed database helpers (Hotdata-owned catalogs with parquet table loads)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from hotdata.exceptions import ApiException
|
|
10
|
+
|
|
11
|
+
DEFAULT_SCHEMA = "public"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ManagedDatabase:
|
|
16
|
+
id: str
|
|
17
|
+
description: str | None
|
|
18
|
+
default_connection_id: str
|
|
19
|
+
|
|
20
|
+
def to_dict(self) -> dict[str, Any]:
|
|
21
|
+
return asdict(self)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class ManagedTable:
|
|
26
|
+
full_name: str
|
|
27
|
+
schema: str
|
|
28
|
+
table: str
|
|
29
|
+
synced: bool
|
|
30
|
+
last_sync: str | None
|
|
31
|
+
|
|
32
|
+
def to_dict(self) -> dict[str, Any]:
|
|
33
|
+
return asdict(self)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class LoadManagedTableResult:
|
|
38
|
+
connection_id: str
|
|
39
|
+
schema_name: str
|
|
40
|
+
table_name: str
|
|
41
|
+
row_count: int
|
|
42
|
+
full_name: str
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict[str, Any]:
|
|
45
|
+
return asdict(self)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_parquet_path(path: str) -> bool:
|
|
49
|
+
return Path(path).suffix.lower() == ".parquet"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def managed_database_from_detail(detail: Any) -> ManagedDatabase:
|
|
53
|
+
return ManagedDatabase(
|
|
54
|
+
id=str(detail.id),
|
|
55
|
+
description=detail.name,
|
|
56
|
+
default_connection_id=str(detail.default_connection_id),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def api_error_message(exc: ApiException) -> str:
|
|
61
|
+
return exc.reason or str(exc)
|
hotdata_framework/env.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
from hotdata import ApiClient, Configuration
|
|
8
|
+
from hotdata.api.workspaces_api import WorkspacesApi
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def normalize_host(url: str) -> str:
|
|
12
|
+
u = url.rstrip("/")
|
|
13
|
+
if u.endswith("/v1"):
|
|
14
|
+
u = u[:-3]
|
|
15
|
+
parsed = urlparse(u)
|
|
16
|
+
if not parsed.scheme or not parsed.netloc:
|
|
17
|
+
return u
|
|
18
|
+
return f"{parsed.scheme}://{parsed.netloc}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def default_api_key() -> str:
|
|
22
|
+
return os.environ.get("HOTDATA_API_KEY", "")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def explicit_workspace_id() -> str | None:
|
|
26
|
+
return os.environ.get("HOTDATA_WORKSPACE")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def default_host() -> str:
|
|
30
|
+
raw = os.environ.get("HOTDATA_API_URL", "https://api.hotdata.dev")
|
|
31
|
+
return normalize_host(raw)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def default_session_id() -> str | None:
|
|
35
|
+
return os.environ.get("HOTDATA_SANDBOX")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def list_workspaces(api_key: str, host: str, session_id: str | None):
|
|
39
|
+
cfg = Configuration(
|
|
40
|
+
host=host,
|
|
41
|
+
api_key=api_key,
|
|
42
|
+
workspace_id=None,
|
|
43
|
+
session_id=session_id,
|
|
44
|
+
)
|
|
45
|
+
with ApiClient(cfg) as api:
|
|
46
|
+
listing = WorkspacesApi(api).list_workspaces()
|
|
47
|
+
return listing.workspaces
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class WorkspaceSelection:
|
|
52
|
+
workspace_id: str
|
|
53
|
+
source: str
|
|
54
|
+
workspaces: list
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resolve_workspace_selection(
|
|
58
|
+
api_key: str, host: str, session_id: str | None
|
|
59
|
+
) -> WorkspaceSelection:
|
|
60
|
+
explicit = explicit_workspace_id()
|
|
61
|
+
if explicit:
|
|
62
|
+
return WorkspaceSelection(
|
|
63
|
+
workspace_id=explicit,
|
|
64
|
+
source="explicit_env",
|
|
65
|
+
workspaces=[],
|
|
66
|
+
)
|
|
67
|
+
workspaces = list_workspaces(api_key, host, session_id)
|
|
68
|
+
if not workspaces:
|
|
69
|
+
raise RuntimeError("No Hotdata workspaces found for this API key.")
|
|
70
|
+
active = [w for w in workspaces if w.active]
|
|
71
|
+
chosen = active[0] if active else workspaces[0]
|
|
72
|
+
return WorkspaceSelection(
|
|
73
|
+
workspace_id=chosen.public_id,
|
|
74
|
+
source="active" if active else "first",
|
|
75
|
+
workspaces=workspaces,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def pick_workspace(api_key: str, host: str, session_id: str | None) -> str:
|
|
80
|
+
selection = resolve_workspace_selection(api_key, host, session_id)
|
|
81
|
+
return selection.workspace_id
|
|
@@ -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))
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from hotdata.exceptions import ApiException
|
|
4
|
+
|
|
5
|
+
from hotdata_framework.client import HotdataClient
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def workspace_health_lines(client: HotdataClient) -> tuple[bool, list[str]]:
|
|
9
|
+
"""Return ``(ok, parts)`` where ``parts`` are short markdown fragments.
|
|
10
|
+
|
|
11
|
+
On failure, ``ok`` is False and ``parts`` is a single-element list with the error text.
|
|
12
|
+
"""
|
|
13
|
+
try:
|
|
14
|
+
listing = client.connections().list_connections()
|
|
15
|
+
n = len(listing.connections)
|
|
16
|
+
lines = [
|
|
17
|
+
"**API** reachable",
|
|
18
|
+
f"**workspace** `{client.workspace_id}`",
|
|
19
|
+
f"**connections** {n}",
|
|
20
|
+
]
|
|
21
|
+
if client.session_id:
|
|
22
|
+
lines.append(f"**sandbox** `{client.session_id}`")
|
|
23
|
+
return True, lines
|
|
24
|
+
except ApiException as e:
|
|
25
|
+
return False, [e.reason or str(e)]
|
|
26
|
+
except Exception as e:
|
|
27
|
+
return False, [str(e)]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""HTTP client defaults for Hotdata SDK :class:`~hotdata.Configuration`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from urllib3.util.retry import Retry
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def default_http_retries() -> Retry:
|
|
9
|
+
"""Retry transient connection failures (e.g. stale pooled sockets)."""
|
|
10
|
+
return Retry(
|
|
11
|
+
total=3,
|
|
12
|
+
connect=3,
|
|
13
|
+
read=3,
|
|
14
|
+
backoff_factor=0.2,
|
|
15
|
+
status_forcelist=(502, 503, 504),
|
|
16
|
+
allowed_methods=frozenset(["GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"]),
|
|
17
|
+
)
|
|
@@ -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
|
+
|
|
23
|
+
from hotdata_framework.client import HotdataClient as RuntimeClient
|
|
24
|
+
from hotdata_framework.databases import LoadManagedTableResult, ManagedDatabase
|
|
25
|
+
from hotdata_framework.errors import (
|
|
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-framework.
|
|
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")
|
|
File without changes
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from hotdata.models.get_result_response import GetResultResponse
|
|
7
|
+
from hotdata.models.query_response import QueryResponse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class QueryResult:
|
|
12
|
+
"""Tabular result from a Hotdata query or stored result id."""
|
|
13
|
+
|
|
14
|
+
columns: list[str]
|
|
15
|
+
rows: list[list[Any]]
|
|
16
|
+
row_count: int
|
|
17
|
+
result_id: str | None
|
|
18
|
+
query_run_id: str | None
|
|
19
|
+
execution_time_ms: int | None
|
|
20
|
+
warning: str | None = None
|
|
21
|
+
error_message: str | None = None
|
|
22
|
+
|
|
23
|
+
def to_records(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
max_rows: int | None = None,
|
|
27
|
+
) -> list[dict[str, Any]]:
|
|
28
|
+
rows = self.rows if max_rows is None else self.rows[:max_rows]
|
|
29
|
+
return [dict(zip(self.columns, row, strict=True)) for row in rows]
|
|
30
|
+
|
|
31
|
+
def metadata_dict(self) -> dict[str, Any]:
|
|
32
|
+
return {
|
|
33
|
+
"row_count": self.row_count,
|
|
34
|
+
"column_count": len(self.columns),
|
|
35
|
+
"result_id": self.result_id,
|
|
36
|
+
"query_run_id": self.query_run_id,
|
|
37
|
+
"execution_time_ms": self.execution_time_ms,
|
|
38
|
+
"warning": self.warning,
|
|
39
|
+
"error_message": self.error_message,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
def to_pandas(self): # type: ignore[no-untyped-def]
|
|
43
|
+
import pandas as pd
|
|
44
|
+
|
|
45
|
+
if not self.columns:
|
|
46
|
+
return pd.DataFrame()
|
|
47
|
+
return pd.DataFrame(self.rows, columns=self.columns)
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_query_response(cls, r: QueryResponse) -> QueryResult:
|
|
51
|
+
return cls(
|
|
52
|
+
columns=list(r.columns),
|
|
53
|
+
rows=[list(row) for row in r.rows],
|
|
54
|
+
row_count=r.row_count,
|
|
55
|
+
result_id=r.result_id,
|
|
56
|
+
query_run_id=r.query_run_id,
|
|
57
|
+
execution_time_ms=r.execution_time_ms,
|
|
58
|
+
warning=r.warning,
|
|
59
|
+
error_message=None,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def from_get_result(cls, r: GetResultResponse) -> QueryResult:
|
|
64
|
+
cols = list(r.columns or [])
|
|
65
|
+
row_data = [list(row) for row in (r.rows or [])]
|
|
66
|
+
return cls(
|
|
67
|
+
columns=cols,
|
|
68
|
+
rows=row_data,
|
|
69
|
+
row_count=r.row_count or 0,
|
|
70
|
+
result_id=r.result_id,
|
|
71
|
+
query_run_id=None,
|
|
72
|
+
execution_time_ms=None,
|
|
73
|
+
warning=None,
|
|
74
|
+
error_message=r.error_message,
|
|
75
|
+
)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hotdata-framework
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Python framework for building Hotdata integrations: workspace/session runtime, query execution, and managed databases
|
|
5
|
+
Project-URL: Homepage, https://www.hotdata.dev
|
|
6
|
+
Project-URL: Documentation, https://www.hotdata.dev/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/hotdata-dev/sdk-python-framework
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: analytics,arrow,data,framework,hotdata,pandas,python,sql
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: hotdata>=0.4.1
|
|
25
|
+
Requires-Dist: pandas>=2.0
|
|
26
|
+
Requires-Dist: pyarrow>=14.0
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# hotdata-framework
|
|
30
|
+
|
|
31
|
+
**A Python framework for building Hotdata integrations.**
|
|
32
|
+
|
|
33
|
+
Shared runtime primitives for Hotdata integrations: workspace/session semantics, execution context, query state, run history, and replayable result handles. Framework packages (Marimo, Jupyter, Streamlit, LangGraph) depend on this package.
|
|
34
|
+
|
|
35
|
+
Runtime boundary and guarantees are defined in `CONTRACT.md`.
|
|
36
|
+
|
|
37
|
+
## Features
|
|
38
|
+
|
|
39
|
+
- **Environment-driven client setup** — create clients from `HOTDATA_API_KEY`, optional `HOTDATA_API_URL`, `HOTDATA_WORKSPACE`, and `HOTDATA_SANDBOX`.
|
|
40
|
+
- **Workspace resolution** — choose an explicit workspace from env, otherwise discover workspaces and select the active workspace or first available workspace.
|
|
41
|
+
- **Sandbox/session propagation** — pass sandbox session context through the SDK via `X-Session-Id`.
|
|
42
|
+
- **HTTP resilience** — configure SDK retries for transient connection failures and retry SQL execution on stale pooled sockets.
|
|
43
|
+
- **SQL execution helper** — run SQL through `POST /v1/query`, poll async query runs when needed, and return a `QueryResult`.
|
|
44
|
+
- **Result utilities** — convert query results to records, pandas DataFrames, or metadata dictionaries for adapter display layers.
|
|
45
|
+
- **History helpers** — list recent results and query run history with normalized dataclasses.
|
|
46
|
+
- **Managed databases** — create Hotdata-owned catalogs, declare tables, upload parquet, and load managed tables (mirrors `hotdata databases` in the CLI).
|
|
47
|
+
- **Health helpers** — build compact API/workspace health summaries for UI integrations.
|
|
48
|
+
|
|
49
|
+
Install:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
uv pip install hotdata-framework
|
|
53
|
+
# or: pip install hotdata-framework
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Example:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
python examples/basic_usage.py
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Development (uses **uv**; creates `.venv/` in this repo):
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
uv sync --locked
|
|
66
|
+
uv run pytest
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`uv.lock` is checked in so CI can run `uv sync --locked`. The default **dev** group (pytest) is enabled via `[tool.uv] default-groups`.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
hotdata_framework/__init__.py,sha256=XTAkQjuxshKLAKMTs-KoDeQBaUMYW4MXEskXRy-WRnM,1719
|
|
2
|
+
hotdata_framework/client.py,sha256=eXer2fHp4AWqC_NWlkUOSRCbnC8Nw3PR0r7SIv1zmJg,17653
|
|
3
|
+
hotdata_framework/databases.py,sha256=wajadlyxl5pB1mgLos-cxr3Yx4pnZiTDyY49acxWpkQ,1313
|
|
4
|
+
hotdata_framework/env.py,sha256=1gm56sQhJ2rdEtfvAzfXc0P44IodLLmSP15Uax_WnoM,2190
|
|
5
|
+
hotdata_framework/errors.py,sha256=8baHY-bcR505yh_CIrBz5cZ0GU5SWei6t4cDImLRwzs,897
|
|
6
|
+
hotdata_framework/health.py,sha256=fFmqN-aPm0gHXj_j9KVB_T6T1fe2clI6aC45R9HxhgM,894
|
|
7
|
+
hotdata_framework/http.py,sha256=k2Ie-9HUennILAXAtQ66N7vGNpA8m1VPJ3F_FlTpN2o,507
|
|
8
|
+
hotdata_framework/managed_client.py,sha256=FRV93QHDlZ6GhtELzFhHq5DGbor7clx9Pa87h5f46vA,6825
|
|
9
|
+
hotdata_framework/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
hotdata_framework/result.py,sha256=q39xn--aRRuDMxXr4Y6pZcr9u6E8s_5PYMBREA3gloA,2303
|
|
11
|
+
hotdata_framework-0.4.0.dist-info/METADATA,sha256=qB4uNmdGzG5iGsoNBWqsFfnM2bbEY1wD8S5xHk6Tmao,3213
|
|
12
|
+
hotdata_framework-0.4.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
13
|
+
hotdata_framework-0.4.0.dist-info/RECORD,,
|