-
Notifications
You must be signed in to change notification settings - Fork 0
Define runtime workspace selection contract #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
6068b56
618f3c3
26acad1
632b689
631e57e
d3a7823
161a142
3e5d531
ba06915
747ca1c
78f8541
466d697
9beb00a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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=[], | ||
| ) | ||
|
Comment on lines
+62
to
+66
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. super nit: (not blocking) returning |
||
| 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,9 +81,43 @@ 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 | ||
|
Comment on lines
+94
to
+112
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. super nit: (not blocking) the new resolver tests cover |
||
|
|
||
|
|
||
| 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: | ||
| 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) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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", | ||
| ] | ||
|
Comment on lines
+11
to
+29
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: (not blocking) asserting |
||
|
|
||
|
|
||
| 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", | ||
| ] | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: (not blocking)
workspaces: listis loosely typed andsource: straccepts any string. Sincesourcehas a small closed set of values ("explicit_env","active","first") it would be more contract-friendly to type it asLiteral["explicit_env", "active", "first"], andworkspacesaslist[Any](or the SDK workspace type). This makes the contract self-documenting and lets type-checkers catch typos in downstream consumers.Also: the dataclass is
frozen=True, butworkspacesis a mutable list — consumers could still mutate it in place. Usingtuple[...]would make immutability real, though that's a stylistic call.