Skip to content

Commit 6068b56

Browse files
committed
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.
1 parent 9bb0ade commit 6068b56

6 files changed

Lines changed: 196 additions & 4 deletions

File tree

CONTRACT.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# hotdata-runtime Contract
2+
3+
`hotdata-runtime` is the framework-agnostic runtime contract for Hotdata integrations.
4+
5+
## Scope
6+
7+
This package provides shared primitives for:
8+
9+
- Environment and workspace resolution
10+
- Query execution and polling
11+
- Normalized tabular result handling
12+
- Basic workspace health checks
13+
14+
## Public Runtime Contract
15+
16+
The supported import surface is:
17+
18+
- `HotdataClient`
19+
- `QueryResult`
20+
- `from_env`
21+
- `workspace_health_lines`
22+
- `default_api_key`
23+
- `default_host`
24+
- `default_session_id`
25+
- `explicit_workspace_id`
26+
- `list_workspaces`
27+
- `normalize_host`
28+
- `pick_workspace`
29+
- `resolve_workspace_selection`
30+
- `WorkspaceSelection`
31+
32+
Adapters should import from `hotdata_runtime` and treat this surface as the stable API.
33+
34+
## Semantic Guarantees
35+
36+
### `HotdataClient`
37+
38+
- Represents runtime context: API key, host, workspace, optional session.
39+
- `from_env()` resolves runtime context from env vars and selected workspace.
40+
- `execute_sql(sql)` returns `QueryResult` or raises `RuntimeError`/`TimeoutError`.
41+
- `get_result(result_id)` returns a ready `QueryResult` and waits for readiness when needed.
42+
43+
### `QueryResult`
44+
45+
- Canonical tabular result model with `columns`, `rows`, and `row_count`.
46+
- Carries server identifiers and execution metadata when available.
47+
- `to_pandas()` converts to a DataFrame with stable column ordering.
48+
49+
### Env Resolution
50+
51+
- `default_api_key()` reads `HOTDATA_API_KEY` then `HOTDATA_TOKEN`.
52+
- `default_host()` reads `HOTDATA_API_URL` (default: `https://api.hotdata.dev`) and normalizes it.
53+
- `default_session_id()` reads `HOTDATA_SANDBOX`.
54+
- `pick_workspace()` prefers explicit env workspace, then active workspace, then first workspace.
55+
- `resolve_workspace_selection()` is the canonical workspace selection algorithm. It returns `WorkspaceSelection` with selected workspace id, selection source, and discovered workspaces when auto-selected.
56+
57+
## Adapter Responsibilities
58+
59+
Framework packages (Jupyter, Marimo, LangChain, LangGraph, LlamaIndex, Streamlit) own:
60+
61+
- Framework-native lifecycle and state management
62+
- Rendering/UI concerns
63+
- Tool/agent wrappers and callback integration
64+
65+
They should not duplicate runtime env/workspace/query semantics.
66+
67+
## Runtime Non-Goals
68+
69+
`hotdata-runtime` does not define framework UI primitives and does not require framework dependencies.
70+
71+
## Versioning Policy
72+
73+
- Backward-incompatible contract changes require a major version bump.
74+
- Additive contract changes are minor versions.
75+
- Bug fixes that preserve contract semantics are patch versions.
76+
77+
## Enforcement
78+
79+
Contract stability is enforced by tests that verify the public export surface and key behavioral invariants.

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

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.
44

5+
Runtime boundary and guarantees are defined in `CONTRACT.md`.
6+
57
Install:
68

79
```bash

hotdata_runtime/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
list_workspaces,
1212
normalize_host,
1313
pick_workspace,
14+
resolve_workspace_selection,
15+
WorkspaceSelection,
1416
)
1517
from hotdata_runtime.health import workspace_health_lines
1618
from hotdata_runtime.result import QueryResult
@@ -33,4 +35,6 @@
3335
"list_workspaces",
3436
"normalize_host",
3537
"pick_workspace",
38+
"resolve_workspace_selection",
39+
"WorkspaceSelection",
3640
]

hotdata_runtime/env.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import os
4+
from dataclasses import dataclass
45
from urllib.parse import urlparse
56

67
from hotdata import ApiClient, Configuration
@@ -50,13 +51,35 @@ def list_workspaces(api_key: str, host: str, session_id: str | None):
5051
return listing.workspaces
5152

5253

53-
def pick_workspace(api_key: str, host: str, session_id: str | None) -> str:
54+
@dataclass(frozen=True)
55+
class WorkspaceSelection:
56+
workspace_id: str
57+
source: str
58+
workspaces: list
59+
60+
61+
def resolve_workspace_selection(
62+
api_key: str, host: str, session_id: str | None
63+
) -> WorkspaceSelection:
5464
explicit = explicit_workspace_id()
5565
if explicit:
56-
return explicit
66+
return WorkspaceSelection(
67+
workspace_id=explicit,
68+
source="explicit_env",
69+
workspaces=[],
70+
)
5771
workspaces = list_workspaces(api_key, host, session_id)
5872
if not workspaces:
5973
raise RuntimeError("No Hotdata workspaces found for this API key.")
6074
active = [w for w in workspaces if w.active]
6175
chosen = active[0] if active else workspaces[0]
62-
return chosen.public_id
76+
return WorkspaceSelection(
77+
workspace_id=chosen.public_id,
78+
source="active" if active else "first",
79+
workspaces=workspaces,
80+
)
81+
82+
83+
def pick_workspace(api_key: str, host: str, session_id: str | None) -> str:
84+
selection = resolve_workspace_selection(api_key, host, session_id)
85+
return selection.workspace_id

tests/test_client.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import pytest
88

9-
from hotdata_runtime.env import normalize_host, pick_workspace
9+
from hotdata_runtime.env import normalize_host, pick_workspace, resolve_workspace_selection
1010
from hotdata_runtime.client import HotdataClient
1111

1212

@@ -30,6 +30,20 @@ def test_pick_workspace_prefers_env(monkeypatch: pytest.MonkeyPatch):
3030
assert pick_workspace("k", "https://api.hotdata.dev", None) == "ws_explicit"
3131

3232

33+
def test_resolve_workspace_selection_prefers_env_without_listing(
34+
monkeypatch: pytest.MonkeyPatch,
35+
):
36+
monkeypatch.setenv("HOTDATA_WORKSPACE", "ws_explicit")
37+
with patch("hotdata_runtime.env.list_workspaces") as listing:
38+
resolved = resolve_workspace_selection(
39+
"k", "https://api.hotdata.dev", None
40+
)
41+
listing.assert_not_called()
42+
assert resolved.workspace_id == "ws_explicit"
43+
assert resolved.source == "explicit_env"
44+
assert resolved.workspaces == []
45+
46+
3347
def test_pick_workspace_prefers_workspace_id_env(monkeypatch: pytest.MonkeyPatch):
3448
monkeypatch.delenv("HOTDATA_WORKSPACE", raising=False)
3549
monkeypatch.setenv("HOTDATA_WORKSPACE_ID", "ws_from_id")
@@ -67,6 +81,28 @@ def test_pick_workspace_falls_back_to_first(monkeypatch: pytest.MonkeyPatch):
6781
assert pick_workspace("k", "https://api.hotdata.dev", None) == "ws_1"
6882

6983

84+
def test_resolve_workspace_selection_returns_workspaces_and_source(
85+
monkeypatch: pytest.MonkeyPatch,
86+
):
87+
monkeypatch.delenv("HOTDATA_WORKSPACE", raising=False)
88+
monkeypatch.delenv("HOTDATA_WORKSPACE_ID", raising=False)
89+
90+
items = [
91+
SimpleNamespace(public_id="ws_1", active=False),
92+
SimpleNamespace(public_id="ws_2", active=True),
93+
]
94+
listing = SimpleNamespace(workspaces=items)
95+
96+
with patch("hotdata_runtime.env.WorkspacesApi") as Api:
97+
Api.return_value.list_workspaces.return_value = listing
98+
resolved = resolve_workspace_selection(
99+
"k", "https://api.hotdata.dev", None
100+
)
101+
assert resolved.workspace_id == "ws_2"
102+
assert resolved.source == "active"
103+
assert resolved.workspaces == items
104+
105+
70106
def test_list_qualified_table_names_passes_connection_id():
71107
client = HotdataClient("k", "ws", host="https://api.hotdata.dev")
72108
with patch.object(client, "iter_tables", return_value=iter([])) as it:

tests/test_contract.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import fields
4+
from unittest.mock import patch
5+
6+
import hotdata_runtime as hr
7+
from hotdata_runtime.client import HotdataClient
8+
from hotdata_runtime.result import QueryResult
9+
10+
11+
def test_public_exports_contract():
12+
assert hr.__all__ == [
13+
"__version__",
14+
"HotdataClient",
15+
"QueryResult",
16+
"workspace_health_lines",
17+
"default_api_key",
18+
"default_host",
19+
"default_session_id",
20+
"explicit_workspace_id",
21+
"from_env",
22+
"list_workspaces",
23+
"normalize_host",
24+
"pick_workspace",
25+
"resolve_workspace_selection",
26+
"WorkspaceSelection",
27+
]
28+
29+
30+
def test_module_from_env_delegates_to_client_classmethod():
31+
sentinel = HotdataClient("k", "ws", host="https://api.hotdata.dev")
32+
with patch.object(HotdataClient, "from_env", return_value=sentinel) as m:
33+
got = hr.from_env()
34+
m.assert_called_once_with()
35+
assert got is sentinel
36+
37+
38+
def test_query_result_contract_fields():
39+
assert [f.name for f in fields(QueryResult)] == [
40+
"columns",
41+
"rows",
42+
"row_count",
43+
"result_id",
44+
"query_run_id",
45+
"execution_time_ms",
46+
"warning",
47+
"error_message",
48+
]

0 commit comments

Comments
 (0)