Skip to content

Commit 632b689

Browse files
committed
Add normalized runtime adapters and shared result helpers.
Introduce canonical result/history summary models and QueryResult metadata/record helpers in hotdata-runtime, then lock the expanded contract and behavior with dedicated tests.
1 parent 26acad1 commit 632b689

7 files changed

Lines changed: 173 additions & 1 deletion

File tree

CONTRACT.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ The supported import surface is:
2727
- `normalize_host`
2828
- `pick_workspace`
2929
- `resolve_workspace_selection`
30+
- `ResultSummary`
31+
- `RunHistoryItem`
3032
- `WorkspaceSelection`
3133

3234
Adapters should import from `hotdata_runtime` and treat this surface as the stable API.
@@ -42,6 +44,8 @@ Adapters should import from `hotdata_runtime` and treat this surface as the stab
4244
- `connections()` returns the connections API wrapper for adapter UI/status features.
4345
- `query_runs()` returns the query-runs API wrapper for adapter history views.
4446
- `results()` returns the results API wrapper for adapter result pickers.
47+
- `list_recent_results(...)` returns normalized `ResultSummary` entries.
48+
- `list_run_history(...)` returns normalized `RunHistoryItem` entries.
4549
- `list_qualified_table_names(...)` returns sorted fully qualified table names.
4650
- `columns_for_qualified(qualified, connection_id=...)` resolves table columns, and
4751
adapters should pass `connection_id` when known.
@@ -51,6 +55,8 @@ Adapters should import from `hotdata_runtime` and treat this surface as the stab
5155
- Canonical tabular result model with `columns`, `rows`, and `row_count`.
5256
- Carries server identifiers and execution metadata when available.
5357
- `to_pandas()` converts to a DataFrame with stable column ordering.
58+
- `to_records(max_rows=...)` returns row dicts keyed by column names.
59+
- `metadata_dict()` returns normalized result metadata for adapter rendering.
5460

5561
### Env Resolution
5662

hotdata_runtime/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
from importlib.metadata import PackageNotFoundError, version
44

5-
from hotdata_runtime.client import HotdataClient, from_env
5+
from hotdata_runtime.client import (
6+
HotdataClient,
7+
ResultSummary,
8+
RunHistoryItem,
9+
from_env,
10+
)
611
from hotdata_runtime.env import (
712
default_api_key,
813
default_host,
@@ -36,5 +41,7 @@
3641
"normalize_host",
3742
"pick_workspace",
3843
"resolve_workspace_selection",
44+
"ResultSummary",
45+
"RunHistoryItem",
3946
"WorkspaceSelection",
4047
]

hotdata_runtime/client.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from dataclasses import asdict, dataclass
34
import time
45
from typing import Any, Iterator
56

@@ -28,6 +29,28 @@
2829
_RESULT_FAILURE = frozenset({"failed", "cancelled"})
2930

3031

32+
@dataclass(frozen=True)
33+
class ResultSummary:
34+
result_id: str
35+
status: str
36+
created_at: str | None
37+
38+
def to_dict(self) -> dict[str, Any]:
39+
return asdict(self)
40+
41+
42+
@dataclass(frozen=True)
43+
class RunHistoryItem:
44+
query_run_id: str
45+
status: str
46+
created_at: str | None
47+
execution_time_ms: int | None
48+
result_id: str | None
49+
50+
def to_dict(self) -> dict[str, Any]:
51+
return asdict(self)
52+
53+
3154
class HotdataClient:
3255
"""Thin wrapper around the Hotdata Python SDK with query polling helpers."""
3356

@@ -109,6 +132,39 @@ def query_runs(self) -> QueryRunsApi:
109132
def results(self) -> ResultsApi:
110133
return self._results_api()
111134

135+
def list_recent_results(
136+
self,
137+
*,
138+
limit: int = 50,
139+
offset: int = 0,
140+
) -> list[ResultSummary]:
141+
listing = self.results().list_results(limit=limit, offset=offset)
142+
return [
143+
ResultSummary(
144+
result_id=r.id,
145+
status=r.status,
146+
created_at=r.created_at,
147+
)
148+
for r in listing.results
149+
]
150+
151+
def list_run_history(
152+
self,
153+
*,
154+
limit: int = 20,
155+
) -> list[RunHistoryItem]:
156+
listing = self.query_runs().list_query_runs(limit=limit)
157+
return [
158+
RunHistoryItem(
159+
query_run_id=r.id,
160+
status=r.status,
161+
created_at=r.created_at,
162+
execution_time_ms=r.execution_time_ms,
163+
result_id=r.result_id,
164+
)
165+
for r in listing.query_runs
166+
]
167+
112168
def iter_tables(
113169
self,
114170
*,

hotdata_runtime/result.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,25 @@ class QueryResult:
2020
warning: str | None = None
2121
error_message: str | None = None
2222

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+
2342
def to_pandas(self): # type: ignore[no-untyped-def]
2443
import pandas as pd
2544

tests/test_client.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,48 @@ def information_schema(self, **kwargs):
184184
id_map.assert_not_called()
185185
assert cols == [col]
186186
assert fake_api.kwargs["connection_id"] == "conn_explicit"
187+
188+
189+
def test_list_recent_results_returns_normalized_summaries():
190+
client = HotdataClient("k", "ws", host="https://api.hotdata.dev")
191+
listing = SimpleNamespace(
192+
results=[
193+
SimpleNamespace(id="res_1", status="ready", created_at="2026-01-01T00:00:00Z"),
194+
SimpleNamespace(id="res_2", status="failed", created_at=None),
195+
]
196+
)
197+
198+
class FakeResultsApi:
199+
def list_results(self, *, limit: int, offset: int):
200+
return listing
201+
202+
with patch.object(client, "results", return_value=FakeResultsApi()):
203+
out = client.list_recent_results(limit=10, offset=2)
204+
assert [r.result_id for r in out] == ["res_1", "res_2"]
205+
assert out[0].status == "ready"
206+
assert out[0].to_dict()["created_at"] == "2026-01-01T00:00:00Z"
207+
208+
209+
def test_list_run_history_returns_normalized_items():
210+
client = HotdataClient("k", "ws", host="https://api.hotdata.dev")
211+
listing = SimpleNamespace(
212+
query_runs=[
213+
SimpleNamespace(
214+
id="run_1",
215+
status="succeeded",
216+
created_at="2026-01-01T00:00:00Z",
217+
execution_time_ms=7,
218+
result_id="res_1",
219+
),
220+
]
221+
)
222+
223+
class FakeRunsApi:
224+
def list_query_runs(self, *, limit: int):
225+
return listing
226+
227+
with patch.object(client, "query_runs", return_value=FakeRunsApi()):
228+
out = client.list_run_history(limit=5)
229+
assert [r.query_run_id for r in out] == ["run_1"]
230+
assert out[0].execution_time_ms == 7
231+
assert out[0].to_dict()["result_id"] == "res_1"

tests/test_contract.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ def test_public_exports_contract():
2323
"normalize_host",
2424
"pick_workspace",
2525
"resolve_workspace_selection",
26+
"ResultSummary",
27+
"RunHistoryItem",
2628
"WorkspaceSelection",
2729
]
2830

tests/test_result.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from hotdata_runtime.result import QueryResult
2+
3+
4+
def _result() -> QueryResult:
5+
return QueryResult(
6+
columns=["a", "b"],
7+
rows=[[1, "x"], [2, "y"]],
8+
row_count=2,
9+
result_id="res_1",
10+
query_run_id="run_1",
11+
execution_time_ms=12,
12+
warning="warn",
13+
error_message=None,
14+
)
15+
16+
17+
def test_to_records_returns_row_dicts():
18+
records = _result().to_records()
19+
assert records == [{"a": 1, "b": "x"}, {"a": 2, "b": "y"}]
20+
21+
22+
def test_to_records_honors_max_rows():
23+
records = _result().to_records(max_rows=1)
24+
assert records == [{"a": 1, "b": "x"}]
25+
26+
27+
def test_metadata_dict_contains_normalized_fields():
28+
meta = _result().metadata_dict()
29+
assert meta == {
30+
"row_count": 2,
31+
"column_count": 2,
32+
"result_id": "res_1",
33+
"query_run_id": "run_1",
34+
"execution_time_ms": 12,
35+
"warning": "warn",
36+
"error_message": None,
37+
}

0 commit comments

Comments
 (0)