Skip to content

Commit d1d6a1c

Browse files
authored
feat!: remove session/sandbox support (#57)
HotdataClient no longer accepts session_id=, the session_id property is gone, default_session_id() and the HOTDATA_SANDBOX read are removed, the workspace helpers lose their session_id parameter, and health output no longer emits a sandbox line. The server stopped enforcing session scoping some time ago, so the value already reached nothing. What makes removal necessary rather than tidy is that the SDK is dropping the SessionId security scheme: against that release Configuration(session_id=...) raises TypeError instead of setting a header, and this package passed it unconditionally -- so every HotdataClient(...) would fail at construction. Measured against hotdata built from sdk-python@main, this suite goes to 97 failed / 28 passed before this change. The dependency is capped at hotdata<0.9, so nothing is broken today. This is what lets the cap be raised later without a second breaking release. TESTS ASSERT THE WIRE, NOT THE SIGNATURE. The first version of these tests was close to worthless, and a sabotage audit proved it: reviving the whole feature from the ENVIRONMENT -- read HOTDATA_SANDBOX, pass it to Configuration, restore the property and the health line -- left the suite fully green. A signature check cannot see that, because no signature changes. So the assertions are on Configuration.api_keys, which is where the generated client records the schemes it will attach, and HOTDATA_SANDBOX is set deliberately in each test so an environment-sourced revival has something to find. Three sabotages that previously passed now fail: revive from the environment 0 -> 4 failures session_id back as a 3rd positional 0 -> 1 failure leave from_env's body stale 0 -> 1 failure Specific repairs to the earlier tests: * the `sandbox` assertion was dead -- it built a client with no session, so on an honest revert the `if client.session_id:` guard was false and nothing was emitted. It now sets the env var, which is the only way the assertion is reachable. * pytest.raises(TypeError) had no match=, so it passed for any unrecognised keyword. It was a typo detector. * from_env's body had no coverage at all -- the module-level test patches the classmethod out -- while this change rewrote two of its lines. It now asserts pick_workspace is called with exactly two positional arguments. * list_workspaces builds its own Configuration, a second place the header can return, and the pre-auth one. Covered separately. Also drops "session" from the package description, which ships to PyPI. 125 -> 130 passed. ruff and mypy counts unchanged from main (2 and 158, both pre-existing; CI runs pytest only). Review follow-ups: assert on the resulting Configuration rather than the kwargs it was handed, because Configuration(api_keys={"SessionId": ...}) is a documented escape hatch that re-attaches the header with no session_id keyword at all -- a kwargs-only check waved it through. Sabotaged that exact vector: 3 tests now fail on it. Also switched to the public client.api.configuration accessor instead of reaching into _config.
1 parent 43ba4ef commit d1d6a1c

11 files changed

Lines changed: 155 additions & 44 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
in this package, in versions already published. Raise the cap deliberately
1717
after running the suite against the new minor.
1818

19+
### Removed
20+
21+
- **Breaking:** session/sandbox support is gone. `HotdataClient` no longer accepts
22+
`session_id=`, `HotdataClient.session_id` is removed, `default_session_id()` and
23+
the `HOTDATA_SANDBOX` read are gone, and `list_workspaces()`,
24+
`resolve_workspace_selection()` and `pick_workspace()` lose their `session_id`
25+
parameter — note that loss is **positional**, so a three-argument call raises an
26+
arity `TypeError` rather than an unexpected-keyword one.
27+
`workspace_health_lines()` no longer emits a `sandbox` line.
28+
29+
**Why now.** The server stopped enforcing session scoping some time ago, so the
30+
value already reached nothing. What makes removal urgent rather than tidy is
31+
that the SDK is dropping the `SessionId` security scheme: against that release
32+
`Configuration(session_id=...)` raises `TypeError` instead of setting a header,
33+
and this package passed it unconditionally — so every `HotdataClient(...)`
34+
would fail at construction. This package still pins `hotdata<0.9`, so nothing
35+
is broken today; the change is what lets the cap be raised later without a
36+
second breaking release.
37+
38+
**Migrating.** Drop `session_id=` from `HotdataClient(...)`, stop reading
39+
`client.session_id`, stop setting `HOTDATA_SANDBOX`, and pass two arguments to
40+
the workspace helpers. Adapters that re-export session context in their own
41+
signatures — a `session_id=` parameter, a `session_id` metadata key — need to
42+
remove it from theirs too, which makes their own release breaking in turn.
43+
1944

2045
## [0.10.0] - 2026-08-07
2146

‎CONTRACT.md‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ The supported import surface is:
2121
- `workspace_health_lines`
2222
- `default_api_key`
2323
- `default_host`
24-
- `default_session_id`
2524
- `explicit_workspace_id`
2625
- `list_workspaces`
2726
- `normalize_host`
@@ -43,7 +42,7 @@ Adapters should import from `hotdata_framework` and treat this surface as the st
4342

4443
### `HotdataClient`
4544

46-
- Represents runtime context: API key, host, workspace, optional session.
45+
- Represents runtime context: API key, host, workspace.
4746
- `from_env()` resolves runtime context from env vars and selected workspace.
4847
- `execute_sql(sql)` returns `QueryResult` or raises `RuntimeError`/`TimeoutError`.
4948
- `get_result(result_id)` returns a ready `QueryResult` and waits for readiness when needed.
@@ -79,7 +78,6 @@ Adapters should import from `hotdata_framework` and treat this surface as the st
7978

8079
- `default_api_key()` reads `HOTDATA_API_KEY`.
8180
- `default_host()` reads `HOTDATA_API_URL` (default: `https://api.hotdata.dev`) and normalizes it.
82-
- `default_session_id()` reads `HOTDATA_SANDBOX`.
8381
- `explicit_workspace_id()` reads `HOTDATA_WORKSPACE` (workspace public id).
8482
- `pick_workspace()` prefers explicit env workspace, then active workspace, then first workspace.
8583
- `resolve_workspace_selection()` is the canonical workspace selection algorithm. It returns `WorkspaceSelection` with selected workspace id, selection source, and discovered workspaces when auto-selected.

‎README.md‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,14 @@
22

33
**A Python framework for building Hotdata integrations.**
44

5-
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.
5+
Shared runtime primitives for Hotdata integrations: workspace semantics, execution context, query state, run history, and replayable result handles. Framework packages (Marimo, Jupyter, Streamlit, LangGraph) depend on this package.
66

77
Runtime boundary and guarantees are defined in `CONTRACT.md`.
88

99
## Features
1010

11-
- **Environment-driven client setup** — create clients from `HOTDATA_API_KEY`, optional `HOTDATA_API_URL`, `HOTDATA_WORKSPACE`, and `HOTDATA_SANDBOX`.
11+
- **Environment-driven client setup** — create clients from `HOTDATA_API_KEY`, optional `HOTDATA_API_URL`, and `HOTDATA_WORKSPACE`.
1212
- **Workspace resolution** — choose an explicit workspace from env, otherwise discover workspaces and select the active workspace or first available workspace.
13-
- **Sandbox/session propagation** — pass sandbox session context through the SDK via `X-Session-Id`.
1413
- **HTTP resilience** — configure SDK retries for transient connection failures and retry SQL execution on stale pooled sockets.
1514
- **SQL execution helper** — run SQL through `POST /v1/query`, poll async query runs when needed, and return a `QueryResult`.
1615
- **Result utilities** — convert query results to records, pandas DataFrames, or metadata dictionaries for adapter display layers.

‎hotdata_framework/__init__.py‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
WorkspaceSelection,
2121
default_api_key,
2222
default_host,
23-
default_session_id,
2423
explicit_workspace_id,
2524
list_workspaces,
2625
normalize_host,
@@ -61,7 +60,6 @@
6160
"classify_sdk_error",
6261
"default_api_key",
6362
"default_host",
64-
"default_session_id",
6563
"explicit_workspace_id",
6664
"from_env",
6765
"is_parquet_path",

‎hotdata_framework/client.py‎

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@
4949
from hotdata_framework.env import (
5050
default_api_key,
5151
default_host,
52-
default_session_id,
5352
normalize_host,
5453
pick_workspace,
5554
)
@@ -150,18 +149,15 @@ def __init__(
150149
workspace_id: str,
151150
*,
152151
host: str | None = None,
153-
session_id: str | None = None,
154152
request_timeout: float | tuple[float, float] | None = None,
155153
) -> None:
156154
self._host = normalize_host(host) if host else default_host()
157155
self._api_key = api_key
158156
self._workspace_id = workspace_id
159-
self._session_id = session_id
160157
self._config = Configuration(
161158
host=self._host,
162159
api_key=api_key,
163160
workspace_id=workspace_id,
164-
session_id=session_id,
165161
retries=default_http_retries(),
166162
)
167163
self._api = ApiClient(self._config)
@@ -174,9 +170,8 @@ def from_env(cls) -> HotdataClient:
174170
if not api_key:
175171
raise RuntimeError("HOTDATA_API_KEY must be set.")
176172
host = default_host()
177-
session = default_session_id()
178-
workspace_id = pick_workspace(api_key, host, session)
179-
return cls(api_key, workspace_id, host=host, session_id=session)
173+
workspace_id = pick_workspace(api_key, host)
174+
return cls(api_key, workspace_id, host=host)
180175

181176
@property
182177
def workspace_id(self) -> str:
@@ -186,10 +181,6 @@ def workspace_id(self) -> str:
186181
def host(self) -> str:
187182
return self._host
188183

189-
@property
190-
def session_id(self) -> str | None:
191-
return self._session_id
192-
193184
@property
194185
def api(self) -> ApiClient:
195186
return self._api

‎hotdata_framework/env.py‎

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,11 @@ def default_host() -> str:
3131
return normalize_host(raw)
3232

3333

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):
34+
def list_workspaces(api_key: str, host: str):
3935
cfg = Configuration(
4036
host=host,
4137
api_key=api_key,
4238
workspace_id=None,
43-
session_id=session_id,
4439
)
4540
with ApiClient(cfg) as api:
4641
listing = WorkspacesApi(api).list_workspaces()
@@ -54,17 +49,15 @@ class WorkspaceSelection:
5449
workspaces: list
5550

5651

57-
def resolve_workspace_selection(
58-
api_key: str, host: str, session_id: str | None
59-
) -> WorkspaceSelection:
52+
def resolve_workspace_selection(api_key: str, host: str) -> WorkspaceSelection:
6053
explicit = explicit_workspace_id()
6154
if explicit:
6255
return WorkspaceSelection(
6356
workspace_id=explicit,
6457
source="explicit_env",
6558
workspaces=[],
6659
)
67-
workspaces = list_workspaces(api_key, host, session_id)
60+
workspaces = list_workspaces(api_key, host)
6861
if not workspaces:
6962
raise RuntimeError("No Hotdata workspaces found for this API key.")
7063
active = [w for w in workspaces if w.active]
@@ -76,6 +69,6 @@ def resolve_workspace_selection(
7669
)
7770

7871

79-
def pick_workspace(api_key: str, host: str, session_id: str | None) -> str:
80-
selection = resolve_workspace_selection(api_key, host, session_id)
72+
def pick_workspace(api_key: str, host: str) -> str:
73+
selection = resolve_workspace_selection(api_key, host)
8174
return selection.workspace_id

‎hotdata_framework/health.py‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,6 @@ def workspace_health_lines(client: HotdataClient) -> tuple[bool, list[str]]:
1818
f"**workspace** `{client.workspace_id}`",
1919
f"**connections** {n}",
2020
]
21-
if client.session_id:
22-
lines.append(f"**sandbox** `{client.session_id}`")
2321
return True, lines
2422
except ApiException as e:
2523
return False, [e.reason or str(e)]

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
55
[project]
66
name = "hotdata-framework"
77
version = "0.10.0"
8-
description = "Python framework for building Hotdata integrations: workspace/session runtime, query execution, and managed databases"
8+
description = "Python framework for building Hotdata integrations: workspace runtime, query execution, and managed databases"
99
readme = "README.md"
1010
requires-python = ">=3.10"
1111
license = { text = "MIT" }

‎tests/test_client.py‎

Lines changed: 103 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from unittest.mock import patch
55

66
import pytest
7+
from hotdata import Configuration
78
from hotdata.exceptions import ForbiddenException
89

910
from hotdata_framework.client import HotdataClient
@@ -153,15 +154,15 @@ def test_normalize_host(raw: str, expected: str):
153154

154155
def test_pick_workspace_prefers_env(monkeypatch: pytest.MonkeyPatch):
155156
monkeypatch.setenv("HOTDATA_WORKSPACE", "ws_explicit")
156-
assert pick_workspace("k", "https://api.hotdata.dev", None) == "ws_explicit"
157+
assert pick_workspace("k", "https://api.hotdata.dev") == "ws_explicit"
157158

158159

159160
def test_resolve_workspace_selection_prefers_env_without_listing(
160161
monkeypatch: pytest.MonkeyPatch,
161162
):
162163
monkeypatch.setenv("HOTDATA_WORKSPACE", "ws_explicit")
163164
with patch("hotdata_framework.env.list_workspaces") as listing:
164-
resolved = resolve_workspace_selection("k", "https://api.hotdata.dev", None)
165+
resolved = resolve_workspace_selection("k", "https://api.hotdata.dev")
165166
listing.assert_not_called()
166167
assert resolved.workspace_id == "ws_explicit"
167168
assert resolved.source == "explicit_env"
@@ -180,7 +181,7 @@ def test_pick_workspace_chooses_first_active(monkeypatch: pytest.MonkeyPatch):
180181

181182
with patch("hotdata_framework.env.WorkspacesApi") as Api:
182183
Api.return_value.list_workspaces.return_value = listing
183-
assert pick_workspace("k", "https://api.hotdata.dev", None) == "ws_2"
184+
assert pick_workspace("k", "https://api.hotdata.dev") == "ws_2"
184185

185186

186187
def test_pick_workspace_falls_back_to_first(monkeypatch: pytest.MonkeyPatch):
@@ -194,7 +195,7 @@ def test_pick_workspace_falls_back_to_first(monkeypatch: pytest.MonkeyPatch):
194195

195196
with patch("hotdata_framework.env.WorkspacesApi") as Api:
196197
Api.return_value.list_workspaces.return_value = listing
197-
assert pick_workspace("k", "https://api.hotdata.dev", None) == "ws_1"
198+
assert pick_workspace("k", "https://api.hotdata.dev") == "ws_1"
198199

199200

200201
def test_resolve_workspace_selection_source_first(monkeypatch: pytest.MonkeyPatch):
@@ -206,7 +207,7 @@ def test_resolve_workspace_selection_source_first(monkeypatch: pytest.MonkeyPatc
206207
listing = SimpleNamespace(workspaces=items)
207208
with patch("hotdata_framework.env.WorkspacesApi") as Api:
208209
Api.return_value.list_workspaces.return_value = listing
209-
resolved = resolve_workspace_selection("k", "https://api.hotdata.dev", None)
210+
resolved = resolve_workspace_selection("k", "https://api.hotdata.dev")
210211
assert resolved.workspace_id == "ws_1"
211212
assert resolved.source == "first"
212213
assert resolved.workspaces == items
@@ -225,7 +226,7 @@ def test_resolve_workspace_selection_returns_workspaces_and_source(
225226

226227
with patch("hotdata_framework.env.WorkspacesApi") as Api:
227228
Api.return_value.list_workspaces.return_value = listing
228-
resolved = resolve_workspace_selection("k", "https://api.hotdata.dev", None)
229+
resolved = resolve_workspace_selection("k", "https://api.hotdata.dev")
229230
assert resolved.workspace_id == "ws_2"
230231
assert resolved.source == "active"
231232
assert resolved.workspaces == items
@@ -447,3 +448,99 @@ def list_query_runs(self, *, limit: int):
447448
assert out[0].execution_time_ms == 7
448449
assert out[0].to_dict()["result_id"] == "res_1"
449450
assert fake_runs.kwargs == {"limit": 5}
451+
452+
453+
# ---------------------------------------------------------------------------
454+
# Session removal — asserted at the wire, not at the signature
455+
#
456+
# A signature-shaped check ("does __init__ accept session_id?") is satisfied by
457+
# any unrecognised keyword and says nothing about what goes on the request. The
458+
# feature was proven revivable from the ENVIRONMENT with the whole suite green:
459+
# read HOTDATA_SANDBOX, pass it to Configuration, and the X-Session-Id header is
460+
# back on every call with no signature change to notice. So these assert on
461+
# `Configuration.api_keys`, which is where the generated client actually decides
462+
# to send the header, and they set HOTDATA_SANDBOX so an environment-sourced
463+
# revival has something to find.
464+
465+
466+
def _sandbox_set(monkeypatch: pytest.MonkeyPatch) -> str:
467+
value = "sb_should_reach_nothing"
468+
monkeypatch.setenv("HOTDATA_SANDBOX", value)
469+
return value
470+
471+
472+
def test_client_registers_no_session_header(monkeypatch: pytest.MonkeyPatch):
473+
"""The removal's observable contract: no X-Session-Id on anything this client
474+
sends. `api_keys` is the SDK's own record of which security schemes it will
475+
attach, so an empty SessionId slot is the header being absent."""
476+
_sandbox_set(monkeypatch)
477+
client = HotdataClient("k", "ws", host="https://api.hotdata.dev")
478+
assert "SessionId" not in client.api.configuration.api_keys
479+
assert client.api.configuration.api_keys == {"WorkspaceId": "ws"}
480+
481+
482+
def test_client_rejects_session_id_by_name(monkeypatch: pytest.MonkeyPatch):
483+
"""`match=` matters: without it the assertion passes for ANY unknown keyword,
484+
making it a typo detector rather than a statement about `session_id`."""
485+
_sandbox_set(monkeypatch)
486+
with pytest.raises(TypeError, match="session_id"):
487+
HotdataClient("k", "ws", host="https://api.hotdata.dev", session_id="sb_x")
488+
# The property is gone too — the CHANGELOG says so, and an adapter reading it
489+
# should get AttributeError rather than a stale value.
490+
assert not hasattr(HotdataClient("k", "ws", host="https://api.hotdata.dev"), "session_id")
491+
492+
493+
def test_workspace_listing_registers_no_session_header(monkeypatch: pytest.MonkeyPatch):
494+
"""`list_workspaces` builds its OWN Configuration, so it is a second place the
495+
header can come back — and it is the pre-auth call, made before any workspace
496+
is known."""
497+
_clear_workspace_env(monkeypatch)
498+
_sandbox_set(monkeypatch)
499+
seen: list[Configuration] = []
500+
501+
def spy(*args: object, **kwargs: object) -> Configuration:
502+
# Assert on the CONFIG, not on the kwargs. `session_id=` is only one way
503+
# back: `Configuration(api_keys={"SessionId": ...})` is a documented
504+
# escape hatch that re-attaches the header with no such keyword, and a
505+
# kwargs-only check waves it through.
506+
cfg = Configuration(*args, **kwargs)
507+
seen.append(cfg)
508+
return cfg
509+
510+
listing = SimpleNamespace(workspaces=[SimpleNamespace(public_id="ws_1", active=True)])
511+
with patch("hotdata_framework.env.Configuration", spy), patch(
512+
"hotdata_framework.env.WorkspacesApi"
513+
) as Api:
514+
Api.return_value.list_workspaces.return_value = listing
515+
assert pick_workspace("k", "https://api.hotdata.dev") == "ws_1"
516+
assert seen, "list_workspaces built no Configuration — the spy never fired"
517+
for cfg in seen:
518+
assert "SessionId" not in cfg.api_keys
519+
520+
521+
def test_from_env_builds_a_client_without_a_session(monkeypatch: pytest.MonkeyPatch):
522+
"""`from_env` is the entry point every adapter uses, and its body had no
523+
coverage at all: the module-level `from_env` test patches this classmethod
524+
out. Leaving a stale 3-argument `pick_workspace(...)` call here broke it
525+
unconditionally while the suite stayed green."""
526+
_clear_workspace_env(monkeypatch)
527+
_sandbox_set(monkeypatch)
528+
monkeypatch.setenv("HOTDATA_API_KEY", "k_env")
529+
monkeypatch.setenv("HOTDATA_API_URL", "https://api.hotdata.dev")
530+
531+
with patch("hotdata_framework.client.pick_workspace") as picked:
532+
picked.return_value = "ws_from_env"
533+
client = HotdataClient.from_env()
534+
535+
# Two arguments, positionally — the signature this change trimmed.
536+
assert picked.call_args.args == ("k_env", "https://api.hotdata.dev")
537+
assert picked.call_args.kwargs == {}
538+
assert client.workspace_id == "ws_from_env"
539+
assert client.host == "https://api.hotdata.dev"
540+
assert "SessionId" not in client.api.configuration.api_keys
541+
542+
543+
def test_from_env_requires_an_api_key(monkeypatch: pytest.MonkeyPatch):
544+
monkeypatch.delenv("HOTDATA_API_KEY", raising=False)
545+
with pytest.raises(RuntimeError, match="HOTDATA_API_KEY"):
546+
HotdataClient.from_env()

‎tests/test_contract.py‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ def test_public_exports_contract():
2828
"classify_sdk_error",
2929
"default_api_key",
3030
"default_host",
31-
"default_session_id",
3231
"explicit_workspace_id",
3332
"from_env",
3433
"is_parquet_path",

0 commit comments

Comments
 (0)