hotdata-framework 0.4.0__tar.gz → 0.5.0__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 (38) hide show
  1. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/CHANGELOG.md +12 -0
  2. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/PKG-INFO +2 -2
  3. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/hotdata_framework/managed_client.py +57 -32
  4. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/pyproject.toml +2 -2
  5. hotdata_framework-0.5.0/tests/test_managed_client.py +93 -0
  6. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/uv.lock +5 -5
  7. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/.github/dependabot.yml +0 -0
  8. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/.github/workflows/check-release.yml +0 -0
  9. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/.github/workflows/ci.yml +0 -0
  10. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/.github/workflows/dependabot-automerge.yml +0 -0
  11. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/.github/workflows/publish.yml +0 -0
  12. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/.github/workflows/release.yml +0 -0
  13. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/.gitignore +0 -0
  14. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/CONTRACT.md +0 -0
  15. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/README.md +0 -0
  16. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/RELEASING.md +0 -0
  17. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/examples/basic_usage.py +0 -0
  18. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/hotdata_framework/__init__.py +0 -0
  19. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/hotdata_framework/client.py +0 -0
  20. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/hotdata_framework/databases.py +0 -0
  21. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/hotdata_framework/env.py +0 -0
  22. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/hotdata_framework/errors.py +0 -0
  23. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/hotdata_framework/health.py +0 -0
  24. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/hotdata_framework/http.py +0 -0
  25. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/hotdata_framework/py.typed +0 -0
  26. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/hotdata_framework/result.py +0 -0
  27. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/scripts/check-release.py +0 -0
  28. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/scripts/extract-changelog.py +0 -0
  29. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/scripts/publish-workflow.sh +0 -0
  30. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/scripts/release.sh +0 -0
  31. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/scripts/update_changelog.py +0 -0
  32. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/tests/test_client.py +0 -0
  33. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/tests/test_contract.py +0 -0
  34. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/tests/test_databases.py +0 -0
  35. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/tests/test_health.py +0 -0
  36. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/tests/test_result.py +0 -0
  37. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/tests/test_update_changelog.py +0 -0
  38. {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/tests/test_version.py +0 -0
@@ -8,6 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
  ## [Unreleased]
9
9
 
10
10
 
11
+ ## [0.5.0] - 2026-06-28
12
+
13
+ ### Changed
14
+
15
+ - Adopt the `hotdata` 0.5.0 SDK surface (dependency bumped from `>=0.4.1` to `>=0.5.0`). The release is backward compatible for everything the framework uses; the only API changes are additive (a new optional `format` field on `LoadManagedTableRequest` and an optional `format` parameter on `ResultsApi.get_result`), so no framework code changes were required.
16
+
17
+ ## [0.4.1] - 2026-06-26
18
+
19
+ ### Fixed
20
+
21
+ - `ManagedDatabaseClient.fetch_table` now waits for the persisted result to reach `ready` before fetching it as Arrow on the synchronous query path (it previously only waited on the async path). This fixes failures on read-modify-write loads (merge/append) and state reads against the live backend, where the result is often still `processing` when the inline preview returns.
22
+
11
23
  ## [0.4.0] - 2026-06-26
12
24
 
13
25
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hotdata-framework
3
- Version: 0.4.0
3
+ Version: 0.5.0
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.4.1
24
+ Requires-Dist: hotdata>=0.5.0
25
25
  Requires-Dist: pandas>=2.0
26
26
  Requires-Dist: pyarrow>=14.0
27
27
  Description-Content-Type: text/markdown
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import time
11
11
  from collections.abc import Callable
12
- from typing import Any, TypeVar
12
+ from typing import Any, Protocol, TypeVar
13
13
 
14
14
  import pyarrow as pa
15
15
  from hotdata.api.query_api import QueryApi
@@ -30,6 +30,16 @@ from hotdata_framework.errors import (
30
30
  T = TypeVar("T")
31
31
 
32
32
 
33
+ class _StatusResponse(Protocol):
34
+ """Async resources (query runs, results) expose a status and error message."""
35
+
36
+ status: str
37
+ error_message: str | None
38
+
39
+
40
+ S = TypeVar("S", bound=_StatusResponse)
41
+
42
+
33
43
  class ManagedDatabaseClient:
34
44
  """Managed-database client with bounded retries over hotdata-framework.
35
45
 
@@ -39,6 +49,10 @@ class ManagedDatabaseClient:
39
49
  database lifecycle.
40
50
  """
41
51
 
52
+ _QUERY_TIMEOUT_SECONDS = 300.0
53
+ _POLL_INTERVAL_SECONDS = 0.4
54
+ _MAX_BACKOFF_SECONDS = 30.0
55
+
42
56
  def __init__(
43
57
  self,
44
58
  *,
@@ -100,7 +114,27 @@ class ManagedDatabaseClient:
100
114
 
101
115
  return self._request_with_retry(operation)
102
116
 
103
- _QUERY_TIMEOUT_SECONDS = 300.0
117
+ def _poll(
118
+ self,
119
+ fetch: Callable[[], S],
120
+ *,
121
+ is_ready: Callable[[S], bool],
122
+ describe: str,
123
+ ) -> S:
124
+ """Poll ``fetch`` until ``is_ready`` is satisfied, or raise on failure/timeout.
125
+
126
+ ``failed``/``cancelled`` statuses raise ``RuntimeError``; exceeding
127
+ :attr:`_QUERY_TIMEOUT_SECONDS` raises ``TimeoutError``.
128
+ """
129
+ deadline = time.monotonic() + self._QUERY_TIMEOUT_SECONDS
130
+ while time.monotonic() < deadline:
131
+ obj = fetch()
132
+ if obj.status in ("failed", "cancelled"):
133
+ raise RuntimeError(obj.error_message or f"{describe} {obj.status}")
134
+ if is_ready(obj):
135
+ return obj
136
+ time.sleep(self._POLL_INTERVAL_SECONDS)
137
+ raise TimeoutError(f"{describe} timed out after {self._QUERY_TIMEOUT_SECONDS}s")
104
138
 
105
139
  def _query_database_scoped(self, sql: str, *, database_id: str) -> str | None:
106
140
  raw = QueryApi(self._runtime.api).query(
@@ -108,41 +142,34 @@ class ManagedDatabaseClient:
108
142
  x_database_id=database_id,
109
143
  )
110
144
  if isinstance(raw, QueryResponse):
111
- return raw.result_id
112
-
145
+ # A synchronous response still persists its full result out-of-band
146
+ # under ``result_id``; that result may be ``processing`` when the
147
+ # inline preview returns, so wait for ``ready`` before the caller
148
+ # fetches it as Arrow.
149
+ return self._wait_result_ready(raw.result_id)
113
150
  if isinstance(raw, AsyncQueryResponse):
114
- runs = QueryRunsApi(self._runtime.api)
115
- deadline = time.monotonic() + self._QUERY_TIMEOUT_SECONDS
116
- result_id: str | None = None
117
- while time.monotonic() < deadline:
118
- run = runs.get_query_run(raw.query_run_id)
119
- if run.status == "succeeded":
120
- result_id = run.result_id
121
- break
122
- if run.status in ("failed", "cancelled"):
123
- raise RuntimeError(run.error_message or f"Query {run.status}")
124
- time.sleep(0.5)
125
- else:
126
- raise TimeoutError(
127
- f"Managed database query timed out after {self._QUERY_TIMEOUT_SECONDS}s"
128
- )
129
- return self._wait_result_ready(result_id)
130
-
151
+ return self._wait_result_ready(self._await_query_run(raw.query_run_id))
131
152
  return None
132
153
 
154
+ def _await_query_run(self, query_run_id: str) -> str | None:
155
+ runs = QueryRunsApi(self._runtime.api)
156
+ run = self._poll(
157
+ lambda: runs.get_query_run(query_run_id),
158
+ is_ready=lambda r: r.status == "succeeded",
159
+ describe="Query",
160
+ )
161
+ return run.result_id
162
+
133
163
  def _wait_result_ready(self, result_id: str | None) -> str | None:
134
164
  if result_id is None:
135
165
  return None
136
166
  results = ResultsApi(self._runtime.api)
137
- deadline = time.monotonic() + self._QUERY_TIMEOUT_SECONDS
138
- while time.monotonic() < deadline:
139
- r = results.get_result(result_id)
140
- if r.status == "ready":
141
- return result_id
142
- if r.status in ("failed", "cancelled"):
143
- raise RuntimeError(r.error_message or f"Result {r.status}")
144
- time.sleep(0.3)
145
- raise TimeoutError(f"Result {result_id} not ready after {self._QUERY_TIMEOUT_SECONDS}s")
167
+ self._poll(
168
+ lambda: results.get_result(result_id),
169
+ is_ready=lambda r: r.status == "ready",
170
+ describe=f"Result {result_id}",
171
+ )
172
+ return result_id
146
173
 
147
174
  def fetch_table_rows(self, *, database: str, schema: str, table: str) -> list[dict[str, Any]]:
148
175
  result = self.fetch_table(database=database, schema=schema, table=table)
@@ -168,8 +195,6 @@ class ManagedDatabaseClient:
168
195
  )
169
196
  )
170
197
 
171
- _MAX_BACKOFF_SECONDS = 30.0
172
-
173
198
  def _request_with_retry(self, operation: Callable[[], T]) -> T:
174
199
  for attempt in range(1, self._max_retries + 1):
175
200
  try:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hotdata-framework"
7
- version = "0.4.0"
7
+ version = "0.5.0"
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.4.1",
29
+ "hotdata>=0.5.0",
30
30
  "pandas>=2.0",
31
31
  "pyarrow>=14.0",
32
32
  ]
@@ -0,0 +1,93 @@
1
+ """Regression tests for ManagedDatabaseClient result handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import SimpleNamespace
6
+ from typing import Any
7
+
8
+ import pyarrow as pa
9
+ import pytest
10
+ from hotdata.models.query_response import QueryResponse
11
+
12
+ import hotdata_framework.managed_client as mc
13
+
14
+
15
+ def _query_response(result_id: str) -> QueryResponse:
16
+ return QueryResponse(
17
+ columns=[],
18
+ rows=[],
19
+ row_count=0,
20
+ preview_row_count=0,
21
+ truncated=False,
22
+ nullable=[],
23
+ result_id=result_id,
24
+ query_run_id="qr",
25
+ execution_time_ms=1,
26
+ )
27
+
28
+
29
+ def test_fetch_table_waits_for_ready_before_arrow(monkeypatch: pytest.MonkeyPatch) -> None:
30
+ """A synchronous ``QueryResponse`` persists its full result out-of-band, and
31
+ that result can still be ``processing`` when the inline preview returns.
32
+
33
+ ``fetch_table`` must poll the result to ``ready`` before fetching it as
34
+ Arrow. The earlier bug returned the ``result_id`` immediately on the sync
35
+ path, so Arrow was fetched against a ``processing`` result and failed.
36
+ """
37
+ calls: list[str] = []
38
+
39
+ class FakeQueryApi:
40
+ def __init__(self, api: object) -> None:
41
+ pass
42
+
43
+ def query(self, request: object, *, x_database_id: str) -> QueryResponse:
44
+ calls.append("query")
45
+ return _query_response("rslt1")
46
+
47
+ statuses = iter(["processing", "processing", "ready"])
48
+
49
+ class FakeResultsApi:
50
+ def __init__(self, api: object) -> None:
51
+ pass
52
+
53
+ def get_result(self, result_id: str) -> Any:
54
+ status = next(statuses)
55
+ calls.append(f"get_result:{status}")
56
+ return SimpleNamespace(status=status, result_id=result_id, error_message=None)
57
+
58
+ class FakeArrowResultsApi:
59
+ def __init__(self, api: object) -> None:
60
+ pass
61
+
62
+ def get_result_arrow(self, result_id: str) -> pa.Table:
63
+ calls.append("arrow")
64
+ return pa.table({"id": [1, 2]})
65
+
66
+ monkeypatch.setattr(mc, "QueryApi", FakeQueryApi)
67
+ monkeypatch.setattr(mc, "ResultsApi", FakeResultsApi)
68
+ monkeypatch.setattr(mc, "ArrowResultsApi", FakeArrowResultsApi)
69
+ monkeypatch.setattr(mc.time, "sleep", lambda _seconds: None)
70
+
71
+ client = mc.ManagedDatabaseClient(
72
+ api_key="k",
73
+ workspace_id="w",
74
+ api_base_url="https://example.test",
75
+ max_retries=1,
76
+ retry_backoff_seconds=0.0,
77
+ )
78
+ client._runtime = SimpleNamespace( # type: ignore[assignment]
79
+ api=object(),
80
+ resolve_managed_database=lambda name: SimpleNamespace(id="db1", default_connection_id="c"),
81
+ list_managed_tables=lambda database, schema=None: [
82
+ SimpleNamespace(table="orders", synced=True)
83
+ ],
84
+ )
85
+
86
+ table = client.fetch_table(database="mydb", schema="public", table="orders")
87
+
88
+ 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")
@@ -86,7 +86,7 @@ wheels = [
86
86
 
87
87
  [[package]]
88
88
  name = "hotdata"
89
- version = "0.4.1"
89
+ version = "0.5.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/10/f8/397b2fa161e74abb115283fc169b2abe7455022cc53e1119f0fee80969d2/hotdata-0.4.1.tar.gz", hash = "sha256:e123d267872178f4dd6c8337ac47cf6616984685aca3aaa5ae2c590cecb1f29d", size = 147822, upload-time = "2026-06-19T15:12:36.131Z" }
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" }
98
98
  wheels = [
99
- { url = "https://files.pythonhosted.org/packages/47/59/5ff00a1adce5a8b312692a7145660027340d3b4b352b4c3427045a59191f/hotdata-0.4.1-py3-none-any.whl", hash = "sha256:0a71c3d74bae06a79a05eb3f42893baf33f8229ef6f0dc735b59899048a2b7b1", size = 289951, upload-time = "2026-06-19T15:12:34.752Z" },
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" },
100
100
  ]
101
101
 
102
102
  [[package]]
103
103
  name = "hotdata-framework"
104
- version = "0.4.0"
104
+ version = "0.5.0"
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.4.1" },
123
+ { name = "hotdata", specifier = ">=0.5.0" },
124
124
  { name = "pandas", specifier = ">=2.0" },
125
125
  { name = "pyarrow", specifier = ">=14.0" },
126
126
  ]