hotdata-framework 0.6.3__tar.gz → 0.7.1__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.3 → hotdata_framework-0.7.1}/CHANGELOG.md +17 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/PKG-INFO +2 -2
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/hotdata_framework/client.py +89 -5
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/hotdata_framework/managed_client.py +12 -4
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/pyproject.toml +3 -2
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/tests/test_databases.py +172 -4
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/tests/test_managed_client.py +53 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/uv.lock +5 -5
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/.github/dependabot.yml +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/.github/workflows/check-release.yml +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/.github/workflows/ci.yml +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/.github/workflows/dependabot-automerge.yml +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/.github/workflows/publish.yml +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/.github/workflows/release.yml +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/.gitignore +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/CONTRACT.md +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/README.md +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/RELEASING.md +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/examples/basic_usage.py +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/hotdata_framework/__init__.py +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/hotdata_framework/databases.py +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/hotdata_framework/env.py +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/hotdata_framework/errors.py +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/hotdata_framework/health.py +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/hotdata_framework/http.py +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/hotdata_framework/py.typed +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/hotdata_framework/result.py +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/scripts/check-release.py +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/scripts/extract-changelog.py +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/scripts/publish-workflow.sh +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/scripts/release.sh +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/scripts/update_changelog.py +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/tests/test_client.py +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/tests/test_contract.py +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/tests/test_errors.py +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/tests/test_health.py +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/tests/test_request_timeout.py +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/tests/test_result.py +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/tests/test_update_changelog.py +0 -0
- {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/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.1] - 2026-07-15
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- `upload_parquet()` now uses the presigned upload session API (`POST /v1/uploads`) instead of reading the entire file into memory before uploading. For multipart mode the file is streamed one `part_size` chunk at a time, eliminating the memory spike that caused OOM on large Parquet files. Falls back to `POST /v1/files` when the server returns 501.
|
|
16
|
+
|
|
17
|
+
## [0.7.0] - 2026-07-14
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- `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.
|
|
22
|
+
- `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.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- `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.
|
|
27
|
+
|
|
11
28
|
## [0.6.3] - 2026-07-08
|
|
12
29
|
|
|
13
30
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hotdata-framework
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.1
|
|
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,10 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import functools
|
|
4
|
+
import os
|
|
4
5
|
import time
|
|
6
|
+
import urllib3
|
|
5
7
|
from collections.abc import Iterator
|
|
6
8
|
from dataclasses import asdict, dataclass
|
|
7
|
-
from typing import Any
|
|
9
|
+
from typing import Any, Literal
|
|
8
10
|
|
|
9
11
|
from hotdata import ApiClient, Configuration
|
|
10
12
|
from hotdata.api.connections_api import ConnectionsApi
|
|
@@ -20,6 +22,9 @@ from hotdata.models.async_query_response import AsyncQueryResponse
|
|
|
20
22
|
from hotdata.models.create_database_request import CreateDatabaseRequest
|
|
21
23
|
from hotdata.models.database_default_schema_decl import DatabaseDefaultSchemaDecl
|
|
22
24
|
from hotdata.models.database_default_table_decl import DatabaseDefaultTableDecl
|
|
25
|
+
from hotdata.models.create_upload_request import CreateUploadRequest
|
|
26
|
+
from hotdata.models.finalize_upload_part import FinalizeUploadPart
|
|
27
|
+
from hotdata.models.finalize_upload_request import FinalizeUploadRequest
|
|
23
28
|
from hotdata.models.load_managed_table_request import LoadManagedTableRequest
|
|
24
29
|
from hotdata.models.query_request import QueryRequest
|
|
25
30
|
from hotdata.models.query_response import QueryResponse
|
|
@@ -46,6 +51,10 @@ from hotdata_framework.env import (
|
|
|
46
51
|
from hotdata_framework.http import default_http_retries
|
|
47
52
|
from hotdata_framework.result import QueryResult
|
|
48
53
|
|
|
54
|
+
# Load modes the managed-table endpoint accepts: replace overwrites, append adds
|
|
55
|
+
# rows, delete/update/upsert match by the table's declared key.
|
|
56
|
+
ManagedLoadMode = Literal["replace", "append", "delete", "update", "upsert"]
|
|
57
|
+
|
|
49
58
|
_TERMINAL = frozenset({"succeeded", "failed", "cancelled"})
|
|
50
59
|
_RESULT_FAILURE = frozenset({"failed", "cancelled"})
|
|
51
60
|
|
|
@@ -241,14 +250,21 @@ class HotdataClient:
|
|
|
241
250
|
*,
|
|
242
251
|
schema: str = DEFAULT_SCHEMA,
|
|
243
252
|
tables: list[str] | None = None,
|
|
253
|
+
keys: dict[str, list[str]] | None = None,
|
|
244
254
|
expires_at: str | None = None,
|
|
245
255
|
) -> ManagedDatabase:
|
|
256
|
+
"""Create a managed database. ``keys`` maps a table to its key columns
|
|
257
|
+
(enabling delete/update/upsert on it); omitted tables are keyless."""
|
|
258
|
+
keys = keys or {}
|
|
246
259
|
schemas = None
|
|
247
260
|
if tables:
|
|
248
261
|
schemas = [
|
|
249
262
|
DatabaseDefaultSchemaDecl(
|
|
250
263
|
name=schema,
|
|
251
|
-
tables=[
|
|
264
|
+
tables=[
|
|
265
|
+
DatabaseDefaultTableDecl(name=t, key=list(keys.get(t, [])))
|
|
266
|
+
for t in tables
|
|
267
|
+
],
|
|
252
268
|
)
|
|
253
269
|
]
|
|
254
270
|
request = CreateDatabaseRequest(
|
|
@@ -295,6 +311,71 @@ class HotdataClient:
|
|
|
295
311
|
def upload_parquet(self, path: str) -> str:
|
|
296
312
|
if not is_parquet_path(path):
|
|
297
313
|
raise ValueError(f"Managed table loads require a parquet file (got {path!r})")
|
|
314
|
+
file_size = os.path.getsize(path)
|
|
315
|
+
try:
|
|
316
|
+
session = self.uploads().create_upload_session_handler(
|
|
317
|
+
CreateUploadRequest(
|
|
318
|
+
declared_size_bytes=file_size,
|
|
319
|
+
content_type="application/octet-stream",
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
except ApiException as e:
|
|
323
|
+
if e.status == 501:
|
|
324
|
+
return self._upload_parquet_post(path)
|
|
325
|
+
raise RuntimeError(api_error_message(e)) from e
|
|
326
|
+
http = urllib3.PoolManager()
|
|
327
|
+
parts: list[FinalizeUploadPart] | None = None
|
|
328
|
+
try:
|
|
329
|
+
if session.mode == "single":
|
|
330
|
+
with open(path, "rb") as f:
|
|
331
|
+
data = f.read()
|
|
332
|
+
resp = http.request(
|
|
333
|
+
"PUT",
|
|
334
|
+
session.url,
|
|
335
|
+
body=data,
|
|
336
|
+
headers={"Content-Length": str(file_size), **session.headers},
|
|
337
|
+
)
|
|
338
|
+
if resp.status not in (200, 201, 204):
|
|
339
|
+
raise RuntimeError(f"Storage PUT failed: HTTP {resp.status}")
|
|
340
|
+
else:
|
|
341
|
+
collected: list[FinalizeUploadPart] = []
|
|
342
|
+
with open(path, "rb") as f:
|
|
343
|
+
for i, part_url in enumerate(session.part_urls):
|
|
344
|
+
chunk = f.read(session.part_size)
|
|
345
|
+
resp = http.request(
|
|
346
|
+
"PUT",
|
|
347
|
+
part_url,
|
|
348
|
+
body=chunk,
|
|
349
|
+
headers={
|
|
350
|
+
"Content-Length": str(len(chunk)),
|
|
351
|
+
**session.headers,
|
|
352
|
+
},
|
|
353
|
+
)
|
|
354
|
+
if resp.status not in (200, 201, 204):
|
|
355
|
+
raise RuntimeError(
|
|
356
|
+
f"Part {i + 1} PUT failed: HTTP {resp.status}"
|
|
357
|
+
)
|
|
358
|
+
collected.append(
|
|
359
|
+
FinalizeUploadPart(
|
|
360
|
+
part_number=i + 1,
|
|
361
|
+
e_tag=resp.headers["ETag"],
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
parts = collected
|
|
365
|
+
finally:
|
|
366
|
+
http.clear()
|
|
367
|
+
try:
|
|
368
|
+
finalized = self.uploads().finalize_upload_handler(
|
|
369
|
+
upload_id=session.upload_id,
|
|
370
|
+
x_upload_finalize_token=session.finalize_token,
|
|
371
|
+
finalize_upload_request=FinalizeUploadRequest(parts=parts),
|
|
372
|
+
)
|
|
373
|
+
except ApiException as e:
|
|
374
|
+
raise RuntimeError(api_error_message(e)) from e
|
|
375
|
+
return finalized.upload_id
|
|
376
|
+
|
|
377
|
+
def _upload_parquet_post(self, path: str) -> str:
|
|
378
|
+
"""Fallback for storage backends that do not support presigned URLs (501)."""
|
|
298
379
|
with open(path, "rb") as f:
|
|
299
380
|
data = f.read()
|
|
300
381
|
try:
|
|
@@ -314,6 +395,7 @@ class HotdataClient:
|
|
|
314
395
|
schema: str = DEFAULT_SCHEMA,
|
|
315
396
|
upload_id: str | None = None,
|
|
316
397
|
file: str | None = None,
|
|
398
|
+
mode: ManagedLoadMode = "replace",
|
|
317
399
|
) -> LoadManagedTableResult:
|
|
318
400
|
if (upload_id is None) == (file is None):
|
|
319
401
|
raise ValueError("Exactly one of upload_id or file is required")
|
|
@@ -324,7 +406,7 @@ class HotdataClient:
|
|
|
324
406
|
assert file is not None
|
|
325
407
|
resolved_upload_id = self.upload_parquet(file)
|
|
326
408
|
request = LoadManagedTableRequest(
|
|
327
|
-
mode=
|
|
409
|
+
mode=mode,
|
|
328
410
|
upload_id=resolved_upload_id,
|
|
329
411
|
)
|
|
330
412
|
try:
|
|
@@ -350,15 +432,17 @@ class HotdataClient:
|
|
|
350
432
|
table: str,
|
|
351
433
|
*,
|
|
352
434
|
schema: str = DEFAULT_SCHEMA,
|
|
435
|
+
key: list[str] | None = None,
|
|
353
436
|
) -> ManagedTable:
|
|
354
437
|
"""Declare a new table on an existing managed database.
|
|
355
438
|
|
|
356
439
|
The table is added empty (declared-but-unloaded); populate it with
|
|
357
440
|
:meth:`load_managed_table`. Use this to evolve a managed database's
|
|
358
|
-
schema after creation without recreating it.
|
|
441
|
+
schema after creation without recreating it. ``key`` sets the
|
|
442
|
+
row-identity columns for delete/update/upsert; omit for keyless.
|
|
359
443
|
"""
|
|
360
444
|
db = self.resolve_managed_database(database)
|
|
361
|
-
request = AddManagedTableRequest(name=table)
|
|
445
|
+
request = AddManagedTableRequest(name=table, key=list(key or []))
|
|
362
446
|
try:
|
|
363
447
|
self._databases_api().add_database_table(db.id, schema, request)
|
|
364
448
|
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,
|
|
@@ -203,23 +204,30 @@ class ManagedDatabaseClient:
|
|
|
203
204
|
*,
|
|
204
205
|
schema: str,
|
|
205
206
|
upload_id: str,
|
|
207
|
+
mode: ManagedLoadMode = "replace",
|
|
206
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.
|
|
207
212
|
return self._request_with_retry(
|
|
208
213
|
lambda: self._runtime.load_managed_table(
|
|
209
214
|
database,
|
|
210
215
|
table,
|
|
211
216
|
schema=schema,
|
|
212
217
|
upload_id=upload_id,
|
|
213
|
-
|
|
218
|
+
mode=mode,
|
|
219
|
+
),
|
|
220
|
+
retryable=(mode != "append"),
|
|
214
221
|
)
|
|
215
222
|
|
|
216
|
-
def _request_with_retry(self, operation: Callable[[], T]) -> T:
|
|
217
|
-
|
|
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):
|
|
218
226
|
try:
|
|
219
227
|
return operation()
|
|
220
228
|
except Exception as error:
|
|
221
229
|
mapped_error = classify_sdk_error(error.__cause__ or error)
|
|
222
|
-
if isinstance(mapped_error, HotdataTransientError) and attempt <
|
|
230
|
+
if isinstance(mapped_error, HotdataTransientError) and attempt < max_attempts:
|
|
223
231
|
backoff = min(self._retry_backoff_seconds * attempt, self._MAX_BACKOFF_SECONDS)
|
|
224
232
|
time.sleep(backoff)
|
|
225
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.1"
|
|
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
|
]
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import io
|
|
3
4
|
from types import SimpleNamespace
|
|
4
|
-
from unittest.mock import mock_open, patch
|
|
5
|
+
from unittest.mock import MagicMock, mock_open, patch
|
|
5
6
|
|
|
6
7
|
import pytest
|
|
7
8
|
from hotdata.exceptions import ApiException
|
|
9
|
+
from hotdata.models.database_default_table_decl import DatabaseDefaultTableDecl
|
|
8
10
|
|
|
9
11
|
from hotdata_framework.client import HotdataClient
|
|
10
12
|
from hotdata_framework.databases import (
|
|
@@ -13,6 +15,20 @@ from hotdata_framework.databases import (
|
|
|
13
15
|
)
|
|
14
16
|
|
|
15
17
|
|
|
18
|
+
def _decl_key_supported() -> bool:
|
|
19
|
+
# `key` ships with the regenerated client; the key tests activate once it does.
|
|
20
|
+
try:
|
|
21
|
+
return DatabaseDefaultTableDecl(name="t", key=["k"]).key == ["k"]
|
|
22
|
+
except Exception:
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
requires_key_field = pytest.mark.skipif(
|
|
27
|
+
not _decl_key_supported(),
|
|
28
|
+
reason="hotdata client without `key` on managed-table decls",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
16
32
|
def _client() -> HotdataClient:
|
|
17
33
|
return HotdataClient("k", "ws", host="https://api.hotdata.dev")
|
|
18
34
|
|
|
@@ -179,16 +195,105 @@ def test_upload_parquet_rejects_non_parquet():
|
|
|
179
195
|
client.upload_parquet("/tmp/data.csv")
|
|
180
196
|
|
|
181
197
|
|
|
182
|
-
def
|
|
198
|
+
def _mock_open_bytes(data: bytes) -> MagicMock:
|
|
199
|
+
"""Return an open() mock whose file handle supports read(n) via BytesIO."""
|
|
200
|
+
bio = io.BytesIO(data)
|
|
201
|
+
m = MagicMock()
|
|
202
|
+
m.__enter__ = lambda s: bio
|
|
203
|
+
m.__exit__ = MagicMock(return_value=False)
|
|
204
|
+
return MagicMock(return_value=m)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _session(mode: str, **kw) -> SimpleNamespace:
|
|
208
|
+
defaults = dict(
|
|
209
|
+
upload_id="upl_sess",
|
|
210
|
+
finalize_token="tok",
|
|
211
|
+
headers={},
|
|
212
|
+
part_size=None,
|
|
213
|
+
part_urls=None,
|
|
214
|
+
url=None,
|
|
215
|
+
)
|
|
216
|
+
return SimpleNamespace(mode=mode, **{**defaults, **kw})
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _http_resp(status: int = 200, etag: str = '"abc"') -> SimpleNamespace:
|
|
220
|
+
return SimpleNamespace(status=status, headers={"ETag": etag})
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_upload_parquet_multipart():
|
|
224
|
+
client = _client()
|
|
225
|
+
data = b"PAR1" + b"\x00" * 6 # 10 bytes -> 2 parts of 5
|
|
226
|
+
session = _session("multipart", part_size=5, part_urls=["https://s/1", "https://s/2"])
|
|
227
|
+
finalized = SimpleNamespace(upload_id="upl_final")
|
|
228
|
+
|
|
229
|
+
with (
|
|
230
|
+
patch("builtins.open", _mock_open_bytes(data)),
|
|
231
|
+
patch("os.path.getsize", return_value=len(data)),
|
|
232
|
+
patch.object(client, "uploads") as uploads,
|
|
233
|
+
patch("hotdata_framework.client.urllib3.PoolManager") as MockPool,
|
|
234
|
+
):
|
|
235
|
+
pool = MockPool.return_value
|
|
236
|
+
pool.request.return_value = _http_resp()
|
|
237
|
+
pool.clear.return_value = None
|
|
238
|
+
uploads.return_value.create_upload_session_handler.return_value = session
|
|
239
|
+
uploads.return_value.finalize_upload_handler.return_value = finalized
|
|
240
|
+
|
|
241
|
+
upload_id = client.upload_parquet("/tmp/data.parquet")
|
|
242
|
+
|
|
243
|
+
assert upload_id == "upl_final"
|
|
244
|
+
assert pool.request.call_count == 2
|
|
245
|
+
finalize_call = uploads.return_value.finalize_upload_handler.call_args
|
|
246
|
+
assert finalize_call.kwargs["upload_id"] == "upl_sess"
|
|
247
|
+
assert finalize_call.kwargs["x_upload_finalize_token"] == "tok"
|
|
248
|
+
parts = finalize_call.kwargs["finalize_upload_request"].parts
|
|
249
|
+
assert len(parts) == 2
|
|
250
|
+
assert parts[0].part_number == 1
|
|
251
|
+
assert parts[1].part_number == 2
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_upload_parquet_single_put():
|
|
255
|
+
client = _client()
|
|
256
|
+
data = b"PAR1tiny"
|
|
257
|
+
session = _session("single", url="https://s/put")
|
|
258
|
+
finalized = SimpleNamespace(upload_id="upl_single")
|
|
259
|
+
|
|
260
|
+
with (
|
|
261
|
+
patch("builtins.open", mock_open(read_data=data)),
|
|
262
|
+
patch("os.path.getsize", return_value=len(data)),
|
|
263
|
+
patch.object(client, "uploads") as uploads,
|
|
264
|
+
patch("hotdata_framework.client.urllib3.PoolManager") as MockPool,
|
|
265
|
+
):
|
|
266
|
+
pool = MockPool.return_value
|
|
267
|
+
pool.request.return_value = _http_resp()
|
|
268
|
+
pool.clear.return_value = None
|
|
269
|
+
uploads.return_value.create_upload_session_handler.return_value = session
|
|
270
|
+
uploads.return_value.finalize_upload_handler.return_value = finalized
|
|
271
|
+
|
|
272
|
+
upload_id = client.upload_parquet("/tmp/data.parquet")
|
|
273
|
+
|
|
274
|
+
assert upload_id == "upl_single"
|
|
275
|
+
pool.request.assert_called_once()
|
|
276
|
+
call_args = pool.request.call_args
|
|
277
|
+
assert call_args.args[0] == "PUT"
|
|
278
|
+
assert call_args.args[1] == "https://s/put"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def test_upload_parquet_fallback_on_501():
|
|
183
282
|
client = _client()
|
|
184
|
-
uploaded = SimpleNamespace(id="
|
|
283
|
+
uploaded = SimpleNamespace(id="upl_post")
|
|
284
|
+
|
|
185
285
|
with (
|
|
186
286
|
patch("builtins.open", mock_open(read_data=b"PAR1")),
|
|
287
|
+
patch("os.path.getsize", return_value=4),
|
|
187
288
|
patch.object(client, "uploads") as uploads,
|
|
188
289
|
):
|
|
290
|
+
err = ApiException(status=501)
|
|
291
|
+
uploads.return_value.create_upload_session_handler.side_effect = err
|
|
189
292
|
uploads.return_value.upload_file.return_value = uploaded
|
|
293
|
+
|
|
190
294
|
upload_id = client.upload_parquet("/tmp/data.parquet")
|
|
191
|
-
|
|
295
|
+
|
|
296
|
+
assert upload_id == "upl_post"
|
|
192
297
|
|
|
193
298
|
|
|
194
299
|
def test_load_managed_table_with_upload_id():
|
|
@@ -230,6 +335,69 @@ def test_load_managed_table_requires_exactly_one_source():
|
|
|
230
335
|
)
|
|
231
336
|
|
|
232
337
|
|
|
338
|
+
def _load_and_capture_request(client, **kwargs):
|
|
339
|
+
db = managed_database_from_detail(_detail())
|
|
340
|
+
loaded = SimpleNamespace(
|
|
341
|
+
connection_id="conn_1", schema_name="public", table_name="orders", row_count=1
|
|
342
|
+
)
|
|
343
|
+
with (
|
|
344
|
+
patch.object(client, "resolve_managed_database", return_value=db),
|
|
345
|
+
patch.object(client, "connections") as connections,
|
|
346
|
+
):
|
|
347
|
+
connections.return_value.load_managed_table.return_value = loaded
|
|
348
|
+
client.load_managed_table("db_1", "orders", upload_id="upl_1", **kwargs)
|
|
349
|
+
return connections.return_value.load_managed_table.call_args.args[3]
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def test_load_managed_table_defaults_to_replace():
|
|
353
|
+
assert _load_and_capture_request(_client()).mode == "replace"
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@pytest.mark.parametrize("mode", ["append", "delete", "update", "upsert"])
|
|
357
|
+
def test_load_managed_table_forwards_mode(mode: str):
|
|
358
|
+
assert _load_and_capture_request(_client(), mode=mode).mode == mode
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@requires_key_field
|
|
362
|
+
def test_create_managed_database_declares_keys():
|
|
363
|
+
client = _client()
|
|
364
|
+
with patch.object(client, "_databases_api") as dbs:
|
|
365
|
+
dbs.return_value.create_database.return_value = _detail(id="db_new")
|
|
366
|
+
client.create_managed_database(
|
|
367
|
+
"mydb", tables=["orders", "events"], keys={"orders": ["id"]}
|
|
368
|
+
)
|
|
369
|
+
req = dbs.return_value.create_database.call_args.args[0]
|
|
370
|
+
declared = {t.name: list(t.key) for t in req.schemas[0].tables}
|
|
371
|
+
assert declared == {"orders": ["id"], "events": []}
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@requires_key_field
|
|
375
|
+
def test_add_managed_table_declares_key():
|
|
376
|
+
client = _client()
|
|
377
|
+
db = managed_database_from_detail(_detail())
|
|
378
|
+
with (
|
|
379
|
+
patch.object(client, "resolve_managed_database", return_value=db),
|
|
380
|
+
patch.object(client, "_databases_api") as dbs,
|
|
381
|
+
):
|
|
382
|
+
client.add_managed_table("db_1", "line_items", key=["order_id", "sku"])
|
|
383
|
+
req = dbs.return_value.add_database_table.call_args.args[2]
|
|
384
|
+
assert req.name == "line_items"
|
|
385
|
+
assert list(req.key) == ["order_id", "sku"]
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@requires_key_field
|
|
389
|
+
def test_add_managed_table_keyless_by_default():
|
|
390
|
+
client = _client()
|
|
391
|
+
db = managed_database_from_detail(_detail())
|
|
392
|
+
with (
|
|
393
|
+
patch.object(client, "resolve_managed_database", return_value=db),
|
|
394
|
+
patch.object(client, "_databases_api") as dbs,
|
|
395
|
+
):
|
|
396
|
+
client.add_managed_table("db_1", "orders")
|
|
397
|
+
req = dbs.return_value.add_database_table.call_args.args[2]
|
|
398
|
+
assert list(req.key) == []
|
|
399
|
+
|
|
400
|
+
|
|
233
401
|
def test_delete_managed_table_uses_default_connection_id():
|
|
234
402
|
client = _client()
|
|
235
403
|
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
|
|
@@ -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.1"
|
|
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.3 → hotdata_framework-0.7.1}/.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
|