hotdata-framework 0.6.0__tar.gz → 0.6.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/CHANGELOG.md +17 -0
  2. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/PKG-INFO +2 -2
  3. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/hotdata_framework/databases.py +6 -1
  4. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/hotdata_framework/errors.py +5 -0
  5. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/hotdata_framework/managed_client.py +23 -7
  6. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/pyproject.toml +2 -2
  7. hotdata_framework-0.6.2/tests/test_errors.py +48 -0
  8. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/tests/test_managed_client.py +73 -8
  9. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/uv.lock +5 -5
  10. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/.github/dependabot.yml +0 -0
  11. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/.github/workflows/check-release.yml +0 -0
  12. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/.github/workflows/ci.yml +0 -0
  13. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/.github/workflows/dependabot-automerge.yml +0 -0
  14. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/.github/workflows/publish.yml +0 -0
  15. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/.github/workflows/release.yml +0 -0
  16. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/.gitignore +0 -0
  17. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/CONTRACT.md +0 -0
  18. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/README.md +0 -0
  19. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/RELEASING.md +0 -0
  20. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/examples/basic_usage.py +0 -0
  21. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/hotdata_framework/__init__.py +0 -0
  22. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/hotdata_framework/client.py +0 -0
  23. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/hotdata_framework/env.py +0 -0
  24. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/hotdata_framework/health.py +0 -0
  25. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/hotdata_framework/http.py +0 -0
  26. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/hotdata_framework/py.typed +0 -0
  27. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/hotdata_framework/result.py +0 -0
  28. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/scripts/check-release.py +0 -0
  29. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/scripts/extract-changelog.py +0 -0
  30. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/scripts/publish-workflow.sh +0 -0
  31. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/scripts/release.sh +0 -0
  32. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/scripts/update_changelog.py +0 -0
  33. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/tests/test_client.py +0 -0
  34. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/tests/test_contract.py +0 -0
  35. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/tests/test_databases.py +0 -0
  36. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/tests/test_health.py +0 -0
  37. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/tests/test_result.py +0 -0
  38. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/tests/test_update_changelog.py +0 -0
  39. {hotdata_framework-0.6.0 → hotdata_framework-0.6.2}/tests/test_version.py +0 -0
@@ -8,6 +8,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
  ## [Unreleased]
9
9
 
10
10
 
11
+ ## [0.6.2] - 2026-07-08
12
+
13
+ ### Changed
14
+
15
+ - Repository text cleanup: the changelog and test docstrings no longer reference external issue trackers. No functional changes; 0.6.2 is byte-identical to 0.6.1 in package code.
16
+
17
+ ## [0.6.1] - 2026-07-08
18
+
19
+ ### Fixed
20
+
21
+ - `ManagedDatabaseClient.fetch_table` now carries the `X-Database-Id` scope header on the result poll, the query-run poll, and the Arrow fetch — not only on the query submit. Results of database-scoped queries are themselves database-scoped, so every read against an existing synced table (merge/append loads, dlt state restore) failed with `400: Bad Request` once the table had data.
22
+ - API error messages now include the response body (flattened, truncated to 500 chars). `400: Bad Request` alone hid the server's actual explanation.
23
+
24
+ ### Changed
25
+
26
+ - The `hotdata` SDK dependency is now `>=0.6.0`, and the scope above rides its native `x_database_id` parameters (`get_result`, `get_query_run`, `get_result_arrow`). Note 0.6.0 made `x_database_id` **required** on `get_result_arrow`, so older framework releases cannot run on it.
27
+
11
28
  ## [0.6.0] - 2026-06-30
12
29
 
13
30
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hotdata-framework
3
- Version: 0.6.0
3
+ Version: 0.6.2
4
4
  Summary: Python framework for building Hotdata integrations: workspace/session runtime, query execution, and managed databases
5
5
  Project-URL: Homepage, https://www.hotdata.dev
6
6
  Project-URL: Documentation, https://www.hotdata.dev/docs
@@ -21,7 +21,7 @@ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
21
21
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
22
  Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.10
24
- Requires-Dist: hotdata>=0.5.0
24
+ Requires-Dist: hotdata>=0.6.0
25
25
  Requires-Dist: pandas>=2.0
26
26
  Requires-Dist: pyarrow>=14.0
27
27
  Description-Content-Type: text/markdown
@@ -58,4 +58,9 @@ def managed_database_from_detail(detail: Any) -> ManagedDatabase:
58
58
 
59
59
 
60
60
  def api_error_message(exc: ApiException) -> str:
61
- return exc.reason or str(exc)
61
+ reason = exc.reason or str(exc)
62
+ # Keep the response body: it carries the API's actual explanation.
63
+ body = getattr(exc, "body", None)
64
+ if body:
65
+ return f"{reason}: {' '.join(str(body).split())[:500]}"
66
+ return reason
@@ -23,6 +23,11 @@ def classify_sdk_error(error: Exception) -> HotdataError:
23
23
  if isinstance(error, ApiException):
24
24
  status_code = int(error.status or 0)
25
25
  message = f"{status_code}: {error.reason or 'unknown error'}"
26
+ # The response body is where the API explains itself (e.g. which
27
+ # header is missing) — without it "400: Bad Request" is undebuggable.
28
+ body = getattr(error, "body", None)
29
+ if body:
30
+ message = f"{message} — {' '.join(str(body).split())[:500]}"
26
31
  if status_code in (408, 409, 425, 429):
27
32
  return HotdataTransientError(message)
28
33
  if 500 <= status_code <= 599:
@@ -110,10 +110,22 @@ class ManagedDatabaseClient:
110
110
  result_id = self._query_database_scoped(sql, database_id=db.id)
111
111
  if result_id is None:
112
112
  return None
113
- return ArrowResultsApi(self._runtime.api).get_result_arrow(result_id)
113
+ return self._fetch_result_arrow(result_id, database_id=db.id)
114
114
 
115
115
  return self._request_with_retry(operation)
116
116
 
117
+ def _fetch_result_arrow(self, result_id: str, *, database_id: str) -> pa.Table:
118
+ """Fetch a ready result as Arrow, carrying the database scope.
119
+
120
+ Results of database-scoped queries are themselves database-scoped —
121
+ the results endpoints reject requests without the scope. The hotdata
122
+ 0.6.0 SDK exposes (and requires) ``x_database_id`` on the Arrow
123
+ helper directly.
124
+ """
125
+ return ArrowResultsApi(self._runtime.api).get_result_arrow(
126
+ result_id, x_database_id=database_id
127
+ )
128
+
117
129
  def _poll(
118
130
  self,
119
131
  fetch: Callable[[], S],
@@ -146,26 +158,30 @@ class ManagedDatabaseClient:
146
158
  # under ``result_id``; that result may be ``processing`` when the
147
159
  # inline preview returns, so wait for ``ready`` before the caller
148
160
  # fetches it as Arrow.
149
- return self._wait_result_ready(raw.result_id)
161
+ return self._wait_result_ready(raw.result_id, database_id=database_id)
150
162
  if isinstance(raw, AsyncQueryResponse):
151
- return self._wait_result_ready(self._await_query_run(raw.query_run_id))
163
+ run_result = self._await_query_run(raw.query_run_id, database_id=database_id)
164
+ return self._wait_result_ready(run_result, database_id=database_id)
152
165
  return None
153
166
 
154
- def _await_query_run(self, query_run_id: str) -> str | None:
167
+ def _await_query_run(self, query_run_id: str, *, database_id: str) -> str | None:
155
168
  runs = QueryRunsApi(self._runtime.api)
156
169
  run = self._poll(
157
- lambda: runs.get_query_run(query_run_id),
170
+ # Runs (like results) of database-scoped queries are database-scoped.
171
+ lambda: runs.get_query_run(query_run_id, x_database_id=database_id),
158
172
  is_ready=lambda r: r.status == "succeeded",
159
173
  describe="Query",
160
174
  )
161
175
  return run.result_id
162
176
 
163
- def _wait_result_ready(self, result_id: str | None) -> str | None:
177
+ def _wait_result_ready(self, result_id: str | None, *, database_id: str) -> str | None:
164
178
  if result_id is None:
165
179
  return None
166
180
  results = ResultsApi(self._runtime.api)
167
181
  self._poll(
168
- lambda: results.get_result(result_id),
182
+ # The stored result of a database-scoped query 400s without the
183
+ # database scope.
184
+ lambda: results.get_result(result_id, x_database_id=database_id),
169
185
  is_ready=lambda r: r.status == "ready",
170
186
  describe=f"Result {result_id}",
171
187
  )
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hotdata-framework"
7
- version = "0.6.0"
7
+ version = "0.6.2"
8
8
  description = "Python framework for building Hotdata integrations: workspace/session runtime, query execution, and managed databases"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -26,7 +26,7 @@ classifiers = [
26
26
  "Typing :: Typed",
27
27
  ]
28
28
  dependencies = [
29
- "hotdata>=0.5.0",
29
+ "hotdata>=0.6.0",
30
30
  "pandas>=2.0",
31
31
  "pyarrow>=14.0",
32
32
  ]
@@ -0,0 +1,48 @@
1
+ """Error message construction: the API's response body must survive.
2
+
3
+ "400: Bad Request" alone is undebuggable; the body carries the server's
4
+ actual explanation (e.g. which header was missing).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from hotdata.rest import ApiException
10
+
11
+ from hotdata_framework.databases import api_error_message
12
+ from hotdata_framework.errors import (
13
+ HotdataTerminalError,
14
+ HotdataTransientError,
15
+ classify_sdk_error,
16
+ )
17
+
18
+ BODY = '{"error":{"code":"BAD_REQUEST","message":"X-Database-Id header is required"}}'
19
+
20
+
21
+ def test_classify_sdk_error_includes_response_body() -> None:
22
+ err = classify_sdk_error(ApiException(status=400, reason="Bad Request", body=BODY))
23
+ assert isinstance(err, HotdataTerminalError)
24
+ assert "400: Bad Request" in str(err)
25
+ assert "X-Database-Id header is required" in str(err)
26
+
27
+
28
+ def test_classify_sdk_error_without_body_keeps_short_form() -> None:
29
+ err = classify_sdk_error(ApiException(status=409, reason="Conflict"))
30
+ assert isinstance(err, HotdataTransientError)
31
+ assert str(err) == "409: Conflict"
32
+
33
+
34
+ def test_classify_sdk_error_truncates_and_flattens_body() -> None:
35
+ noisy = "x\n" * 1000
36
+ err = classify_sdk_error(ApiException(status=500, reason="ISE", body=noisy))
37
+ assert "\n" not in str(err)
38
+ assert len(str(err)) < 600
39
+
40
+
41
+ def test_api_error_message_includes_body() -> None:
42
+ msg = api_error_message(ApiException(status=400, reason="Bad Request", body=BODY))
43
+ assert msg.startswith("Bad Request: ")
44
+ assert "X-Database-Id header is required" in msg
45
+
46
+
47
+ def test_api_error_message_without_body() -> None:
48
+ assert api_error_message(ApiException(status=404, reason="Not Found")) == "Not Found"
@@ -50,7 +50,7 @@ def test_fetch_table_waits_for_ready_before_arrow(monkeypatch: pytest.MonkeyPatc
50
50
  def __init__(self, api: object) -> None:
51
51
  pass
52
52
 
53
- def get_result(self, result_id: str) -> Any:
53
+ def get_result(self, result_id: str, **kwargs: Any) -> Any:
54
54
  status = next(statuses)
55
55
  calls.append(f"get_result:{status}")
56
56
  return SimpleNamespace(status=status, result_id=result_id, error_message=None)
@@ -59,7 +59,7 @@ def test_fetch_table_waits_for_ready_before_arrow(monkeypatch: pytest.MonkeyPatc
59
59
  def __init__(self, api: object) -> None:
60
60
  pass
61
61
 
62
- def get_result_arrow(self, result_id: str) -> pa.Table:
62
+ def get_result_arrow(self, result_id: str, **kwargs: Any) -> pa.Table:
63
63
  calls.append("arrow")
64
64
  return pa.table({"id": [1, 2]})
65
65
 
@@ -75,7 +75,20 @@ def test_fetch_table_waits_for_ready_before_arrow(monkeypatch: pytest.MonkeyPatc
75
75
  max_retries=1,
76
76
  retry_backoff_seconds=0.0,
77
77
  )
78
- client._runtime = SimpleNamespace( # type: ignore[assignment]
78
+ client._runtime = _fake_runtime()
79
+
80
+ table = client.fetch_table(database="mydb", schema="public", table="orders")
81
+
82
+ assert table is not None
83
+ assert table.num_rows == 2
84
+ # The result was polled to readiness, and Arrow was fetched only afterwards.
85
+ assert "get_result:processing" in calls
86
+ assert "get_result:ready" in calls
87
+ assert calls.index("arrow") > calls.index("get_result:ready")
88
+
89
+
90
+ def _fake_runtime() -> SimpleNamespace:
91
+ return SimpleNamespace(
79
92
  api=object(),
80
93
  resolve_managed_database=lambda name: SimpleNamespace(id="db1", default_connection_id="c"),
81
94
  list_managed_tables=lambda database, schema=None: [
@@ -83,11 +96,63 @@ def test_fetch_table_waits_for_ready_before_arrow(monkeypatch: pytest.MonkeyPatc
83
96
  ],
84
97
  )
85
98
 
99
+
100
+ def test_fetch_table_carries_database_scope_on_result_reads(
101
+ monkeypatch: pytest.MonkeyPatch,
102
+ ) -> None:
103
+ """Results (and runs) of a database-scoped query are database-scoped:
104
+ the results endpoints 400 with "X-Database-Id header is required" when
105
+ the scope is missing. ``fetch_table`` must carry the database id on the
106
+ result poll and the Arrow fetch, not only on the query submit — the
107
+ hotdata 0.6.0 SDK exposes ``x_database_id`` on all three.
108
+
109
+ Regression: reruns/append loads against an existing synced table failed
110
+ with an opaque ``400: Bad Request`` because both reads omitted the scope.
111
+ """
112
+ result_scopes: list[str | None] = []
113
+ arrow_scopes: list[str | None] = []
114
+
115
+ class FakeQueryApi:
116
+ def __init__(self, api: object) -> None:
117
+ pass
118
+
119
+ def query(self, request: object, *, x_database_id: str) -> QueryResponse:
120
+ assert x_database_id == "db1"
121
+ return _query_response("rslt1")
122
+
123
+ class FakeResultsApi:
124
+ def __init__(self, api: object) -> None:
125
+ pass
126
+
127
+ def get_result(self, result_id: str, *, x_database_id: str | None = None) -> Any:
128
+ result_scopes.append(x_database_id)
129
+ return SimpleNamespace(status="ready", result_id=result_id, error_message=None)
130
+
131
+ class FakeArrowResultsApi:
132
+ def __init__(self, api: object) -> None:
133
+ pass
134
+
135
+ # x_database_id is REQUIRED in the 0.6.0 SDK — mirroring that here
136
+ # makes this test fail if a caller ever drops the scope again.
137
+ def get_result_arrow(self, result_id: str, *, x_database_id: str) -> pa.Table:
138
+ arrow_scopes.append(x_database_id)
139
+ return pa.table({"id": [1]})
140
+
141
+ monkeypatch.setattr(mc, "QueryApi", FakeQueryApi)
142
+ monkeypatch.setattr(mc, "ResultsApi", FakeResultsApi)
143
+ monkeypatch.setattr(mc, "ArrowResultsApi", FakeArrowResultsApi)
144
+
145
+ client = mc.ManagedDatabaseClient(
146
+ api_key="k",
147
+ workspace_id="w",
148
+ api_base_url="https://example.test",
149
+ max_retries=1,
150
+ retry_backoff_seconds=0.0,
151
+ )
152
+ client._runtime = _fake_runtime()
153
+
86
154
  table = client.fetch_table(database="mydb", schema="public", table="orders")
87
155
 
88
156
  assert table is not None
89
- assert table.num_rows == 2
90
- # The result was polled to readiness, and Arrow was fetched only afterwards.
91
- assert "get_result:processing" in calls
92
- assert "get_result:ready" in calls
93
- assert calls.index("arrow") > calls.index("get_result:ready")
157
+ assert result_scopes == ["db1"]
158
+ assert arrow_scopes == ["db1"]
@@ -86,7 +86,7 @@ wheels = [
86
86
 
87
87
  [[package]]
88
88
  name = "hotdata"
89
- version = "0.5.0"
89
+ version = "0.6.0"
90
90
  source = { registry = "https://pypi.org/simple" }
91
91
  dependencies = [
92
92
  { name = "pydantic" },
@@ -94,14 +94,14 @@ dependencies = [
94
94
  { name = "typing-extensions" },
95
95
  { name = "urllib3" },
96
96
  ]
97
- sdist = { url = "https://files.pythonhosted.org/packages/f2/d8/79f4906f4a39aacd7a94c7991af63064cecb95015d4fbe4d50757b21c940/hotdata-0.5.0.tar.gz", hash = "sha256:3d0240e6a3e7333e4290d8b3e75afebba43e518b868e1c3e3818fd719a6ab994", size = 170462, upload-time = "2026-06-28T16:28:49.591Z" }
97
+ sdist = { url = "https://files.pythonhosted.org/packages/d2/17/6d9e7cbe7e766cd52968653096ce4199d0cb1aa55b73fcfc2979fb9805ff/hotdata-0.6.0.tar.gz", hash = "sha256:c655fd9691a1d65dd4ed51b86f1c27bf8e73268b4c44696987a57a624da35143", size = 214162, upload-time = "2026-07-07T16:44:32.65Z" }
98
98
  wheels = [
99
- { url = "https://files.pythonhosted.org/packages/aa/0e/026a2e03647e3d3c26027b690ea24ef036fbeb314b85517db3d606aab4da/hotdata-0.5.0-py3-none-any.whl", hash = "sha256:e7c4f5f8af6a7b370810fb599e5a166fc4bb569f6f41c506698c4bebfb500fcc", size = 286907, upload-time = "2026-06-28T16:28:48.298Z" },
99
+ { url = "https://files.pythonhosted.org/packages/dc/0a/4c9f23e1fa85a8d0336681f608c13100e4ab2cf4204846d90374b1bb141b/hotdata-0.6.0-py3-none-any.whl", hash = "sha256:3324758eb0aa37bbcc505ddac524566a8c10fbe0787e226932e8b04d565200c1", size = 312820, upload-time = "2026-07-07T16:44:31.121Z" },
100
100
  ]
101
101
 
102
102
  [[package]]
103
103
  name = "hotdata-framework"
104
- version = "0.6.0"
104
+ version = "0.6.2"
105
105
  source = { editable = "." }
106
106
  dependencies = [
107
107
  { name = "hotdata" },
@@ -120,7 +120,7 @@ dev = [
120
120
 
121
121
  [package.metadata]
122
122
  requires-dist = [
123
- { name = "hotdata", specifier = ">=0.5.0" },
123
+ { name = "hotdata", specifier = ">=0.6.0" },
124
124
  { name = "pandas", specifier = ">=2.0" },
125
125
  { name = "pyarrow", specifier = ">=14.0" },
126
126
  ]