hotdata-runtime 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hotdata_runtime/__init__.py +47 -0
- hotdata_runtime/client.py +341 -0
- hotdata_runtime/env.py +81 -0
- hotdata_runtime/health.py +27 -0
- hotdata_runtime/http.py +19 -0
- hotdata_runtime/result.py +75 -0
- hotdata_runtime-0.1.0.dist-info/METADATA +48 -0
- hotdata_runtime-0.1.0.dist-info/RECORD +9 -0
- hotdata_runtime-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Hotdata runtime primitives for notebook and app integrations."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from hotdata_runtime.client import (
|
|
6
|
+
HotdataClient,
|
|
7
|
+
ResultSummary,
|
|
8
|
+
RunHistoryItem,
|
|
9
|
+
from_env,
|
|
10
|
+
)
|
|
11
|
+
from hotdata_runtime.env import (
|
|
12
|
+
default_api_key,
|
|
13
|
+
default_host,
|
|
14
|
+
default_session_id,
|
|
15
|
+
explicit_workspace_id,
|
|
16
|
+
list_workspaces,
|
|
17
|
+
normalize_host,
|
|
18
|
+
pick_workspace,
|
|
19
|
+
resolve_workspace_selection,
|
|
20
|
+
WorkspaceSelection,
|
|
21
|
+
)
|
|
22
|
+
from hotdata_runtime.health import workspace_health_lines
|
|
23
|
+
from hotdata_runtime.result import QueryResult
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
__version__ = version("hotdata-runtime")
|
|
27
|
+
except PackageNotFoundError:
|
|
28
|
+
__version__ = "0.0.0+unknown"
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"__version__",
|
|
32
|
+
"HotdataClient",
|
|
33
|
+
"QueryResult",
|
|
34
|
+
"workspace_health_lines",
|
|
35
|
+
"default_api_key",
|
|
36
|
+
"default_host",
|
|
37
|
+
"default_session_id",
|
|
38
|
+
"explicit_workspace_id",
|
|
39
|
+
"from_env",
|
|
40
|
+
"list_workspaces",
|
|
41
|
+
"normalize_host",
|
|
42
|
+
"pick_workspace",
|
|
43
|
+
"resolve_workspace_selection",
|
|
44
|
+
"ResultSummary",
|
|
45
|
+
"RunHistoryItem",
|
|
46
|
+
"WorkspaceSelection",
|
|
47
|
+
]
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Iterator
|
|
6
|
+
|
|
7
|
+
from urllib3.exceptions import HTTPError as Urllib3HTTPError
|
|
8
|
+
from urllib3.exceptions import ProtocolError
|
|
9
|
+
|
|
10
|
+
from hotdata import ApiClient, Configuration
|
|
11
|
+
from hotdata.api.connections_api import ConnectionsApi
|
|
12
|
+
from hotdata.api.information_schema_api import InformationSchemaApi
|
|
13
|
+
from hotdata.api.query_api import QueryApi
|
|
14
|
+
from hotdata.api.query_runs_api import QueryRunsApi
|
|
15
|
+
from hotdata.api.results_api import ResultsApi
|
|
16
|
+
from hotdata.exceptions import ApiException
|
|
17
|
+
from hotdata.models.async_query_response import AsyncQueryResponse
|
|
18
|
+
from hotdata.models.query_request import QueryRequest
|
|
19
|
+
from hotdata.models.query_response import QueryResponse
|
|
20
|
+
from hotdata.models.table_info import TableInfo
|
|
21
|
+
|
|
22
|
+
from hotdata_runtime.env import (
|
|
23
|
+
default_api_key,
|
|
24
|
+
default_host,
|
|
25
|
+
default_session_id,
|
|
26
|
+
normalize_host,
|
|
27
|
+
pick_workspace,
|
|
28
|
+
)
|
|
29
|
+
from hotdata_runtime.http import default_http_retries
|
|
30
|
+
from hotdata_runtime.result import QueryResult
|
|
31
|
+
|
|
32
|
+
_TERMINAL = frozenset({"succeeded", "failed", "cancelled"})
|
|
33
|
+
_RESULT_FAILURE = frozenset({"failed", "cancelled"})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ResultSummary:
|
|
38
|
+
result_id: str
|
|
39
|
+
status: str
|
|
40
|
+
created_at: str | None
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> dict[str, Any]:
|
|
43
|
+
return asdict(self)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class RunHistoryItem:
|
|
48
|
+
query_run_id: str
|
|
49
|
+
status: str
|
|
50
|
+
created_at: str | None
|
|
51
|
+
execution_time_ms: int | None
|
|
52
|
+
result_id: str | None
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict[str, Any]:
|
|
55
|
+
return asdict(self)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class HotdataClient:
|
|
59
|
+
"""Thin wrapper around the Hotdata Python SDK with query polling helpers."""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
api_key: str,
|
|
64
|
+
workspace_id: str,
|
|
65
|
+
*,
|
|
66
|
+
host: str | None = None,
|
|
67
|
+
session_id: str | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
self._host = normalize_host(host) if host else default_host()
|
|
70
|
+
self._api_key = api_key
|
|
71
|
+
self._workspace_id = workspace_id
|
|
72
|
+
self._session_id = session_id
|
|
73
|
+
self._config = Configuration(
|
|
74
|
+
host=self._host,
|
|
75
|
+
api_key=api_key,
|
|
76
|
+
workspace_id=workspace_id,
|
|
77
|
+
session_id=session_id,
|
|
78
|
+
retries=default_http_retries(),
|
|
79
|
+
)
|
|
80
|
+
self._api = ApiClient(self._config)
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def from_env(cls) -> HotdataClient:
|
|
84
|
+
api_key = default_api_key()
|
|
85
|
+
if not api_key:
|
|
86
|
+
raise RuntimeError("HOTDATA_API_KEY must be set.")
|
|
87
|
+
host = default_host()
|
|
88
|
+
session = default_session_id()
|
|
89
|
+
workspace_id = pick_workspace(api_key, host, session)
|
|
90
|
+
return cls(api_key, workspace_id, host=host, session_id=session)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def workspace_id(self) -> str:
|
|
94
|
+
return self._workspace_id
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def host(self) -> str:
|
|
98
|
+
return self._host
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def session_id(self) -> str | None:
|
|
102
|
+
return self._session_id
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def api(self) -> ApiClient:
|
|
106
|
+
return self._api
|
|
107
|
+
|
|
108
|
+
def close(self) -> None:
|
|
109
|
+
self._api.close()
|
|
110
|
+
|
|
111
|
+
def __enter__(self) -> HotdataClient:
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
def __exit__(self, *args: object) -> None:
|
|
115
|
+
self.close()
|
|
116
|
+
|
|
117
|
+
def connections(self) -> ConnectionsApi:
|
|
118
|
+
return ConnectionsApi(self._api)
|
|
119
|
+
|
|
120
|
+
def _information_schema(self) -> InformationSchemaApi:
|
|
121
|
+
return InformationSchemaApi(self._api)
|
|
122
|
+
|
|
123
|
+
def _query_api(self) -> QueryApi:
|
|
124
|
+
return QueryApi(self._api)
|
|
125
|
+
|
|
126
|
+
def _query_runs_api(self) -> QueryRunsApi:
|
|
127
|
+
return QueryRunsApi(self._api)
|
|
128
|
+
|
|
129
|
+
def _results_api(self) -> ResultsApi:
|
|
130
|
+
return ResultsApi(self._api)
|
|
131
|
+
|
|
132
|
+
def query_runs(self) -> QueryRunsApi:
|
|
133
|
+
return self._query_runs_api()
|
|
134
|
+
|
|
135
|
+
def results(self) -> ResultsApi:
|
|
136
|
+
return self._results_api()
|
|
137
|
+
|
|
138
|
+
def list_recent_results(
|
|
139
|
+
self,
|
|
140
|
+
*,
|
|
141
|
+
limit: int = 50,
|
|
142
|
+
offset: int = 0,
|
|
143
|
+
) -> list[ResultSummary]:
|
|
144
|
+
listing = self.results().list_results(limit=limit, offset=offset)
|
|
145
|
+
return [
|
|
146
|
+
ResultSummary(
|
|
147
|
+
result_id=r.id,
|
|
148
|
+
status=r.status,
|
|
149
|
+
created_at=r.created_at,
|
|
150
|
+
)
|
|
151
|
+
for r in listing.results
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
def list_run_history(
|
|
155
|
+
self,
|
|
156
|
+
*,
|
|
157
|
+
limit: int = 20,
|
|
158
|
+
) -> list[RunHistoryItem]:
|
|
159
|
+
listing = self.query_runs().list_query_runs(limit=limit)
|
|
160
|
+
return [
|
|
161
|
+
RunHistoryItem(
|
|
162
|
+
query_run_id=r.id,
|
|
163
|
+
status=r.status,
|
|
164
|
+
created_at=r.created_at,
|
|
165
|
+
execution_time_ms=r.execution_time_ms,
|
|
166
|
+
result_id=r.result_id,
|
|
167
|
+
)
|
|
168
|
+
for r in listing.query_runs
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
def iter_tables(
|
|
172
|
+
self,
|
|
173
|
+
*,
|
|
174
|
+
connection_id: str | None = None,
|
|
175
|
+
include_columns: bool = False,
|
|
176
|
+
page_size: int = 200,
|
|
177
|
+
) -> Iterator[TableInfo]:
|
|
178
|
+
cursor: str | None = None
|
|
179
|
+
while True:
|
|
180
|
+
resp = self._information_schema().information_schema(
|
|
181
|
+
connection_id=connection_id,
|
|
182
|
+
include_columns=include_columns,
|
|
183
|
+
limit=page_size,
|
|
184
|
+
cursor=cursor,
|
|
185
|
+
)
|
|
186
|
+
yield from resp.tables
|
|
187
|
+
if not resp.has_more or not resp.next_cursor:
|
|
188
|
+
break
|
|
189
|
+
cursor = resp.next_cursor
|
|
190
|
+
|
|
191
|
+
def qualified_table_name(self, t: TableInfo) -> str:
|
|
192
|
+
return f"{t.connection}.{t.var_schema}.{t.table}"
|
|
193
|
+
|
|
194
|
+
def list_qualified_table_names(
|
|
195
|
+
self, *, limit: int = 5000, connection_id: str | None = None
|
|
196
|
+
) -> list[str]:
|
|
197
|
+
out: list[str] = []
|
|
198
|
+
for t in self.iter_tables(connection_id=connection_id):
|
|
199
|
+
out.append(self.qualified_table_name(t))
|
|
200
|
+
if len(out) >= limit:
|
|
201
|
+
break
|
|
202
|
+
return sorted(out)
|
|
203
|
+
|
|
204
|
+
def connection_id_by_name(self) -> dict[str, str]:
|
|
205
|
+
listing = self.connections().list_connections()
|
|
206
|
+
id_map: dict[str, str] = {}
|
|
207
|
+
duplicate_names: set[str] = set()
|
|
208
|
+
for c in listing.connections:
|
|
209
|
+
if c.name in id_map and id_map[c.name] != c.id:
|
|
210
|
+
duplicate_names.add(c.name)
|
|
211
|
+
id_map[c.name] = c.id
|
|
212
|
+
if duplicate_names:
|
|
213
|
+
names = ", ".join(sorted(duplicate_names))
|
|
214
|
+
raise RuntimeError(
|
|
215
|
+
f"Duplicate connection names found: {names}. "
|
|
216
|
+
"Use an explicit connection_id."
|
|
217
|
+
)
|
|
218
|
+
return id_map
|
|
219
|
+
|
|
220
|
+
def columns_for_qualified(
|
|
221
|
+
self,
|
|
222
|
+
qualified: str,
|
|
223
|
+
*,
|
|
224
|
+
connection_id: str | None = None,
|
|
225
|
+
) -> list[TableInfo]:
|
|
226
|
+
parts = qualified.split(".")
|
|
227
|
+
if len(parts) < 3:
|
|
228
|
+
raise ValueError(
|
|
229
|
+
f"Expected connection.schema.table, got {qualified!r}"
|
|
230
|
+
)
|
|
231
|
+
conn_name, schema_name, table_name = (
|
|
232
|
+
parts[0],
|
|
233
|
+
parts[1],
|
|
234
|
+
".".join(parts[2:]),
|
|
235
|
+
)
|
|
236
|
+
conn_id = connection_id
|
|
237
|
+
if conn_id is None:
|
|
238
|
+
id_map = self.connection_id_by_name()
|
|
239
|
+
conn_id = id_map.get(conn_name)
|
|
240
|
+
if not conn_id:
|
|
241
|
+
raise KeyError(f"Unknown connection {conn_name!r}")
|
|
242
|
+
resp = self._information_schema().information_schema(
|
|
243
|
+
connection_id=conn_id,
|
|
244
|
+
var_schema=schema_name,
|
|
245
|
+
table=table_name,
|
|
246
|
+
include_columns=True,
|
|
247
|
+
limit=10,
|
|
248
|
+
)
|
|
249
|
+
if not resp.tables:
|
|
250
|
+
return []
|
|
251
|
+
first = resp.tables[0]
|
|
252
|
+
return first.columns or []
|
|
253
|
+
|
|
254
|
+
def _poll_query_run(
|
|
255
|
+
self,
|
|
256
|
+
query_run_id: str,
|
|
257
|
+
*,
|
|
258
|
+
timeout_s: float = 300.0,
|
|
259
|
+
interval_s: float = 0.5,
|
|
260
|
+
):
|
|
261
|
+
runs = self._query_runs_api()
|
|
262
|
+
deadline = time.monotonic() + timeout_s
|
|
263
|
+
last = None
|
|
264
|
+
while time.monotonic() < deadline:
|
|
265
|
+
last = runs.get_query_run(query_run_id)
|
|
266
|
+
if last.status in _TERMINAL:
|
|
267
|
+
return last
|
|
268
|
+
time.sleep(interval_s)
|
|
269
|
+
raise TimeoutError(
|
|
270
|
+
f"Query run {query_run_id} did not finish within {timeout_s}s "
|
|
271
|
+
f"(last status: {getattr(last, 'status', None)})"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def _wait_result_ready(
|
|
275
|
+
self,
|
|
276
|
+
result_id: str,
|
|
277
|
+
*,
|
|
278
|
+
timeout_s: float = 300.0,
|
|
279
|
+
interval_s: float = 0.5,
|
|
280
|
+
):
|
|
281
|
+
results = self._results_api()
|
|
282
|
+
deadline = time.monotonic() + timeout_s
|
|
283
|
+
last = None
|
|
284
|
+
while time.monotonic() < deadline:
|
|
285
|
+
last = results.get_result(result_id)
|
|
286
|
+
if last.status == "ready":
|
|
287
|
+
return last
|
|
288
|
+
if last.status in _RESULT_FAILURE:
|
|
289
|
+
raise RuntimeError(
|
|
290
|
+
last.error_message or f"Result {last.status}"
|
|
291
|
+
)
|
|
292
|
+
time.sleep(interval_s)
|
|
293
|
+
raise TimeoutError(
|
|
294
|
+
f"Result {result_id} not ready within {timeout_s}s "
|
|
295
|
+
f"(last status: {getattr(last, 'status', None)})"
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def execute_sql(self, sql: str) -> QueryResult:
|
|
299
|
+
last_err: BaseException | None = None
|
|
300
|
+
for attempt in range(3):
|
|
301
|
+
try:
|
|
302
|
+
return self._execute_sql_once(sql)
|
|
303
|
+
except (ProtocolError, ConnectionResetError, Urllib3HTTPError) as e:
|
|
304
|
+
last_err = e
|
|
305
|
+
if attempt == 2:
|
|
306
|
+
raise
|
|
307
|
+
time.sleep(0.2 * (2**attempt))
|
|
308
|
+
raise last_err # pragma: no cover
|
|
309
|
+
|
|
310
|
+
def _execute_sql_once(self, sql: str) -> QueryResult:
|
|
311
|
+
q = self._query_api()
|
|
312
|
+
try:
|
|
313
|
+
raw = q.query(QueryRequest(sql=sql))
|
|
314
|
+
except ApiException as e:
|
|
315
|
+
raise RuntimeError(e.reason or str(e)) from e
|
|
316
|
+
|
|
317
|
+
if isinstance(raw, AsyncQueryResponse):
|
|
318
|
+
run = self._poll_query_run(raw.query_run_id)
|
|
319
|
+
if run.status != "succeeded":
|
|
320
|
+
raise RuntimeError(
|
|
321
|
+
run.error_message or f"Query failed ({run.status})"
|
|
322
|
+
)
|
|
323
|
+
if run.result_id:
|
|
324
|
+
persisted = self._wait_result_ready(run.result_id)
|
|
325
|
+
return QueryResult.from_get_result(persisted)
|
|
326
|
+
raise RuntimeError("Query succeeded but no result_id was returned.")
|
|
327
|
+
|
|
328
|
+
if isinstance(raw, QueryResponse):
|
|
329
|
+
return QueryResult.from_query_response(raw)
|
|
330
|
+
|
|
331
|
+
raise RuntimeError(f"Unexpected query response type: {type(raw)!r}")
|
|
332
|
+
|
|
333
|
+
def get_result(self, result_id: str) -> QueryResult:
|
|
334
|
+
r = self._results_api().get_result(result_id)
|
|
335
|
+
if r.status != "ready":
|
|
336
|
+
r = self._wait_result_ready(result_id)
|
|
337
|
+
return QueryResult.from_get_result(r)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def from_env() -> HotdataClient:
|
|
341
|
+
return HotdataClient.from_env()
|
hotdata_runtime/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,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from hotdata.exceptions import ApiException
|
|
4
|
+
|
|
5
|
+
from hotdata_runtime.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)]
|
hotdata_runtime/http.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
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(
|
|
17
|
+
["GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"]
|
|
18
|
+
),
|
|
19
|
+
)
|
|
@@ -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)) 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,48 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hotdata-runtime
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Workspace/session runtime primitives for Hotdata integrations
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: hotdata>=0.1.0
|
|
8
|
+
Requires-Dist: pandas>=2.0
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# hotdata-runtime
|
|
12
|
+
|
|
13
|
+
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.
|
|
14
|
+
|
|
15
|
+
Runtime boundary and guarantees are defined in `CONTRACT.md`.
|
|
16
|
+
|
|
17
|
+
## Features
|
|
18
|
+
|
|
19
|
+
- **Environment-driven client setup** — create clients from `HOTDATA_API_KEY`, optional `HOTDATA_API_URL`, `HOTDATA_WORKSPACE`, and `HOTDATA_SANDBOX`.
|
|
20
|
+
- **Workspace resolution** — choose an explicit workspace from env, otherwise discover workspaces and select the active workspace or first available workspace.
|
|
21
|
+
- **Sandbox/session propagation** — pass sandbox session context through the SDK via `X-Session-Id`.
|
|
22
|
+
- **HTTP resilience** — configure SDK retries for transient connection failures and retry SQL execution on stale pooled sockets.
|
|
23
|
+
- **SQL execution helper** — run SQL through `POST /v1/query`, poll async query runs when needed, and return a `QueryResult`.
|
|
24
|
+
- **Result utilities** — convert query results to records, pandas DataFrames, or metadata dictionaries for adapter display layers.
|
|
25
|
+
- **History helpers** — list recent results and query run history with normalized dataclasses.
|
|
26
|
+
- **Health helpers** — build compact API/workspace health summaries for UI integrations.
|
|
27
|
+
|
|
28
|
+
Install:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
uv pip install hotdata-runtime
|
|
32
|
+
# or: pip install hotdata-runtime
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
python examples/basic_usage.py
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Development (uses **uv**; creates `.venv/` in this repo):
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
uv sync --locked
|
|
45
|
+
uv run pytest
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`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,9 @@
|
|
|
1
|
+
hotdata_runtime/__init__.py,sha256=fq8XUN9mbHEC78y2a361m0-8EqTdxF9nhpm0d_ylZEA,1094
|
|
2
|
+
hotdata_runtime/client.py,sha256=j3TXMJScBEkuIjWTqe-q_v7w5o5l0Lyvw71-sfHclhs,10611
|
|
3
|
+
hotdata_runtime/env.py,sha256=1gm56sQhJ2rdEtfvAzfXc0P44IodLLmSP15Uax_WnoM,2190
|
|
4
|
+
hotdata_runtime/health.py,sha256=37Gg_R8dHxSssiiLvJCWnW81ND6-IDb3xGsbsXbYdAc,892
|
|
5
|
+
hotdata_runtime/http.py,sha256=9UC4Rbw8-IEQ2sQ_MUTnOSM4KLPj3QKoFEXWUmtjMnE,529
|
|
6
|
+
hotdata_runtime/result.py,sha256=qs2EkdoxYG1H6sLn5gqDFLstBznwBEPkI0FwCeu4f-E,2290
|
|
7
|
+
hotdata_runtime-0.1.0.dist-info/METADATA,sha256=LocMOVRUWbyYwLxyU75RxnnUowU4MlWiAGYv8hj3xLc,1981
|
|
8
|
+
hotdata_runtime-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
hotdata_runtime-0.1.0.dist-info/RECORD,,
|