hotdata-framework 0.6.2__tar.gz → 0.7.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.6.2 → hotdata_framework-0.7.0}/CHANGELOG.md +17 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/PKG-INFO +2 -2
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/hotdata_framework/client.py +62 -5
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/hotdata_framework/managed_client.py +14 -4
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/pyproject.toml +3 -2
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/tests/test_databases.py +78 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/tests/test_managed_client.py +53 -0
- hotdata_framework-0.7.0/tests/test_request_timeout.py +69 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/uv.lock +5 -5
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/.github/dependabot.yml +0 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/.github/workflows/check-release.yml +0 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/.github/workflows/ci.yml +0 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/.github/workflows/dependabot-automerge.yml +0 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/.github/workflows/publish.yml +0 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/.github/workflows/release.yml +0 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/.gitignore +0 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/CONTRACT.md +0 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/README.md +0 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/RELEASING.md +0 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/examples/basic_usage.py +0 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/hotdata_framework/__init__.py +0 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/hotdata_framework/databases.py +0 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/hotdata_framework/env.py +0 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/hotdata_framework/errors.py +0 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/hotdata_framework/health.py +0 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/hotdata_framework/http.py +0 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/hotdata_framework/py.typed +0 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/hotdata_framework/result.py +0 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/scripts/check-release.py +0 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/scripts/extract-changelog.py +0 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/scripts/publish-workflow.sh +0 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/scripts/release.sh +0 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/scripts/update_changelog.py +0 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/tests/test_client.py +0 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/tests/test_contract.py +0 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/tests/test_errors.py +0 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/tests/test_health.py +0 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/tests/test_result.py +0 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/tests/test_update_changelog.py +0 -0
- {hotdata_framework-0.6.2 → hotdata_framework-0.7.0}/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.7.0] - 2026-07-14
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `load_managed_table(..., mode=...)` selects the load mode (`replace` (default), `append`, `delete`, `update`, `upsert`) instead of always replacing the table. `replace`/`append` apply the upload directly; `delete`/`update`/`upsert` match rows by the table's declared key. Backward compatible — omitting `mode` still replaces.
|
|
16
|
+
- `create_managed_database(..., keys={table: [cols]})` and `add_managed_table(..., key=[cols])` declare a table's row-identity key, enabling the key-based load modes on it. Requires a `hotdata` client whose managed-table decl models carry `key` (see the dependency floor bump); tables declared without a key stay `replace`/`append`-only.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- `load_managed_table(..., mode="append")` is no longer retried on transient errors. Every other mode is idempotent, but retrying an `append` whose commit succeeded before the response was received would duplicate the uploaded rows; `append` now runs at most once. `mode` is also now typed as a literal of the accepted values.
|
|
21
|
+
|
|
22
|
+
## [0.6.3] - 2026-07-08
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- `HotdataClient` and `ManagedDatabaseClient` accept `request_timeout` (seconds, or a `(connect, read)` pair). The generated SDK otherwise issues every HTTP request with urllib3's no-timeout default, so a stalled or unreachable server blocks the calling thread indefinitely; the new parameter applies a socket-level deadline to every call through the client while still honoring an explicit per-call `_request_timeout`. Also exported as `apply_default_request_timeout(api_client, timeout)` for callers holding a raw generated client. Default remains no timeout (behavior unchanged unless opted in).
|
|
27
|
+
|
|
11
28
|
## [0.6.2] - 2026-07-08
|
|
12
29
|
|
|
13
30
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hotdata-framework
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.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.7.0
|
|
25
25
|
Requires-Dist: pandas>=2.0
|
|
26
26
|
Requires-Dist: pyarrow>=14.0
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import functools
|
|
3
4
|
import time
|
|
4
5
|
from collections.abc import Iterator
|
|
5
6
|
from dataclasses import asdict, dataclass
|
|
6
|
-
from typing import Any
|
|
7
|
+
from typing import Any, Literal
|
|
7
8
|
|
|
8
9
|
from hotdata import ApiClient, Configuration
|
|
9
10
|
from hotdata.api.connections_api import ConnectionsApi
|
|
@@ -45,6 +46,10 @@ from hotdata_framework.env import (
|
|
|
45
46
|
from hotdata_framework.http import default_http_retries
|
|
46
47
|
from hotdata_framework.result import QueryResult
|
|
47
48
|
|
|
49
|
+
# Load modes the managed-table endpoint accepts: replace overwrites, append adds
|
|
50
|
+
# rows, delete/update/upsert match by the table's declared key.
|
|
51
|
+
ManagedLoadMode = Literal["replace", "append", "delete", "update", "upsert"]
|
|
52
|
+
|
|
48
53
|
_TERMINAL = frozenset({"succeeded", "failed", "cancelled"})
|
|
49
54
|
_RESULT_FAILURE = frozenset({"failed", "cancelled"})
|
|
50
55
|
|
|
@@ -71,6 +76,45 @@ class RunHistoryItem:
|
|
|
71
76
|
return asdict(self)
|
|
72
77
|
|
|
73
78
|
|
|
79
|
+
|
|
80
|
+
def apply_default_request_timeout(
|
|
81
|
+
api_client: ApiClient, timeout: float | tuple[float, float]
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Give every request through this client a socket-level deadline.
|
|
84
|
+
|
|
85
|
+
The generated client forwards ``_request_timeout=None`` — urllib3's
|
|
86
|
+
no-timeout — on every call unless the caller passes one explicitly, and
|
|
87
|
+
most helper methods expose no such knob. A stalled or black-holed server
|
|
88
|
+
therefore blocks the calling thread indefinitely. Wrapping the REST seam
|
|
89
|
+
applies ``timeout`` (seconds, or a ``(connect, read)`` pair) as the
|
|
90
|
+
default while still honoring an explicit per-call ``_request_timeout``.
|
|
91
|
+
"""
|
|
92
|
+
rest_client = api_client.rest_client
|
|
93
|
+
original = rest_client.request
|
|
94
|
+
|
|
95
|
+
@functools.wraps(original)
|
|
96
|
+
def request_with_default_timeout(
|
|
97
|
+
method,
|
|
98
|
+
url,
|
|
99
|
+
headers=None,
|
|
100
|
+
body=None,
|
|
101
|
+
post_params=None,
|
|
102
|
+
_request_timeout=None,
|
|
103
|
+
):
|
|
104
|
+
if _request_timeout is None:
|
|
105
|
+
_request_timeout = timeout
|
|
106
|
+
return original(
|
|
107
|
+
method,
|
|
108
|
+
url,
|
|
109
|
+
headers=headers,
|
|
110
|
+
body=body,
|
|
111
|
+
post_params=post_params,
|
|
112
|
+
_request_timeout=_request_timeout,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
rest_client.request = request_with_default_timeout
|
|
116
|
+
|
|
117
|
+
|
|
74
118
|
class HotdataClient:
|
|
75
119
|
"""Thin wrapper around the Hotdata Python SDK with query polling helpers."""
|
|
76
120
|
|
|
@@ -81,6 +125,7 @@ class HotdataClient:
|
|
|
81
125
|
*,
|
|
82
126
|
host: str | None = None,
|
|
83
127
|
session_id: str | None = None,
|
|
128
|
+
request_timeout: float | tuple[float, float] | None = None,
|
|
84
129
|
) -> None:
|
|
85
130
|
self._host = normalize_host(host) if host else default_host()
|
|
86
131
|
self._api_key = api_key
|
|
@@ -94,6 +139,8 @@ class HotdataClient:
|
|
|
94
139
|
retries=default_http_retries(),
|
|
95
140
|
)
|
|
96
141
|
self._api = ApiClient(self._config)
|
|
142
|
+
if request_timeout is not None:
|
|
143
|
+
apply_default_request_timeout(self._api, request_timeout)
|
|
97
144
|
|
|
98
145
|
@classmethod
|
|
99
146
|
def from_env(cls) -> HotdataClient:
|
|
@@ -198,14 +245,21 @@ class HotdataClient:
|
|
|
198
245
|
*,
|
|
199
246
|
schema: str = DEFAULT_SCHEMA,
|
|
200
247
|
tables: list[str] | None = None,
|
|
248
|
+
keys: dict[str, list[str]] | None = None,
|
|
201
249
|
expires_at: str | None = None,
|
|
202
250
|
) -> ManagedDatabase:
|
|
251
|
+
"""Create a managed database. ``keys`` maps a table to its key columns
|
|
252
|
+
(enabling delete/update/upsert on it); omitted tables are keyless."""
|
|
253
|
+
keys = keys or {}
|
|
203
254
|
schemas = None
|
|
204
255
|
if tables:
|
|
205
256
|
schemas = [
|
|
206
257
|
DatabaseDefaultSchemaDecl(
|
|
207
258
|
name=schema,
|
|
208
|
-
tables=[
|
|
259
|
+
tables=[
|
|
260
|
+
DatabaseDefaultTableDecl(name=t, key=list(keys.get(t, [])))
|
|
261
|
+
for t in tables
|
|
262
|
+
],
|
|
209
263
|
)
|
|
210
264
|
]
|
|
211
265
|
request = CreateDatabaseRequest(
|
|
@@ -271,6 +325,7 @@ class HotdataClient:
|
|
|
271
325
|
schema: str = DEFAULT_SCHEMA,
|
|
272
326
|
upload_id: str | None = None,
|
|
273
327
|
file: str | None = None,
|
|
328
|
+
mode: ManagedLoadMode = "replace",
|
|
274
329
|
) -> LoadManagedTableResult:
|
|
275
330
|
if (upload_id is None) == (file is None):
|
|
276
331
|
raise ValueError("Exactly one of upload_id or file is required")
|
|
@@ -281,7 +336,7 @@ class HotdataClient:
|
|
|
281
336
|
assert file is not None
|
|
282
337
|
resolved_upload_id = self.upload_parquet(file)
|
|
283
338
|
request = LoadManagedTableRequest(
|
|
284
|
-
mode=
|
|
339
|
+
mode=mode,
|
|
285
340
|
upload_id=resolved_upload_id,
|
|
286
341
|
)
|
|
287
342
|
try:
|
|
@@ -307,15 +362,17 @@ class HotdataClient:
|
|
|
307
362
|
table: str,
|
|
308
363
|
*,
|
|
309
364
|
schema: str = DEFAULT_SCHEMA,
|
|
365
|
+
key: list[str] | None = None,
|
|
310
366
|
) -> ManagedTable:
|
|
311
367
|
"""Declare a new table on an existing managed database.
|
|
312
368
|
|
|
313
369
|
The table is added empty (declared-but-unloaded); populate it with
|
|
314
370
|
:meth:`load_managed_table`. Use this to evolve a managed database's
|
|
315
|
-
schema after creation without recreating it.
|
|
371
|
+
schema after creation without recreating it. ``key`` sets the
|
|
372
|
+
row-identity columns for delete/update/upsert; omit for keyless.
|
|
316
373
|
"""
|
|
317
374
|
db = self.resolve_managed_database(database)
|
|
318
|
-
request = AddManagedTableRequest(name=table)
|
|
375
|
+
request = AddManagedTableRequest(name=table, key=list(key or []))
|
|
319
376
|
try:
|
|
320
377
|
self._databases_api().add_database_table(db.id, schema, request)
|
|
321
378
|
except ApiException as e:
|
|
@@ -21,6 +21,7 @@ from hotdata.models.query_request import QueryRequest
|
|
|
21
21
|
from hotdata.models.query_response import QueryResponse
|
|
22
22
|
|
|
23
23
|
from hotdata_framework.client import HotdataClient as RuntimeClient
|
|
24
|
+
from hotdata_framework.client import ManagedLoadMode
|
|
24
25
|
from hotdata_framework.databases import LoadManagedTableResult, ManagedDatabase
|
|
25
26
|
from hotdata_framework.errors import (
|
|
26
27
|
HotdataTransientError,
|
|
@@ -61,6 +62,7 @@ class ManagedDatabaseClient:
|
|
|
61
62
|
api_base_url: str,
|
|
62
63
|
max_retries: int,
|
|
63
64
|
retry_backoff_seconds: float,
|
|
65
|
+
request_timeout: float | tuple[float, float] | None = None,
|
|
64
66
|
) -> None:
|
|
65
67
|
self._max_retries = max_retries
|
|
66
68
|
self._retry_backoff_seconds = retry_backoff_seconds
|
|
@@ -68,6 +70,7 @@ class ManagedDatabaseClient:
|
|
|
68
70
|
api_key,
|
|
69
71
|
workspace_id,
|
|
70
72
|
host=api_base_url.rstrip("/"),
|
|
73
|
+
request_timeout=request_timeout,
|
|
71
74
|
)
|
|
72
75
|
|
|
73
76
|
def close(self) -> None:
|
|
@@ -201,23 +204,30 @@ class ManagedDatabaseClient:
|
|
|
201
204
|
*,
|
|
202
205
|
schema: str,
|
|
203
206
|
upload_id: str,
|
|
207
|
+
mode: ManagedLoadMode = "replace",
|
|
204
208
|
) -> LoadManagedTableResult:
|
|
209
|
+
# append is the only non-idempotent mode: if the server commits the load
|
|
210
|
+
# but the response is lost, a retry re-appends the same rows. Run it
|
|
211
|
+
# at-most-once; every other mode is safe to retry.
|
|
205
212
|
return self._request_with_retry(
|
|
206
213
|
lambda: self._runtime.load_managed_table(
|
|
207
214
|
database,
|
|
208
215
|
table,
|
|
209
216
|
schema=schema,
|
|
210
217
|
upload_id=upload_id,
|
|
211
|
-
|
|
218
|
+
mode=mode,
|
|
219
|
+
),
|
|
220
|
+
retryable=(mode != "append"),
|
|
212
221
|
)
|
|
213
222
|
|
|
214
|
-
def _request_with_retry(self, operation: Callable[[], T]) -> T:
|
|
215
|
-
|
|
223
|
+
def _request_with_retry(self, operation: Callable[[], T], *, retryable: bool = True) -> T:
|
|
224
|
+
max_attempts = self._max_retries if retryable else 1
|
|
225
|
+
for attempt in range(1, max_attempts + 1):
|
|
216
226
|
try:
|
|
217
227
|
return operation()
|
|
218
228
|
except Exception as error:
|
|
219
229
|
mapped_error = classify_sdk_error(error.__cause__ or error)
|
|
220
|
-
if isinstance(mapped_error, HotdataTransientError) and attempt <
|
|
230
|
+
if isinstance(mapped_error, HotdataTransientError) and attempt < max_attempts:
|
|
221
231
|
backoff = min(self._retry_backoff_seconds * attempt, self._MAX_BACKOFF_SECONDS)
|
|
222
232
|
time.sleep(backoff)
|
|
223
233
|
continue
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hotdata-framework"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.7.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,8 @@ classifiers = [
|
|
|
26
26
|
"Typing :: Typed",
|
|
27
27
|
]
|
|
28
28
|
dependencies = [
|
|
29
|
-
|
|
29
|
+
# 0.7.0 adds `key` to table decls (create_managed_database(keys=) / add_managed_table(key=))
|
|
30
|
+
"hotdata>=0.7.0",
|
|
30
31
|
"pandas>=2.0",
|
|
31
32
|
"pyarrow>=14.0",
|
|
32
33
|
]
|
|
@@ -5,6 +5,7 @@ from unittest.mock import mock_open, patch
|
|
|
5
5
|
|
|
6
6
|
import pytest
|
|
7
7
|
from hotdata.exceptions import ApiException
|
|
8
|
+
from hotdata.models.database_default_table_decl import DatabaseDefaultTableDecl
|
|
8
9
|
|
|
9
10
|
from hotdata_framework.client import HotdataClient
|
|
10
11
|
from hotdata_framework.databases import (
|
|
@@ -13,6 +14,20 @@ from hotdata_framework.databases import (
|
|
|
13
14
|
)
|
|
14
15
|
|
|
15
16
|
|
|
17
|
+
def _decl_key_supported() -> bool:
|
|
18
|
+
# `key` ships with the regenerated client; the key tests activate once it does.
|
|
19
|
+
try:
|
|
20
|
+
return DatabaseDefaultTableDecl(name="t", key=["k"]).key == ["k"]
|
|
21
|
+
except Exception:
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
requires_key_field = pytest.mark.skipif(
|
|
26
|
+
not _decl_key_supported(),
|
|
27
|
+
reason="hotdata client without `key` on managed-table decls",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
16
31
|
def _client() -> HotdataClient:
|
|
17
32
|
return HotdataClient("k", "ws", host="https://api.hotdata.dev")
|
|
18
33
|
|
|
@@ -230,6 +245,69 @@ def test_load_managed_table_requires_exactly_one_source():
|
|
|
230
245
|
)
|
|
231
246
|
|
|
232
247
|
|
|
248
|
+
def _load_and_capture_request(client, **kwargs):
|
|
249
|
+
db = managed_database_from_detail(_detail())
|
|
250
|
+
loaded = SimpleNamespace(
|
|
251
|
+
connection_id="conn_1", schema_name="public", table_name="orders", row_count=1
|
|
252
|
+
)
|
|
253
|
+
with (
|
|
254
|
+
patch.object(client, "resolve_managed_database", return_value=db),
|
|
255
|
+
patch.object(client, "connections") as connections,
|
|
256
|
+
):
|
|
257
|
+
connections.return_value.load_managed_table.return_value = loaded
|
|
258
|
+
client.load_managed_table("db_1", "orders", upload_id="upl_1", **kwargs)
|
|
259
|
+
return connections.return_value.load_managed_table.call_args.args[3]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_load_managed_table_defaults_to_replace():
|
|
263
|
+
assert _load_and_capture_request(_client()).mode == "replace"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@pytest.mark.parametrize("mode", ["append", "delete", "update", "upsert"])
|
|
267
|
+
def test_load_managed_table_forwards_mode(mode: str):
|
|
268
|
+
assert _load_and_capture_request(_client(), mode=mode).mode == mode
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@requires_key_field
|
|
272
|
+
def test_create_managed_database_declares_keys():
|
|
273
|
+
client = _client()
|
|
274
|
+
with patch.object(client, "_databases_api") as dbs:
|
|
275
|
+
dbs.return_value.create_database.return_value = _detail(id="db_new")
|
|
276
|
+
client.create_managed_database(
|
|
277
|
+
"mydb", tables=["orders", "events"], keys={"orders": ["id"]}
|
|
278
|
+
)
|
|
279
|
+
req = dbs.return_value.create_database.call_args.args[0]
|
|
280
|
+
declared = {t.name: list(t.key) for t in req.schemas[0].tables}
|
|
281
|
+
assert declared == {"orders": ["id"], "events": []}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@requires_key_field
|
|
285
|
+
def test_add_managed_table_declares_key():
|
|
286
|
+
client = _client()
|
|
287
|
+
db = managed_database_from_detail(_detail())
|
|
288
|
+
with (
|
|
289
|
+
patch.object(client, "resolve_managed_database", return_value=db),
|
|
290
|
+
patch.object(client, "_databases_api") as dbs,
|
|
291
|
+
):
|
|
292
|
+
client.add_managed_table("db_1", "line_items", key=["order_id", "sku"])
|
|
293
|
+
req = dbs.return_value.add_database_table.call_args.args[2]
|
|
294
|
+
assert req.name == "line_items"
|
|
295
|
+
assert list(req.key) == ["order_id", "sku"]
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@requires_key_field
|
|
299
|
+
def test_add_managed_table_keyless_by_default():
|
|
300
|
+
client = _client()
|
|
301
|
+
db = managed_database_from_detail(_detail())
|
|
302
|
+
with (
|
|
303
|
+
patch.object(client, "resolve_managed_database", return_value=db),
|
|
304
|
+
patch.object(client, "_databases_api") as dbs,
|
|
305
|
+
):
|
|
306
|
+
client.add_managed_table("db_1", "orders")
|
|
307
|
+
req = dbs.return_value.add_database_table.call_args.args[2]
|
|
308
|
+
assert list(req.key) == []
|
|
309
|
+
|
|
310
|
+
|
|
233
311
|
def test_delete_managed_table_uses_default_connection_id():
|
|
234
312
|
client = _client()
|
|
235
313
|
db = managed_database_from_detail(_detail())
|
|
@@ -156,3 +156,56 @@ def test_fetch_table_carries_database_scope_on_result_reads(
|
|
|
156
156
|
assert table is not None
|
|
157
157
|
assert result_scopes == ["db1"]
|
|
158
158
|
assert arrow_scopes == ["db1"]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _load_recording_runtime(calls: list[str]) -> SimpleNamespace:
|
|
162
|
+
"""A runtime whose ``load_managed_table`` records each mode and always fails
|
|
163
|
+
with a transient error, so retry behaviour is observable via ``calls``."""
|
|
164
|
+
|
|
165
|
+
def load_managed_table(
|
|
166
|
+
database: str, table: str, *, schema: str, upload_id: str, mode: str
|
|
167
|
+
) -> SimpleNamespace:
|
|
168
|
+
calls.append(mode)
|
|
169
|
+
raise TimeoutError("commit succeeded but response was lost")
|
|
170
|
+
|
|
171
|
+
runtime = _fake_runtime()
|
|
172
|
+
runtime.load_managed_table = load_managed_table
|
|
173
|
+
return runtime
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _managed_client(max_retries: int) -> Any:
|
|
177
|
+
return mc.ManagedDatabaseClient(
|
|
178
|
+
api_key="k",
|
|
179
|
+
workspace_id="w",
|
|
180
|
+
api_base_url="https://example.test",
|
|
181
|
+
max_retries=max_retries,
|
|
182
|
+
retry_backoff_seconds=0.0,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_append_load_runs_at_most_once(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
187
|
+
"""``append`` is not idempotent: retrying after a commit whose response was
|
|
188
|
+
lost would duplicate rows. A transient failure must surface immediately
|
|
189
|
+
without re-appending, even with retries budgeted."""
|
|
190
|
+
monkeypatch.setattr(mc.time, "sleep", lambda _seconds: None)
|
|
191
|
+
calls: list[str] = []
|
|
192
|
+
client = _managed_client(max_retries=8)
|
|
193
|
+
client._runtime = _load_recording_runtime(calls)
|
|
194
|
+
|
|
195
|
+
with pytest.raises(mc.HotdataTransientError):
|
|
196
|
+
client.load_managed_table("db", "orders", schema="public", upload_id="u1", mode="append")
|
|
197
|
+
|
|
198
|
+
assert calls == ["append"] # tried once, never retried
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_idempotent_load_retries_on_transient(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
202
|
+
"""Idempotent modes still exhaust the retry budget on transient errors."""
|
|
203
|
+
monkeypatch.setattr(mc.time, "sleep", lambda _seconds: None)
|
|
204
|
+
calls: list[str] = []
|
|
205
|
+
client = _managed_client(max_retries=3)
|
|
206
|
+
client._runtime = _load_recording_runtime(calls)
|
|
207
|
+
|
|
208
|
+
with pytest.raises(mc.HotdataTransientError):
|
|
209
|
+
client.load_managed_table("db", "orders", schema="public", upload_id="u1", mode="replace")
|
|
210
|
+
|
|
211
|
+
assert calls == ["replace", "replace", "replace"] # retried up to max_retries
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""The default request timeout must reach every call through the client.
|
|
2
|
+
|
|
3
|
+
The generated client sends ``_request_timeout=None`` (urllib3 no-timeout)
|
|
4
|
+
unless a caller passes one per call — and the helper methods expose no such
|
|
5
|
+
knob — so a stalled server blocks the calling thread indefinitely.
|
|
6
|
+
``apply_default_request_timeout`` wraps the REST seam once at construction.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from hotdata_framework.client import HotdataClient, apply_default_request_timeout
|
|
12
|
+
from hotdata_framework.managed_client import ManagedDatabaseClient
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _FakeRest:
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
self.seen: list[object] = []
|
|
18
|
+
|
|
19
|
+
def request(self, method, url, headers=None, body=None, post_params=None, _request_timeout=None):
|
|
20
|
+
self.seen.append(_request_timeout)
|
|
21
|
+
return "resp"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _FakeApi:
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self.rest_client = _FakeRest()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_default_applied_when_no_per_call_timeout() -> None:
|
|
30
|
+
api = _FakeApi()
|
|
31
|
+
rest = api.rest_client
|
|
32
|
+
apply_default_request_timeout(api, (10.0, 60.0))
|
|
33
|
+
|
|
34
|
+
assert api.rest_client.request("GET", "http://x") == "resp"
|
|
35
|
+
assert rest.seen == [(10.0, 60.0)]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_explicit_per_call_timeout_wins() -> None:
|
|
39
|
+
api = _FakeApi()
|
|
40
|
+
rest = api.rest_client
|
|
41
|
+
apply_default_request_timeout(api, 60.0)
|
|
42
|
+
|
|
43
|
+
api.rest_client.request("GET", "http://x", _request_timeout=5.0)
|
|
44
|
+
assert rest.seen == [5.0]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_hotdata_client_wraps_its_rest_seam() -> None:
|
|
48
|
+
client = HotdataClient("k", "w", host="https://example.test", request_timeout=7.0)
|
|
49
|
+
# The wrapper is installed in place of the generated bound method.
|
|
50
|
+
assert client.api.rest_client.request.__name__ == "request"
|
|
51
|
+
assert client.api.rest_client.request.__wrapped__ is not None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_hotdata_client_without_timeout_is_untouched() -> None:
|
|
55
|
+
client = HotdataClient("k", "w", host="https://example.test")
|
|
56
|
+
assert not hasattr(client.api.rest_client.request, "__wrapped__")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_managed_client_threads_timeout_through() -> None:
|
|
60
|
+
client = ManagedDatabaseClient(
|
|
61
|
+
api_key="k",
|
|
62
|
+
workspace_id="w",
|
|
63
|
+
api_base_url="https://example.test",
|
|
64
|
+
max_retries=1,
|
|
65
|
+
retry_backoff_seconds=0.0,
|
|
66
|
+
request_timeout=(5.0, 30.0),
|
|
67
|
+
)
|
|
68
|
+
assert client._runtime.api.rest_client.request.__wrapped__ is not None
|
|
69
|
+
client.close()
|
|
@@ -86,7 +86,7 @@ wheels = [
|
|
|
86
86
|
|
|
87
87
|
[[package]]
|
|
88
88
|
name = "hotdata"
|
|
89
|
-
version = "0.
|
|
89
|
+
version = "0.7.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/61/30/08681132e019f6c9ddb566dc38be6a302763c4b4848b2d97e3d7ace8d257/hotdata-0.7.0.tar.gz", hash = "sha256:ef4484a74c0f33ee543b0a0dbfb5a6c04e7b5812b277f42a6448000d41c2405d", size = 215130, upload-time = "2026-07-14T12:21:33.936Z" }
|
|
98
98
|
wheels = [
|
|
99
|
-
{ url = "https://files.pythonhosted.org/packages/dc/
|
|
99
|
+
{ url = "https://files.pythonhosted.org/packages/dc/ef/6c4236640629688074892759b1a7593c08f30e9d916b6951c2bbfec19d7f/hotdata-0.7.0-py3-none-any.whl", hash = "sha256:d9e3008e3084d22bcc27b7bf1d08d8c2c255fe3726c252c777bb614a3cae0576", size = 314103, upload-time = "2026-07-14T12:21:32.154Z" },
|
|
100
100
|
]
|
|
101
101
|
|
|
102
102
|
[[package]]
|
|
103
103
|
name = "hotdata-framework"
|
|
104
|
-
version = "0.
|
|
104
|
+
version = "0.7.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.7.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.6.2 → hotdata_framework-0.7.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
|