From 6068b5677d46ae87f12c008af15ab23e885fe4c4 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Thu, 14 May 2026 12:14:06 -0700 Subject: [PATCH 01/13] Define a canonical workspace selection contract. Introduce a shared resolver API in hotdata-runtime and enforce it with contract tests so adapters can rely on one source of workspace selection semantics. --- CONTRACT.md | 79 +++++++++++++++++++++++++++++++++++++ README.md | 2 + hotdata_runtime/__init__.py | 4 ++ hotdata_runtime/env.py | 29 ++++++++++++-- tests/test_client.py | 38 +++++++++++++++++- tests/test_contract.py | 48 ++++++++++++++++++++++ 6 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 CONTRACT.md create mode 100644 tests/test_contract.py diff --git a/CONTRACT.md b/CONTRACT.md new file mode 100644 index 0000000..06a8d40 --- /dev/null +++ b/CONTRACT.md @@ -0,0 +1,79 @@ +# hotdata-runtime Contract + +`hotdata-runtime` is the framework-agnostic runtime contract for Hotdata integrations. + +## Scope + +This package provides shared primitives for: + +- Environment and workspace resolution +- Query execution and polling +- Normalized tabular result handling +- Basic workspace health checks + +## Public Runtime Contract + +The supported import surface is: + +- `HotdataClient` +- `QueryResult` +- `from_env` +- `workspace_health_lines` +- `default_api_key` +- `default_host` +- `default_session_id` +- `explicit_workspace_id` +- `list_workspaces` +- `normalize_host` +- `pick_workspace` +- `resolve_workspace_selection` +- `WorkspaceSelection` + +Adapters should import from `hotdata_runtime` and treat this surface as the stable API. + +## Semantic Guarantees + +### `HotdataClient` + +- Represents runtime context: API key, host, workspace, optional session. +- `from_env()` resolves runtime context from env vars and selected workspace. +- `execute_sql(sql)` returns `QueryResult` or raises `RuntimeError`/`TimeoutError`. +- `get_result(result_id)` returns a ready `QueryResult` and waits for readiness when needed. + +### `QueryResult` + +- Canonical tabular result model with `columns`, `rows`, and `row_count`. +- Carries server identifiers and execution metadata when available. +- `to_pandas()` converts to a DataFrame with stable column ordering. + +### Env Resolution + +- `default_api_key()` reads `HOTDATA_API_KEY` then `HOTDATA_TOKEN`. +- `default_host()` reads `HOTDATA_API_URL` (default: `https://api.hotdata.dev`) and normalizes it. +- `default_session_id()` reads `HOTDATA_SANDBOX`. +- `pick_workspace()` prefers explicit env workspace, then active workspace, then first workspace. +- `resolve_workspace_selection()` is the canonical workspace selection algorithm. It returns `WorkspaceSelection` with selected workspace id, selection source, and discovered workspaces when auto-selected. + +## Adapter Responsibilities + +Framework packages (Jupyter, Marimo, LangChain, LangGraph, LlamaIndex, Streamlit) own: + +- Framework-native lifecycle and state management +- Rendering/UI concerns +- Tool/agent wrappers and callback integration + +They should not duplicate runtime env/workspace/query semantics. + +## Runtime Non-Goals + +`hotdata-runtime` does not define framework UI primitives and does not require framework dependencies. + +## Versioning Policy + +- Backward-incompatible contract changes require a major version bump. +- Additive contract changes are minor versions. +- Bug fixes that preserve contract semantics are patch versions. + +## Enforcement + +Contract stability is enforced by tests that verify the public export surface and key behavioral invariants. diff --git a/README.md b/README.md index e833ef8..50428c3 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ 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. +Runtime boundary and guarantees are defined in `CONTRACT.md`. + Install: ```bash diff --git a/hotdata_runtime/__init__.py b/hotdata_runtime/__init__.py index 94d84a4..670565b 100644 --- a/hotdata_runtime/__init__.py +++ b/hotdata_runtime/__init__.py @@ -11,6 +11,8 @@ list_workspaces, normalize_host, pick_workspace, + resolve_workspace_selection, + WorkspaceSelection, ) from hotdata_runtime.health import workspace_health_lines from hotdata_runtime.result import QueryResult @@ -33,4 +35,6 @@ "list_workspaces", "normalize_host", "pick_workspace", + "resolve_workspace_selection", + "WorkspaceSelection", ] diff --git a/hotdata_runtime/env.py b/hotdata_runtime/env.py index 4496a8b..bc7b09a 100644 --- a/hotdata_runtime/env.py +++ b/hotdata_runtime/env.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +from dataclasses import dataclass from urllib.parse import urlparse from hotdata import ApiClient, Configuration @@ -50,13 +51,35 @@ def list_workspaces(api_key: str, host: str, session_id: str | None): return listing.workspaces -def pick_workspace(api_key: str, host: str, session_id: str | None) -> str: +@dataclass(frozen=True) +class WorkspaceSelection: + workspace_id: str + source: str + workspaces: list + + +def resolve_workspace_selection( + api_key: str, host: str, session_id: str | None +) -> WorkspaceSelection: explicit = explicit_workspace_id() if explicit: - return explicit + return WorkspaceSelection( + workspace_id=explicit, + source="explicit_env", + workspaces=[], + ) workspaces = list_workspaces(api_key, host, session_id) if not workspaces: raise RuntimeError("No Hotdata workspaces found for this API key.") active = [w for w in workspaces if w.active] chosen = active[0] if active else workspaces[0] - return chosen.public_id + return WorkspaceSelection( + workspace_id=chosen.public_id, + source="active" if active else "first", + workspaces=workspaces, + ) + + +def pick_workspace(api_key: str, host: str, session_id: str | None) -> str: + selection = resolve_workspace_selection(api_key, host, session_id) + return selection.workspace_id diff --git a/tests/test_client.py b/tests/test_client.py index 1b420ec..7e2bebe 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -6,7 +6,7 @@ import pytest -from hotdata_runtime.env import normalize_host, pick_workspace +from hotdata_runtime.env import normalize_host, pick_workspace, resolve_workspace_selection from hotdata_runtime.client import HotdataClient @@ -30,6 +30,20 @@ def test_pick_workspace_prefers_env(monkeypatch: pytest.MonkeyPatch): assert pick_workspace("k", "https://api.hotdata.dev", None) == "ws_explicit" +def test_resolve_workspace_selection_prefers_env_without_listing( + monkeypatch: pytest.MonkeyPatch, +): + monkeypatch.setenv("HOTDATA_WORKSPACE", "ws_explicit") + with patch("hotdata_runtime.env.list_workspaces") as listing: + resolved = resolve_workspace_selection( + "k", "https://api.hotdata.dev", None + ) + listing.assert_not_called() + assert resolved.workspace_id == "ws_explicit" + assert resolved.source == "explicit_env" + assert resolved.workspaces == [] + + def test_pick_workspace_prefers_workspace_id_env(monkeypatch: pytest.MonkeyPatch): monkeypatch.delenv("HOTDATA_WORKSPACE", raising=False) monkeypatch.setenv("HOTDATA_WORKSPACE_ID", "ws_from_id") @@ -67,6 +81,28 @@ def test_pick_workspace_falls_back_to_first(monkeypatch: pytest.MonkeyPatch): assert pick_workspace("k", "https://api.hotdata.dev", None) == "ws_1" +def test_resolve_workspace_selection_returns_workspaces_and_source( + monkeypatch: pytest.MonkeyPatch, +): + monkeypatch.delenv("HOTDATA_WORKSPACE", raising=False) + monkeypatch.delenv("HOTDATA_WORKSPACE_ID", raising=False) + + items = [ + SimpleNamespace(public_id="ws_1", active=False), + SimpleNamespace(public_id="ws_2", active=True), + ] + listing = SimpleNamespace(workspaces=items) + + with patch("hotdata_runtime.env.WorkspacesApi") as Api: + Api.return_value.list_workspaces.return_value = listing + resolved = resolve_workspace_selection( + "k", "https://api.hotdata.dev", None + ) + assert resolved.workspace_id == "ws_2" + assert resolved.source == "active" + assert resolved.workspaces == items + + def test_list_qualified_table_names_passes_connection_id(): client = HotdataClient("k", "ws", host="https://api.hotdata.dev") with patch.object(client, "iter_tables", return_value=iter([])) as it: diff --git a/tests/test_contract.py b/tests/test_contract.py new file mode 100644 index 0000000..811290a --- /dev/null +++ b/tests/test_contract.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from dataclasses import fields +from unittest.mock import patch + +import hotdata_runtime as hr +from hotdata_runtime.client import HotdataClient +from hotdata_runtime.result import QueryResult + + +def test_public_exports_contract(): + assert hr.__all__ == [ + "__version__", + "HotdataClient", + "QueryResult", + "workspace_health_lines", + "default_api_key", + "default_host", + "default_session_id", + "explicit_workspace_id", + "from_env", + "list_workspaces", + "normalize_host", + "pick_workspace", + "resolve_workspace_selection", + "WorkspaceSelection", + ] + + +def test_module_from_env_delegates_to_client_classmethod(): + sentinel = HotdataClient("k", "ws", host="https://api.hotdata.dev") + with patch.object(HotdataClient, "from_env", return_value=sentinel) as m: + got = hr.from_env() + m.assert_called_once_with() + assert got is sentinel + + +def test_query_result_contract_fields(): + assert [f.name for f in fields(QueryResult)] == [ + "columns", + "rows", + "row_count", + "result_id", + "query_run_id", + "execution_time_ms", + "warning", + "error_message", + ] From 618f3c331081d6d20eda2007b76004ebe683d3bd Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Fri, 15 May 2026 16:05:28 -0700 Subject: [PATCH 02/13] Handle cancelled persisted results as terminal failures. Treat cancelled result states as immediate runtime errors and add coverage so result polling no longer times out on terminal cancellation. --- hotdata_runtime/client.py | 5 +++-- tests/test_client.py | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/hotdata_runtime/client.py b/hotdata_runtime/client.py index ea73bfc..0f0f758 100644 --- a/hotdata_runtime/client.py +++ b/hotdata_runtime/client.py @@ -25,6 +25,7 @@ from hotdata_runtime.result import QueryResult _TERMINAL = frozenset({"succeeded", "failed", "cancelled"}) +_RESULT_FAILURE = frozenset({"failed", "cancelled"}) class HotdataClient: @@ -206,9 +207,9 @@ def _wait_result_ready( last = results.get_result(result_id) if last.status == "ready": return last - if last.status == "failed": + if last.status in _RESULT_FAILURE: raise RuntimeError( - last.error_message or "Result persistence failed" + last.error_message or f"Result {last.status}" ) time.sleep(interval_s) raise TimeoutError( diff --git a/tests/test_client.py b/tests/test_client.py index 7e2bebe..3a56d27 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -109,3 +109,15 @@ def test_list_qualified_table_names_passes_connection_id(): client.list_qualified_table_names(limit=5, connection_id="conn_a") it.assert_called_once() assert it.call_args.kwargs["connection_id"] == "conn_a" + + +def test_wait_result_ready_raises_on_cancelled(): + client = HotdataClient("k", "ws", host="https://api.hotdata.dev") + + class FakeResultsApi: + def get_result(self, result_id: str): + return SimpleNamespace(status="cancelled", error_message=None) + + with patch.object(client, "_results_api", return_value=FakeResultsApi()): + with pytest.raises(RuntimeError, match="cancelled"): + client._wait_result_ready("res_1", timeout_s=0.1, interval_s=0) From 26acad1d28f305d12777a42be98591f5922fbaca Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Fri, 15 May 2026 16:27:20 -0700 Subject: [PATCH 03/13] Align runtime contract with adapter usage and ambiguity safeguards. Document the adapter-facing HotdataClient methods in the runtime contract, add duplicate-connection-name protection, and support explicit connection_id column resolution with regression coverage. --- CONTRACT.md | 6 ++++ hotdata_runtime/client.py | 31 +++++++++++++++---- tests/test_client.py | 63 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 6 deletions(-) diff --git a/CONTRACT.md b/CONTRACT.md index 06a8d40..045d424 100644 --- a/CONTRACT.md +++ b/CONTRACT.md @@ -39,6 +39,12 @@ Adapters should import from `hotdata_runtime` and treat this surface as the stab - `from_env()` resolves runtime context from env vars and selected workspace. - `execute_sql(sql)` returns `QueryResult` or raises `RuntimeError`/`TimeoutError`. - `get_result(result_id)` returns a ready `QueryResult` and waits for readiness when needed. +- `connections()` returns the connections API wrapper for adapter UI/status features. +- `query_runs()` returns the query-runs API wrapper for adapter history views. +- `results()` returns the results API wrapper for adapter result pickers. +- `list_qualified_table_names(...)` returns sorted fully qualified table names. +- `columns_for_qualified(qualified, connection_id=...)` resolves table columns, and + adapters should pass `connection_id` when known. ### `QueryResult` diff --git a/hotdata_runtime/client.py b/hotdata_runtime/client.py index 0f0f758..bb4f0c9 100644 --- a/hotdata_runtime/client.py +++ b/hotdata_runtime/client.py @@ -144,9 +144,26 @@ def list_qualified_table_names( def connection_id_by_name(self) -> dict[str, str]: listing = self.connections().list_connections() - return {c.name: c.id for c in listing.connections} + id_map: dict[str, str] = {} + duplicate_names: set[str] = set() + for c in listing.connections: + if c.name in id_map and id_map[c.name] != c.id: + duplicate_names.add(c.name) + id_map[c.name] = c.id + if duplicate_names: + names = ", ".join(sorted(duplicate_names)) + raise RuntimeError( + f"Duplicate connection names found: {names}. " + "Use an explicit connection_id." + ) + return id_map - def columns_for_qualified(self, qualified: str) -> list[TableInfo]: + def columns_for_qualified( + self, + qualified: str, + *, + connection_id: str | None = None, + ) -> list[TableInfo]: parts = qualified.split(".") if len(parts) < 3: raise ValueError( @@ -157,10 +174,12 @@ def columns_for_qualified(self, qualified: str) -> list[TableInfo]: parts[1], ".".join(parts[2:]), ) - id_map = self.connection_id_by_name() - conn_id = id_map.get(conn_name) - if not conn_id: - raise KeyError(f"Unknown connection {conn_name!r}") + conn_id = connection_id + if conn_id is None: + id_map = self.connection_id_by_name() + conn_id = id_map.get(conn_name) + if not conn_id: + raise KeyError(f"Unknown connection {conn_name!r}") resp = self._information_schema().information_schema( connection_id=conn_id, var_schema=schema_name, diff --git a/tests/test_client.py b/tests/test_client.py index 3a56d27..19cce22 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -81,6 +81,24 @@ def test_pick_workspace_falls_back_to_first(monkeypatch: pytest.MonkeyPatch): assert pick_workspace("k", "https://api.hotdata.dev", None) == "ws_1" +def test_resolve_workspace_selection_source_first(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("HOTDATA_WORKSPACE", raising=False) + monkeypatch.delenv("HOTDATA_WORKSPACE_ID", raising=False) + items = [ + SimpleNamespace(public_id="ws_1", active=False), + SimpleNamespace(public_id="ws_2", active=False), + ] + listing = SimpleNamespace(workspaces=items) + with patch("hotdata_runtime.env.WorkspacesApi") as Api: + Api.return_value.list_workspaces.return_value = listing + resolved = resolve_workspace_selection( + "k", "https://api.hotdata.dev", None + ) + assert resolved.workspace_id == "ws_1" + assert resolved.source == "first" + assert resolved.workspaces == items + + def test_resolve_workspace_selection_returns_workspaces_and_source( monkeypatch: pytest.MonkeyPatch, ): @@ -121,3 +139,48 @@ def get_result(self, result_id: str): with patch.object(client, "_results_api", return_value=FakeResultsApi()): with pytest.raises(RuntimeError, match="cancelled"): client._wait_result_ready("res_1", timeout_s=0.1, interval_s=0) + + +def test_connection_id_by_name_raises_on_duplicate_names(): + client = HotdataClient("k", "ws", host="https://api.hotdata.dev") + listing = SimpleNamespace( + connections=[ + SimpleNamespace(name="warehouse", id="conn_1"), + SimpleNamespace(name="warehouse", id="conn_2"), + ] + ) + + class FakeConnectionsApi: + def list_connections(self): + return listing + + with patch.object(client, "connections", return_value=FakeConnectionsApi()): + with pytest.raises(RuntimeError, match="Duplicate connection names"): + client.connection_id_by_name() + + +def test_columns_for_qualified_prefers_explicit_connection_id(): + client = HotdataClient("k", "ws", host="https://api.hotdata.dev") + col = SimpleNamespace(name="a", data_type="INTEGER", nullable=True) + table = SimpleNamespace(columns=[col]) + response = SimpleNamespace(tables=[table]) + + class FakeInformationSchemaApi: + def __init__(self): + self.kwargs = None + + def information_schema(self, **kwargs): + self.kwargs = kwargs + return response + + fake_api = FakeInformationSchemaApi() + with patch.object(client, "_information_schema", return_value=fake_api), patch.object( + client, "connection_id_by_name" + ) as id_map: + cols = client.columns_for_qualified( + "warehouse.public.orders", + connection_id="conn_explicit", + ) + id_map.assert_not_called() + assert cols == [col] + assert fake_api.kwargs["connection_id"] == "conn_explicit" From 632b689180ccad95f21aae53bd3cb5093758b2c6 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Fri, 15 May 2026 16:34:01 -0700 Subject: [PATCH 04/13] 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. --- CONTRACT.md | 6 ++++ hotdata_runtime/__init__.py | 9 +++++- hotdata_runtime/client.py | 56 +++++++++++++++++++++++++++++++++++++ hotdata_runtime/result.py | 19 +++++++++++++ tests/test_client.py | 45 +++++++++++++++++++++++++++++ tests/test_contract.py | 2 ++ tests/test_result.py | 37 ++++++++++++++++++++++++ 7 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 tests/test_result.py diff --git a/CONTRACT.md b/CONTRACT.md index 045d424..94350a4 100644 --- a/CONTRACT.md +++ b/CONTRACT.md @@ -27,6 +27,8 @@ The supported import surface is: - `normalize_host` - `pick_workspace` - `resolve_workspace_selection` +- `ResultSummary` +- `RunHistoryItem` - `WorkspaceSelection` 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 - `connections()` returns the connections API wrapper for adapter UI/status features. - `query_runs()` returns the query-runs API wrapper for adapter history views. - `results()` returns the results API wrapper for adapter result pickers. +- `list_recent_results(...)` returns normalized `ResultSummary` entries. +- `list_run_history(...)` returns normalized `RunHistoryItem` entries. - `list_qualified_table_names(...)` returns sorted fully qualified table names. - `columns_for_qualified(qualified, connection_id=...)` resolves table columns, and adapters should pass `connection_id` when known. @@ -51,6 +55,8 @@ Adapters should import from `hotdata_runtime` and treat this surface as the stab - Canonical tabular result model with `columns`, `rows`, and `row_count`. - Carries server identifiers and execution metadata when available. - `to_pandas()` converts to a DataFrame with stable column ordering. +- `to_records(max_rows=...)` returns row dicts keyed by column names. +- `metadata_dict()` returns normalized result metadata for adapter rendering. ### Env Resolution diff --git a/hotdata_runtime/__init__.py b/hotdata_runtime/__init__.py index 670565b..ef6bd9c 100644 --- a/hotdata_runtime/__init__.py +++ b/hotdata_runtime/__init__.py @@ -2,7 +2,12 @@ from importlib.metadata import PackageNotFoundError, version -from hotdata_runtime.client import HotdataClient, from_env +from hotdata_runtime.client import ( + HotdataClient, + ResultSummary, + RunHistoryItem, + from_env, +) from hotdata_runtime.env import ( default_api_key, default_host, @@ -36,5 +41,7 @@ "normalize_host", "pick_workspace", "resolve_workspace_selection", + "ResultSummary", + "RunHistoryItem", "WorkspaceSelection", ] diff --git a/hotdata_runtime/client.py b/hotdata_runtime/client.py index bb4f0c9..93cc6bd 100644 --- a/hotdata_runtime/client.py +++ b/hotdata_runtime/client.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import asdict, dataclass import time from typing import Any, Iterator @@ -28,6 +29,28 @@ _RESULT_FAILURE = frozenset({"failed", "cancelled"}) +@dataclass(frozen=True) +class ResultSummary: + result_id: str + status: str + created_at: str | None + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class RunHistoryItem: + query_run_id: str + status: str + created_at: str | None + execution_time_ms: int | None + result_id: str | None + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + class HotdataClient: """Thin wrapper around the Hotdata Python SDK with query polling helpers.""" @@ -109,6 +132,39 @@ def query_runs(self) -> QueryRunsApi: def results(self) -> ResultsApi: return self._results_api() + def list_recent_results( + self, + *, + limit: int = 50, + offset: int = 0, + ) -> list[ResultSummary]: + listing = self.results().list_results(limit=limit, offset=offset) + return [ + ResultSummary( + result_id=r.id, + status=r.status, + created_at=r.created_at, + ) + for r in listing.results + ] + + def list_run_history( + self, + *, + limit: int = 20, + ) -> list[RunHistoryItem]: + listing = self.query_runs().list_query_runs(limit=limit) + return [ + RunHistoryItem( + query_run_id=r.id, + status=r.status, + created_at=r.created_at, + execution_time_ms=r.execution_time_ms, + result_id=r.result_id, + ) + for r in listing.query_runs + ] + def iter_tables( self, *, diff --git a/hotdata_runtime/result.py b/hotdata_runtime/result.py index 64e700b..a8a18e8 100644 --- a/hotdata_runtime/result.py +++ b/hotdata_runtime/result.py @@ -20,6 +20,25 @@ class QueryResult: warning: str | None = None error_message: str | None = None + def to_records( + self, + *, + max_rows: int | None = None, + ) -> list[dict[str, Any]]: + rows = self.rows if max_rows is None else self.rows[:max_rows] + return [dict(zip(self.columns, row)) for row in rows] + + def metadata_dict(self) -> dict[str, Any]: + return { + "row_count": self.row_count, + "column_count": len(self.columns), + "result_id": self.result_id, + "query_run_id": self.query_run_id, + "execution_time_ms": self.execution_time_ms, + "warning": self.warning, + "error_message": self.error_message, + } + def to_pandas(self): # type: ignore[no-untyped-def] import pandas as pd diff --git a/tests/test_client.py b/tests/test_client.py index 19cce22..14d8460 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -184,3 +184,48 @@ def information_schema(self, **kwargs): id_map.assert_not_called() assert cols == [col] assert fake_api.kwargs["connection_id"] == "conn_explicit" + + +def test_list_recent_results_returns_normalized_summaries(): + client = HotdataClient("k", "ws", host="https://api.hotdata.dev") + listing = SimpleNamespace( + results=[ + SimpleNamespace(id="res_1", status="ready", created_at="2026-01-01T00:00:00Z"), + SimpleNamespace(id="res_2", status="failed", created_at=None), + ] + ) + + class FakeResultsApi: + def list_results(self, *, limit: int, offset: int): + return listing + + with patch.object(client, "results", return_value=FakeResultsApi()): + out = client.list_recent_results(limit=10, offset=2) + assert [r.result_id for r in out] == ["res_1", "res_2"] + assert out[0].status == "ready" + assert out[0].to_dict()["created_at"] == "2026-01-01T00:00:00Z" + + +def test_list_run_history_returns_normalized_items(): + client = HotdataClient("k", "ws", host="https://api.hotdata.dev") + listing = SimpleNamespace( + query_runs=[ + SimpleNamespace( + id="run_1", + status="succeeded", + created_at="2026-01-01T00:00:00Z", + execution_time_ms=7, + result_id="res_1", + ), + ] + ) + + class FakeRunsApi: + def list_query_runs(self, *, limit: int): + return listing + + with patch.object(client, "query_runs", return_value=FakeRunsApi()): + out = client.list_run_history(limit=5) + assert [r.query_run_id for r in out] == ["run_1"] + assert out[0].execution_time_ms == 7 + assert out[0].to_dict()["result_id"] == "res_1" diff --git a/tests/test_contract.py b/tests/test_contract.py index 811290a..edfd55c 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -23,6 +23,8 @@ def test_public_exports_contract(): "normalize_host", "pick_workspace", "resolve_workspace_selection", + "ResultSummary", + "RunHistoryItem", "WorkspaceSelection", ] diff --git a/tests/test_result.py b/tests/test_result.py new file mode 100644 index 0000000..3c1ebac --- /dev/null +++ b/tests/test_result.py @@ -0,0 +1,37 @@ +from hotdata_runtime.result import QueryResult + + +def _result() -> QueryResult: + return QueryResult( + columns=["a", "b"], + rows=[[1, "x"], [2, "y"]], + row_count=2, + result_id="res_1", + query_run_id="run_1", + execution_time_ms=12, + warning="warn", + error_message=None, + ) + + +def test_to_records_returns_row_dicts(): + records = _result().to_records() + assert records == [{"a": 1, "b": "x"}, {"a": 2, "b": "y"}] + + +def test_to_records_honors_max_rows(): + records = _result().to_records(max_rows=1) + assert records == [{"a": 1, "b": "x"}] + + +def test_metadata_dict_contains_normalized_fields(): + meta = _result().metadata_dict() + assert meta == { + "row_count": 2, + "column_count": 2, + "result_id": "res_1", + "query_run_id": "run_1", + "execution_time_ms": 12, + "warning": "warn", + "error_message": None, + } From 631e57ec83397493c0354d9aa11b87b91ec5fbbe Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Fri, 15 May 2026 16:40:55 -0700 Subject: [PATCH 05/13] Align run-history helper pagination with result listings. Add offset support to list_run_history and extend tests/contracts so runtime pagination semantics stay consistent across normalized helper APIs. --- CONTRACT.md | 2 +- hotdata_runtime/client.py | 3 ++- tests/test_client.py | 12 +++++++++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/CONTRACT.md b/CONTRACT.md index 94350a4..497aa7c 100644 --- a/CONTRACT.md +++ b/CONTRACT.md @@ -45,7 +45,7 @@ Adapters should import from `hotdata_runtime` and treat this surface as the stab - `query_runs()` returns the query-runs API wrapper for adapter history views. - `results()` returns the results API wrapper for adapter result pickers. - `list_recent_results(...)` returns normalized `ResultSummary` entries. -- `list_run_history(...)` returns normalized `RunHistoryItem` entries. +- `list_run_history(limit=..., offset=...)` returns normalized `RunHistoryItem` entries. - `list_qualified_table_names(...)` returns sorted fully qualified table names. - `columns_for_qualified(qualified, connection_id=...)` resolves table columns, and adapters should pass `connection_id` when known. diff --git a/hotdata_runtime/client.py b/hotdata_runtime/client.py index 93cc6bd..e7fd63a 100644 --- a/hotdata_runtime/client.py +++ b/hotdata_runtime/client.py @@ -152,8 +152,9 @@ def list_run_history( self, *, limit: int = 20, + offset: int = 0, ) -> list[RunHistoryItem]: - listing = self.query_runs().list_query_runs(limit=limit) + listing = self.query_runs().list_query_runs(limit=limit, offset=offset) return [ RunHistoryItem( query_run_id=r.id, diff --git a/tests/test_client.py b/tests/test_client.py index 14d8460..980053b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -221,11 +221,17 @@ def test_list_run_history_returns_normalized_items(): ) class FakeRunsApi: - def list_query_runs(self, *, limit: int): + def __init__(self): + self.kwargs = None + + def list_query_runs(self, *, limit: int, offset: int): + self.kwargs = {"limit": limit, "offset": offset} return listing - with patch.object(client, "query_runs", return_value=FakeRunsApi()): - out = client.list_run_history(limit=5) + fake_runs = FakeRunsApi() + with patch.object(client, "query_runs", return_value=fake_runs): + out = client.list_run_history(limit=5, offset=3) assert [r.query_run_id for r in out] == ["run_1"] assert out[0].execution_time_ms == 7 assert out[0].to_dict()["result_id"] == "res_1" + assert fake_runs.kwargs == {"limit": 5, "offset": 3} From d3a78235ebdf44b8746c6acff5ef79b85ec6d9ce Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Fri, 15 May 2026 16:44:27 -0700 Subject: [PATCH 06/13] Add runtime usage example and README reference. Provide a concrete script demonstrating query execution, metadata, records, and normalized history/result helper usage in hotdata-runtime. --- README.md | 6 ++++++ examples/basic_usage.py | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 examples/basic_usage.py diff --git a/README.md b/README.md index 50428c3..f4a1407 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,12 @@ uv pip install hotdata-runtime # or: pip install hotdata-runtime ``` +Example: + +```bash +python examples/basic_usage.py +``` + Development (uses **uv**; creates `.venv/` in this repo): ```bash diff --git a/examples/basic_usage.py b/examples/basic_usage.py new file mode 100644 index 0000000..ed259f6 --- /dev/null +++ b/examples/basic_usage.py @@ -0,0 +1,25 @@ +"""Basic hotdata-runtime usage.""" + +from hotdata_runtime import from_env + + +def main() -> None: + client = from_env() + result = client.execute_sql("SELECT 1 AS ok") + + print("result metadata:", result.metadata_dict()) + print("records:", result.to_records(max_rows=5)) + + print("recent results:") + for item in client.list_recent_results(limit=5, offset=0): + print(item.to_dict()) + + print("run history:") + for item in client.list_run_history(limit=5, offset=0): + print(item.to_dict()) + + client.close() + + +if __name__ == "__main__": + main() From 161a14267922832dbfd05e337f1d0f4833625a15 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Fri, 15 May 2026 16:55:44 -0700 Subject: [PATCH 07/13] Drop HOTDATA_TOKEN fallback from runtime auth resolution. Require HOTDATA_API_KEY as the single environment contract so runtime behavior and docs stay unambiguous. --- CONTRACT.md | 2 +- hotdata_runtime/client.py | 4 +--- hotdata_runtime/env.py | 4 +--- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/CONTRACT.md b/CONTRACT.md index 497aa7c..a80b792 100644 --- a/CONTRACT.md +++ b/CONTRACT.md @@ -60,7 +60,7 @@ Adapters should import from `hotdata_runtime` and treat this surface as the stab ### Env Resolution -- `default_api_key()` reads `HOTDATA_API_KEY` then `HOTDATA_TOKEN`. +- `default_api_key()` reads `HOTDATA_API_KEY`. - `default_host()` reads `HOTDATA_API_URL` (default: `https://api.hotdata.dev`) and normalizes it. - `default_session_id()` reads `HOTDATA_SANDBOX`. - `pick_workspace()` prefers explicit env workspace, then active workspace, then first workspace. diff --git a/hotdata_runtime/client.py b/hotdata_runtime/client.py index e7fd63a..357d083 100644 --- a/hotdata_runtime/client.py +++ b/hotdata_runtime/client.py @@ -78,9 +78,7 @@ def __init__( def from_env(cls) -> HotdataClient: api_key = default_api_key() if not api_key: - raise RuntimeError( - "HOTDATA_API_KEY or HOTDATA_TOKEN must be set." - ) + raise RuntimeError("HOTDATA_API_KEY must be set.") host = default_host() session = default_session_id() workspace_id = pick_workspace(api_key, host, session) diff --git a/hotdata_runtime/env.py b/hotdata_runtime/env.py index bc7b09a..314b322 100644 --- a/hotdata_runtime/env.py +++ b/hotdata_runtime/env.py @@ -19,9 +19,7 @@ def normalize_host(url: str) -> str: def default_api_key() -> str: - return os.environ.get("HOTDATA_API_KEY", "") or os.environ.get( - "HOTDATA_TOKEN", "" - ) + return os.environ.get("HOTDATA_API_KEY", "") def explicit_workspace_id() -> str | None: From 3e5d5319d304d0f824f7606565729736ce6b5ebf Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Sat, 16 May 2026 20:06:47 -0700 Subject: [PATCH 08/13] refactor: read workspace id from HOTDATA_WORKSPACE_ID only Drop the legacy HOTDATA_WORKSPACE fallback so runtime workspace selection matches the canonical env var used across adapters. --- CONTRACT.md | 1 + hotdata_runtime/env.py | 4 +--- tests/test_client.py | 23 +++++++---------------- 3 files changed, 9 insertions(+), 19 deletions(-) diff --git a/CONTRACT.md b/CONTRACT.md index a80b792..3b16620 100644 --- a/CONTRACT.md +++ b/CONTRACT.md @@ -63,6 +63,7 @@ Adapters should import from `hotdata_runtime` and treat this surface as the stab - `default_api_key()` reads `HOTDATA_API_KEY`. - `default_host()` reads `HOTDATA_API_URL` (default: `https://api.hotdata.dev`) and normalizes it. - `default_session_id()` reads `HOTDATA_SANDBOX`. +- `explicit_workspace_id()` reads `HOTDATA_WORKSPACE_ID` (workspace public id). - `pick_workspace()` prefers explicit env workspace, then active workspace, then first workspace. - `resolve_workspace_selection()` is the canonical workspace selection algorithm. It returns `WorkspaceSelection` with selected workspace id, selection source, and discovered workspaces when auto-selected. diff --git a/hotdata_runtime/env.py b/hotdata_runtime/env.py index 314b322..de25815 100644 --- a/hotdata_runtime/env.py +++ b/hotdata_runtime/env.py @@ -23,9 +23,7 @@ def default_api_key() -> str: def explicit_workspace_id() -> str | None: - return os.environ.get("HOTDATA_WORKSPACE") or os.environ.get( - "HOTDATA_WORKSPACE_ID" - ) + return os.environ.get("HOTDATA_WORKSPACE_ID") def default_host() -> str: diff --git a/tests/test_client.py b/tests/test_client.py index 980053b..c610e28 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -25,15 +25,15 @@ def test_normalize_host(raw: str, expected: str): assert normalize_host(raw) == expected -def test_pick_workspace_prefers_env(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setenv("HOTDATA_WORKSPACE", "ws_explicit") +def test_pick_workspace_prefers_workspace_id_env(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("HOTDATA_WORKSPACE_ID", "ws_explicit") assert pick_workspace("k", "https://api.hotdata.dev", None) == "ws_explicit" def test_resolve_workspace_selection_prefers_env_without_listing( monkeypatch: pytest.MonkeyPatch, ): - monkeypatch.setenv("HOTDATA_WORKSPACE", "ws_explicit") + monkeypatch.setenv("HOTDATA_WORKSPACE_ID", "ws_explicit") with patch("hotdata_runtime.env.list_workspaces") as listing: resolved = resolve_workspace_selection( "k", "https://api.hotdata.dev", None @@ -44,14 +44,8 @@ def test_resolve_workspace_selection_prefers_env_without_listing( assert resolved.workspaces == [] -def test_pick_workspace_prefers_workspace_id_env(monkeypatch: pytest.MonkeyPatch): - monkeypatch.delenv("HOTDATA_WORKSPACE", raising=False) - monkeypatch.setenv("HOTDATA_WORKSPACE_ID", "ws_from_id") - assert pick_workspace("k", "https://api.hotdata.dev", None) == "ws_from_id" - def test_pick_workspace_chooses_first_active(monkeypatch: pytest.MonkeyPatch): - monkeypatch.delenv("HOTDATA_WORKSPACE", raising=False) monkeypatch.delenv("HOTDATA_WORKSPACE_ID", raising=False) items = [ @@ -67,7 +61,6 @@ def test_pick_workspace_chooses_first_active(monkeypatch: pytest.MonkeyPatch): def test_pick_workspace_falls_back_to_first(monkeypatch: pytest.MonkeyPatch): - monkeypatch.delenv("HOTDATA_WORKSPACE", raising=False) monkeypatch.delenv("HOTDATA_WORKSPACE_ID", raising=False) items = [ @@ -82,7 +75,6 @@ def test_pick_workspace_falls_back_to_first(monkeypatch: pytest.MonkeyPatch): def test_resolve_workspace_selection_source_first(monkeypatch: pytest.MonkeyPatch): - monkeypatch.delenv("HOTDATA_WORKSPACE", raising=False) monkeypatch.delenv("HOTDATA_WORKSPACE_ID", raising=False) items = [ SimpleNamespace(public_id="ws_1", active=False), @@ -102,7 +94,6 @@ def test_resolve_workspace_selection_source_first(monkeypatch: pytest.MonkeyPatc def test_resolve_workspace_selection_returns_workspaces_and_source( monkeypatch: pytest.MonkeyPatch, ): - monkeypatch.delenv("HOTDATA_WORKSPACE", raising=False) monkeypatch.delenv("HOTDATA_WORKSPACE_ID", raising=False) items = [ @@ -224,14 +215,14 @@ class FakeRunsApi: def __init__(self): self.kwargs = None - def list_query_runs(self, *, limit: int, offset: int): - self.kwargs = {"limit": limit, "offset": offset} + def list_query_runs(self, *, limit: int): + self.kwargs = {"limit": limit} return listing fake_runs = FakeRunsApi() with patch.object(client, "query_runs", return_value=fake_runs): - out = client.list_run_history(limit=5, offset=3) + out = client.list_run_history(limit=5) assert [r.query_run_id for r in out] == ["run_1"] assert out[0].execution_time_ms == 7 assert out[0].to_dict()["result_id"] == "res_1" - assert fake_runs.kwargs == {"limit": 5, "offset": 3} + assert fake_runs.kwargs == {"limit": 5} From ba0691584d3ef53c000f9cf1784effdc3a2b3f58 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Sat, 16 May 2026 20:06:50 -0700 Subject: [PATCH 09/13] feat: add HTTP retries and resilient execute_sql Configure urllib3 retries on the SDK client and retry transient connection errors when running SQL. Also drop the invalid offset argument from list_run_history. --- hotdata_runtime/client.py | 20 ++++++++++++++++++-- hotdata_runtime/http.py | 19 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 hotdata_runtime/http.py diff --git a/hotdata_runtime/client.py b/hotdata_runtime/client.py index 357d083..c648f78 100644 --- a/hotdata_runtime/client.py +++ b/hotdata_runtime/client.py @@ -4,6 +4,9 @@ import time from typing import Any, Iterator +from urllib3.exceptions import HTTPError as Urllib3HTTPError +from urllib3.exceptions import ProtocolError + from hotdata import ApiClient, Configuration from hotdata.api.connections_api import ConnectionsApi from hotdata.api.information_schema_api import InformationSchemaApi @@ -23,6 +26,7 @@ normalize_host, pick_workspace, ) +from hotdata_runtime.http import default_http_retries from hotdata_runtime.result import QueryResult _TERMINAL = frozenset({"succeeded", "failed", "cancelled"}) @@ -71,6 +75,7 @@ def __init__( api_key=api_key, workspace_id=workspace_id, session_id=session_id, + retries=default_http_retries(), ) self._api = ApiClient(self._config) @@ -150,9 +155,8 @@ def list_run_history( self, *, limit: int = 20, - offset: int = 0, ) -> list[RunHistoryItem]: - listing = self.query_runs().list_query_runs(limit=limit, offset=offset) + listing = self.query_runs().list_query_runs(limit=limit) return [ RunHistoryItem( query_run_id=r.id, @@ -292,6 +296,18 @@ def _wait_result_ready( ) def execute_sql(self, sql: str) -> QueryResult: + last_err: BaseException | None = None + for attempt in range(3): + try: + return self._execute_sql_once(sql) + except (ProtocolError, ConnectionResetError, Urllib3HTTPError) as e: + last_err = e + if attempt == 2: + raise + time.sleep(0.2 * (2**attempt)) + raise last_err # pragma: no cover + + def _execute_sql_once(self, sql: str) -> QueryResult: q = self._query_api() try: raw = q.query(QueryRequest(sql=sql)) diff --git a/hotdata_runtime/http.py b/hotdata_runtime/http.py new file mode 100644 index 0000000..1e7458b --- /dev/null +++ b/hotdata_runtime/http.py @@ -0,0 +1,19 @@ +"""HTTP client defaults for Hotdata SDK :class:`~hotdata.Configuration`.""" + +from __future__ import annotations + +from urllib3.util.retry import Retry + + +def default_http_retries() -> Retry: + """Retry transient connection failures (e.g. stale pooled sockets).""" + return Retry( + total=3, + connect=3, + read=3, + backoff_factor=0.2, + status_forcelist=(502, 503, 504), + allowed_methods=frozenset( + ["GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"] + ), + ) From 747ca1c15b53fe257da31ed23fc83361ead84afe Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Sat, 16 May 2026 20:09:10 -0700 Subject: [PATCH 10/13] refactor: use HOTDATA_WORKSPACE env var Revert to the canonical HOTDATA_WORKSPACE name for explicit workspace selection, matching the CLI and other adapters. --- CONTRACT.md | 2 +- hotdata_runtime/env.py | 2 +- tests/test_client.py | 14 +++++++------- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/CONTRACT.md b/CONTRACT.md index 3b16620..6943226 100644 --- a/CONTRACT.md +++ b/CONTRACT.md @@ -63,7 +63,7 @@ Adapters should import from `hotdata_runtime` and treat this surface as the stab - `default_api_key()` reads `HOTDATA_API_KEY`. - `default_host()` reads `HOTDATA_API_URL` (default: `https://api.hotdata.dev`) and normalizes it. - `default_session_id()` reads `HOTDATA_SANDBOX`. -- `explicit_workspace_id()` reads `HOTDATA_WORKSPACE_ID` (workspace public id). +- `explicit_workspace_id()` reads `HOTDATA_WORKSPACE` (workspace public id). - `pick_workspace()` prefers explicit env workspace, then active workspace, then first workspace. - `resolve_workspace_selection()` is the canonical workspace selection algorithm. It returns `WorkspaceSelection` with selected workspace id, selection source, and discovered workspaces when auto-selected. diff --git a/hotdata_runtime/env.py b/hotdata_runtime/env.py index de25815..8b0a84b 100644 --- a/hotdata_runtime/env.py +++ b/hotdata_runtime/env.py @@ -23,7 +23,7 @@ def default_api_key() -> str: def explicit_workspace_id() -> str | None: - return os.environ.get("HOTDATA_WORKSPACE_ID") + return os.environ.get("HOTDATA_WORKSPACE") def default_host() -> str: diff --git a/tests/test_client.py b/tests/test_client.py index c610e28..fc5ccdb 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -25,15 +25,15 @@ def test_normalize_host(raw: str, expected: str): assert normalize_host(raw) == expected -def test_pick_workspace_prefers_workspace_id_env(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setenv("HOTDATA_WORKSPACE_ID", "ws_explicit") +def test_pick_workspace_prefers_env(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("HOTDATA_WORKSPACE", "ws_explicit") assert pick_workspace("k", "https://api.hotdata.dev", None) == "ws_explicit" def test_resolve_workspace_selection_prefers_env_without_listing( monkeypatch: pytest.MonkeyPatch, ): - monkeypatch.setenv("HOTDATA_WORKSPACE_ID", "ws_explicit") + monkeypatch.setenv("HOTDATA_WORKSPACE", "ws_explicit") with patch("hotdata_runtime.env.list_workspaces") as listing: resolved = resolve_workspace_selection( "k", "https://api.hotdata.dev", None @@ -46,7 +46,7 @@ def test_resolve_workspace_selection_prefers_env_without_listing( def test_pick_workspace_chooses_first_active(monkeypatch: pytest.MonkeyPatch): - monkeypatch.delenv("HOTDATA_WORKSPACE_ID", raising=False) + monkeypatch.delenv("HOTDATA_WORKSPACE", raising=False) items = [ SimpleNamespace(public_id="ws_1", active=False), @@ -61,7 +61,7 @@ def test_pick_workspace_chooses_first_active(monkeypatch: pytest.MonkeyPatch): def test_pick_workspace_falls_back_to_first(monkeypatch: pytest.MonkeyPatch): - monkeypatch.delenv("HOTDATA_WORKSPACE_ID", raising=False) + monkeypatch.delenv("HOTDATA_WORKSPACE", raising=False) items = [ SimpleNamespace(public_id="ws_1", active=False), @@ -75,7 +75,7 @@ def test_pick_workspace_falls_back_to_first(monkeypatch: pytest.MonkeyPatch): def test_resolve_workspace_selection_source_first(monkeypatch: pytest.MonkeyPatch): - monkeypatch.delenv("HOTDATA_WORKSPACE_ID", raising=False) + monkeypatch.delenv("HOTDATA_WORKSPACE", raising=False) items = [ SimpleNamespace(public_id="ws_1", active=False), SimpleNamespace(public_id="ws_2", active=False), @@ -94,7 +94,7 @@ def test_resolve_workspace_selection_source_first(monkeypatch: pytest.MonkeyPatc def test_resolve_workspace_selection_returns_workspaces_and_source( monkeypatch: pytest.MonkeyPatch, ): - monkeypatch.delenv("HOTDATA_WORKSPACE_ID", raising=False) + monkeypatch.delenv("HOTDATA_WORKSPACE", raising=False) items = [ SimpleNamespace(public_id="ws_1", active=False), From 78f8541f39e6bdb7d10432ca537d0e222e194640 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Sat, 16 May 2026 20:14:26 -0700 Subject: [PATCH 11/13] fix: remove unsupported run history offset from example Keep the runnable usage example aligned with the current list_run_history helper signature. --- examples/basic_usage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/basic_usage.py b/examples/basic_usage.py index ed259f6..4f93bd0 100644 --- a/examples/basic_usage.py +++ b/examples/basic_usage.py @@ -15,7 +15,7 @@ def main() -> None: print(item.to_dict()) print("run history:") - for item in client.list_run_history(limit=5, offset=0): + for item in client.list_run_history(limit=5): print(item.to_dict()) client.close() From 466d6973e0ddeb61749d35fa0a0b1d4c159f7d61 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Sat, 16 May 2026 20:18:21 -0700 Subject: [PATCH 12/13] docs: add runtime feature overview Summarize the runtime client, workspace, retry, result, and health helpers so downstream adapters can quickly understand the package surface. --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index f4a1407..f4e41ad 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,17 @@ Shared runtime primitives for Hotdata integrations: workspace/session semantics, Runtime boundary and guarantees are defined in `CONTRACT.md`. +## Features + +- **Environment-driven client setup** — create clients from `HOTDATA_API_KEY`, optional `HOTDATA_API_URL`, `HOTDATA_WORKSPACE`, and `HOTDATA_SANDBOX`. +- **Workspace resolution** — choose an explicit workspace from env, otherwise discover workspaces and select the active workspace or first available workspace. +- **Sandbox/session propagation** — pass sandbox session context through the SDK via `X-Session-Id`. +- **HTTP resilience** — configure SDK retries for transient connection failures and retry SQL execution on stale pooled sockets. +- **SQL execution helper** — run SQL through `POST /v1/query`, poll async query runs when needed, and return a `QueryResult`. +- **Result utilities** — convert query results to records, pandas DataFrames, or metadata dictionaries for adapter display layers. +- **History helpers** — list recent results and query run history with normalized dataclasses. +- **Health helpers** — build compact API/workspace health summaries for UI integrations. + Install: ```bash From 9beb00a2d0aef63b8fae468ea3a23d83a1c1df34 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Sat, 16 May 2026 20:26:14 -0700 Subject: [PATCH 13/13] docs: match run history contract to helper signature Document list_run_history without offset so adapter guidance matches the runtime API. --- CONTRACT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRACT.md b/CONTRACT.md index 6943226..ae60b53 100644 --- a/CONTRACT.md +++ b/CONTRACT.md @@ -45,7 +45,7 @@ Adapters should import from `hotdata_runtime` and treat this surface as the stab - `query_runs()` returns the query-runs API wrapper for adapter history views. - `results()` returns the results API wrapper for adapter result pickers. - `list_recent_results(...)` returns normalized `ResultSummary` entries. -- `list_run_history(limit=..., offset=...)` returns normalized `RunHistoryItem` entries. +- `list_run_history(limit=...)` returns normalized `RunHistoryItem` entries. - `list_qualified_table_names(...)` returns sorted fully qualified table names. - `columns_for_qualified(qualified, connection_id=...)` resolves table columns, and adapters should pass `connection_id` when known.