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.
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/CHANGELOG.md +12 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/PKG-INFO +2 -2
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/hotdata_framework/managed_client.py +57 -32
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/pyproject.toml +2 -2
- hotdata_framework-0.5.0/tests/test_managed_client.py +93 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/uv.lock +5 -5
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/.github/dependabot.yml +0 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/.github/workflows/check-release.yml +0 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/.github/workflows/ci.yml +0 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/.github/workflows/dependabot-automerge.yml +0 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/.github/workflows/publish.yml +0 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/.github/workflows/release.yml +0 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/.gitignore +0 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/CONTRACT.md +0 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/README.md +0 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/RELEASING.md +0 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/examples/basic_usage.py +0 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/hotdata_framework/__init__.py +0 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/hotdata_framework/client.py +0 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/hotdata_framework/databases.py +0 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/hotdata_framework/env.py +0 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/hotdata_framework/errors.py +0 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/hotdata_framework/health.py +0 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/hotdata_framework/http.py +0 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/hotdata_framework/py.typed +0 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/hotdata_framework/result.py +0 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/scripts/check-release.py +0 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/scripts/extract-changelog.py +0 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/scripts/publish-workflow.sh +0 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/scripts/release.sh +0 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/scripts/update_changelog.py +0 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/tests/test_client.py +0 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/tests/test_contract.py +0 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/tests/test_databases.py +0 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/tests/test_health.py +0 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/tests/test_result.py +0 -0
- {hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/tests/test_update_changelog.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
r
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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/
|
|
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/
|
|
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.
|
|
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.
|
|
123
|
+
{ name = "hotdata", specifier = ">=0.5.0" },
|
|
124
124
|
{ name = "pandas", specifier = ">=2.0" },
|
|
125
125
|
{ name = "pyarrow", specifier = ">=14.0" },
|
|
126
126
|
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hotdata_framework-0.4.0 → hotdata_framework-0.5.0}/.github/workflows/dependabot-automerge.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|