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.
@@ -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)
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any