hotdata-framework 0.7.2__tar.gz → 0.8.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.7.2 → hotdata_framework-0.8.0}/CHANGELOG.md +19 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/PKG-INFO +2 -2
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/hotdata_framework/client.py +22 -68
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/hotdata_framework/errors.py +4 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/hotdata_framework/managed_client.py +6 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/pyproject.toml +2 -2
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/tests/test_databases.py +45 -118
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/tests/test_managed_client.py +38 -1
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/uv.lock +5 -5
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/.github/dependabot.yml +0 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/.github/workflows/check-release.yml +0 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/.github/workflows/ci.yml +0 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/.github/workflows/dependabot-automerge.yml +0 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/.github/workflows/publish.yml +0 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/.github/workflows/release.yml +0 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/.gitignore +0 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/CONTRACT.md +0 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/README.md +0 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/RELEASING.md +0 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/examples/basic_usage.py +0 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/hotdata_framework/__init__.py +0 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/hotdata_framework/databases.py +0 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/hotdata_framework/env.py +0 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/hotdata_framework/health.py +0 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/hotdata_framework/http.py +0 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/hotdata_framework/py.typed +0 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/hotdata_framework/result.py +0 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/scripts/check-release.py +0 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/scripts/extract-changelog.py +0 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/scripts/publish-workflow.sh +0 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/scripts/release.sh +0 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/scripts/update_changelog.py +0 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/tests/test_client.py +0 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/tests/test_contract.py +0 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/tests/test_errors.py +0 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/tests/test_health.py +0 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/tests/test_request_timeout.py +0 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/tests/test_result.py +0 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/tests/test_update_changelog.py +0 -0
- {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/tests/test_version.py +0 -0
|
@@ -8,6 +8,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
10
|
|
|
11
|
+
## [0.8.0] - 2026-07-20
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- `load_managed_table` accepts a `key` argument — the merge key columns for
|
|
16
|
+
`delete`/`update`/`upsert` loads, matched per-load instead of requiring a key
|
|
17
|
+
declared at table creation. Omit it to use the table's declared key; ignored
|
|
18
|
+
for `replace`/`append`. Requires `hotdata>=0.8.0`.
|
|
19
|
+
|
|
20
|
+
## [0.7.3] - 2026-07-16
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- `upload_parquet()` now delegates to the SDK's `hotdata.uploads.UploadsApi.upload_file()` instead of hand-rolling the session → PUT → finalize flow. Uploads gain concurrent part PUTs under a peak-memory budget, per-part retries, and ETag/size validation, making large uploads substantially faster. Errors still surface as `RuntimeError` with the underlying `ApiException` as the direct cause.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- `classify_sdk_error` now classifies HTTP 501 (Not Implemented) as terminal instead of transient — a permanent capability gap must not burn retries.
|
|
29
|
+
|
|
11
30
|
## [0.7.2] - 2026-07-15
|
|
12
31
|
|
|
13
32
|
### Removed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hotdata-framework
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.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.8.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,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import functools
|
|
4
|
-
import os
|
|
5
4
|
import time
|
|
6
|
-
import urllib3
|
|
7
5
|
from collections.abc import Iterator
|
|
8
6
|
from dataclasses import asdict, dataclass
|
|
9
7
|
from typing import Any, Literal
|
|
@@ -15,16 +13,15 @@ from hotdata.api.information_schema_api import InformationSchemaApi
|
|
|
15
13
|
from hotdata.api.query_api import QueryApi
|
|
16
14
|
from hotdata.api.query_runs_api import QueryRunsApi
|
|
17
15
|
from hotdata.api.results_api import ResultsApi
|
|
18
|
-
|
|
16
|
+
# The enriched wrapper (hotdata.uploads), NOT the generated hotdata.api class:
|
|
17
|
+
# it adds the full upload_file orchestration used by upload_parquet.
|
|
18
|
+
from hotdata.uploads import UploadError, UploadsApi
|
|
19
19
|
from hotdata.exceptions import ApiException
|
|
20
20
|
from hotdata.models.add_managed_table_request import AddManagedTableRequest
|
|
21
21
|
from hotdata.models.async_query_response import AsyncQueryResponse
|
|
22
22
|
from hotdata.models.create_database_request import CreateDatabaseRequest
|
|
23
23
|
from hotdata.models.database_default_schema_decl import DatabaseDefaultSchemaDecl
|
|
24
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
|
|
28
25
|
from hotdata.models.load_managed_table_request import LoadManagedTableRequest
|
|
29
26
|
from hotdata.models.query_request import QueryRequest
|
|
30
27
|
from hotdata.models.query_response import QueryResponse
|
|
@@ -309,74 +306,29 @@ class HotdataClient:
|
|
|
309
306
|
return rows
|
|
310
307
|
|
|
311
308
|
def upload_parquet(self, path: str) -> str:
|
|
309
|
+
"""Upload a parquet file via the SDK's upload orchestration.
|
|
310
|
+
|
|
311
|
+
``UploadsApi.upload_file`` owns the whole session -> storage PUT ->
|
|
312
|
+
finalize flow: concurrent part uploads under a peak-memory budget,
|
|
313
|
+
per-part retries, and ETag/size validation. Errors surface with the
|
|
314
|
+
underlying ``ApiException`` as the direct cause when there is one, so
|
|
315
|
+
retry classification keeps seeing the status code.
|
|
316
|
+
"""
|
|
312
317
|
if not is_parquet_path(path):
|
|
313
318
|
raise ValueError(f"Managed table loads require a parquet file (got {path!r})")
|
|
314
|
-
file_size = os.path.getsize(path)
|
|
315
319
|
try:
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
raise RuntimeError(
|
|
325
|
-
"the server does not support presigned uploads (HTTP 501); "
|
|
326
|
-
"managed table loads require a storage backend that can issue "
|
|
327
|
-
"presigned URLs (the POST /v1/files fallback was removed in "
|
|
328
|
-
"hotdata-framework 0.7.2)"
|
|
329
|
-
) from e
|
|
330
|
-
raise RuntimeError(api_error_message(e)) from e
|
|
331
|
-
http = urllib3.PoolManager()
|
|
332
|
-
parts: list[FinalizeUploadPart] | None = None
|
|
333
|
-
try:
|
|
334
|
-
if session.mode == "single":
|
|
335
|
-
with open(path, "rb") as f:
|
|
336
|
-
data = f.read()
|
|
337
|
-
resp = http.request(
|
|
338
|
-
"PUT",
|
|
339
|
-
session.url,
|
|
340
|
-
body=data,
|
|
341
|
-
headers={"Content-Length": str(file_size), **session.headers},
|
|
342
|
-
)
|
|
343
|
-
if resp.status not in (200, 201, 204):
|
|
344
|
-
raise RuntimeError(f"Storage PUT failed: HTTP {resp.status}")
|
|
345
|
-
else:
|
|
346
|
-
collected: list[FinalizeUploadPart] = []
|
|
347
|
-
with open(path, "rb") as f:
|
|
348
|
-
for i, part_url in enumerate(session.part_urls):
|
|
349
|
-
chunk = f.read(session.part_size)
|
|
350
|
-
resp = http.request(
|
|
351
|
-
"PUT",
|
|
352
|
-
part_url,
|
|
353
|
-
body=chunk,
|
|
354
|
-
headers={
|
|
355
|
-
"Content-Length": str(len(chunk)),
|
|
356
|
-
**session.headers,
|
|
357
|
-
},
|
|
358
|
-
)
|
|
359
|
-
if resp.status not in (200, 201, 204):
|
|
360
|
-
raise RuntimeError(
|
|
361
|
-
f"Part {i + 1} PUT failed: HTTP {resp.status}"
|
|
362
|
-
)
|
|
363
|
-
collected.append(
|
|
364
|
-
FinalizeUploadPart(
|
|
365
|
-
part_number=i + 1,
|
|
366
|
-
e_tag=resp.headers["ETag"],
|
|
367
|
-
)
|
|
368
|
-
)
|
|
369
|
-
parts = collected
|
|
370
|
-
finally:
|
|
371
|
-
http.clear()
|
|
372
|
-
try:
|
|
373
|
-
finalized = self.uploads().finalize_upload_handler(
|
|
374
|
-
upload_id=session.upload_id,
|
|
375
|
-
x_upload_finalize_token=session.finalize_token,
|
|
376
|
-
finalize_upload_request=FinalizeUploadRequest(parts=parts),
|
|
320
|
+
finalized = self.uploads().upload_file(
|
|
321
|
+
path, content_type="application/octet-stream"
|
|
377
322
|
)
|
|
378
323
|
except ApiException as e:
|
|
379
324
|
raise RuntimeError(api_error_message(e)) from e
|
|
325
|
+
except UploadError as e:
|
|
326
|
+
node: BaseException | None = e
|
|
327
|
+
while node is not None and not isinstance(node, ApiException):
|
|
328
|
+
node = node.__cause__
|
|
329
|
+
if isinstance(node, ApiException):
|
|
330
|
+
raise RuntimeError(api_error_message(node)) from node
|
|
331
|
+
raise RuntimeError(str(e)) from e
|
|
380
332
|
return finalized.upload_id
|
|
381
333
|
|
|
382
334
|
def load_managed_table(
|
|
@@ -388,6 +340,7 @@ class HotdataClient:
|
|
|
388
340
|
upload_id: str | None = None,
|
|
389
341
|
file: str | None = None,
|
|
390
342
|
mode: ManagedLoadMode = "replace",
|
|
343
|
+
key: list[str] | None = None,
|
|
391
344
|
) -> LoadManagedTableResult:
|
|
392
345
|
if (upload_id is None) == (file is None):
|
|
393
346
|
raise ValueError("Exactly one of upload_id or file is required")
|
|
@@ -400,6 +353,7 @@ class HotdataClient:
|
|
|
400
353
|
request = LoadManagedTableRequest(
|
|
401
354
|
mode=mode,
|
|
402
355
|
upload_id=resolved_upload_id,
|
|
356
|
+
key=key,
|
|
403
357
|
)
|
|
404
358
|
try:
|
|
405
359
|
loaded = self.connections().load_managed_table(
|
|
@@ -30,6 +30,10 @@ def classify_sdk_error(error: Exception) -> HotdataError:
|
|
|
30
30
|
message = f"{message} — {' '.join(str(body).split())[:500]}"
|
|
31
31
|
if status_code in (408, 409, 425, 429):
|
|
32
32
|
return HotdataTransientError(message)
|
|
33
|
+
if status_code == 501:
|
|
34
|
+
# Not Implemented is a permanent capability gap (e.g. the storage
|
|
35
|
+
# backend cannot issue presigned URLs) — retrying cannot succeed.
|
|
36
|
+
return HotdataTerminalError(message)
|
|
33
37
|
if 500 <= status_code <= 599:
|
|
34
38
|
return HotdataTransientError(message)
|
|
35
39
|
return HotdataTerminalError(message)
|
|
@@ -205,10 +205,15 @@ class ManagedDatabaseClient:
|
|
|
205
205
|
schema: str,
|
|
206
206
|
upload_id: str,
|
|
207
207
|
mode: ManagedLoadMode = "replace",
|
|
208
|
+
key: list[str] | None = None,
|
|
208
209
|
) -> LoadManagedTableResult:
|
|
209
210
|
# append is the only non-idempotent mode: if the server commits the load
|
|
210
211
|
# but the response is lost, a retry re-appends the same rows. Run it
|
|
211
212
|
# at-most-once; every other mode is safe to retry.
|
|
213
|
+
#
|
|
214
|
+
# `key` is the merge key for delete/update/upsert loads: when set it is
|
|
215
|
+
# matched per-load instead of a key declared at table creation. Omit it
|
|
216
|
+
# to use the table's declared key. Ignored for replace/append.
|
|
212
217
|
return self._request_with_retry(
|
|
213
218
|
lambda: self._runtime.load_managed_table(
|
|
214
219
|
database,
|
|
@@ -216,6 +221,7 @@ class ManagedDatabaseClient:
|
|
|
216
221
|
schema=schema,
|
|
217
222
|
upload_id=upload_id,
|
|
218
223
|
mode=mode,
|
|
224
|
+
key=key,
|
|
219
225
|
),
|
|
220
226
|
retryable=(mode != "append"),
|
|
221
227
|
)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hotdata-framework"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.8.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"
|
|
@@ -27,7 +27,7 @@ classifiers = [
|
|
|
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.
|
|
30
|
+
"hotdata>=0.8.0",
|
|
31
31
|
"pandas>=2.0",
|
|
32
32
|
"pyarrow>=14.0",
|
|
33
33
|
]
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import io
|
|
4
3
|
from types import SimpleNamespace
|
|
5
|
-
from unittest.mock import
|
|
4
|
+
from unittest.mock import patch
|
|
6
5
|
|
|
7
6
|
import pytest
|
|
8
7
|
from hotdata.exceptions import ApiException
|
|
@@ -195,143 +194,61 @@ def test_upload_parquet_rejects_non_parquet():
|
|
|
195
194
|
client.upload_parquet("/tmp/data.csv")
|
|
196
195
|
|
|
197
196
|
|
|
198
|
-
def
|
|
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():
|
|
197
|
+
def test_upload_parquet_delegates_to_sdk_upload_file():
|
|
224
198
|
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
199
|
finalized = SimpleNamespace(upload_id="upl_final")
|
|
228
200
|
|
|
229
|
-
with (
|
|
230
|
-
|
|
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
|
-
|
|
201
|
+
with patch.object(client, "uploads") as uploads:
|
|
202
|
+
uploads.return_value.upload_file.return_value = finalized
|
|
241
203
|
upload_id = client.upload_parquet("/tmp/data.parquet")
|
|
242
204
|
|
|
243
205
|
assert upload_id == "upl_final"
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
206
|
+
uploads.return_value.upload_file.assert_called_once_with(
|
|
207
|
+
"/tmp/data.parquet", content_type="application/octet-stream"
|
|
208
|
+
)
|
|
209
|
+
|
|
252
210
|
|
|
211
|
+
def test_upload_parquet_surfaces_api_cause_from_upload_error():
|
|
212
|
+
# SessionCreateError et al. wrap the ApiException as __cause__; the wrapper
|
|
213
|
+
# must re-raise with that ApiException as the DIRECT cause so
|
|
214
|
+
# classify_sdk_error keeps seeing the status code.
|
|
215
|
+
from hotdata.uploads import SessionCreateError
|
|
253
216
|
|
|
254
|
-
def test_upload_parquet_single_put():
|
|
255
217
|
client = _client()
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
218
|
+
api_exc = ApiException(status=501)
|
|
219
|
+
sdk_err = SessionCreateError("opening the upload session failed")
|
|
220
|
+
sdk_err.__cause__ = api_exc
|
|
259
221
|
|
|
260
|
-
with (
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
222
|
+
with patch.object(client, "uploads") as uploads:
|
|
223
|
+
uploads.return_value.upload_file.side_effect = sdk_err
|
|
224
|
+
with pytest.raises(RuntimeError) as exc_info:
|
|
225
|
+
client.upload_parquet("/tmp/data.parquet")
|
|
271
226
|
|
|
272
|
-
|
|
227
|
+
assert exc_info.value.__cause__ is api_exc
|
|
273
228
|
|
|
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
229
|
|
|
230
|
+
def test_upload_parquet_wraps_plain_upload_error():
|
|
231
|
+
from hotdata.uploads import StorageError
|
|
280
232
|
|
|
281
|
-
def test_upload_parquet_raises_on_501():
|
|
282
233
|
client = _client()
|
|
234
|
+
sdk_err = StorageError(status=503, part_number=3, body="upstream unavailable")
|
|
283
235
|
|
|
284
|
-
with (
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
patch.object(client, "uploads") as uploads,
|
|
288
|
-
):
|
|
289
|
-
err = ApiException(status=501)
|
|
290
|
-
uploads.return_value.create_upload_session_handler.side_effect = err
|
|
291
|
-
|
|
292
|
-
with pytest.raises(RuntimeError, match="presigned uploads"):
|
|
236
|
+
with patch.object(client, "uploads") as uploads:
|
|
237
|
+
uploads.return_value.upload_file.side_effect = sdk_err
|
|
238
|
+
with pytest.raises(RuntimeError, match="503"):
|
|
293
239
|
client.upload_parquet("/tmp/data.parquet")
|
|
294
240
|
|
|
295
|
-
uploads.return_value.upload_file.assert_not_called()
|
|
296
241
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
data = b"PAR1" + b"\x00" * 9 # 13 bytes -> parts of 5, 5, 3
|
|
303
|
-
read_sizes: list[int] = []
|
|
304
|
-
|
|
305
|
-
class _TrackingFile(io.BytesIO):
|
|
306
|
-
def read(self, n=-1):
|
|
307
|
-
read_sizes.append(n)
|
|
308
|
-
return super().read(n)
|
|
309
|
-
|
|
310
|
-
bio = _TrackingFile(data)
|
|
311
|
-
handle = MagicMock()
|
|
312
|
-
handle.__enter__ = lambda s: bio
|
|
313
|
-
handle.__exit__ = MagicMock(return_value=False)
|
|
314
|
-
session = _session(
|
|
315
|
-
"multipart", part_size=part_size, part_urls=["https://s/1", "https://s/2", "https://s/3"]
|
|
242
|
+
def test_classify_501_is_terminal():
|
|
243
|
+
from hotdata_framework.errors import (
|
|
244
|
+
HotdataTerminalError,
|
|
245
|
+
HotdataTransientError,
|
|
246
|
+
classify_sdk_error,
|
|
316
247
|
)
|
|
317
|
-
finalized = SimpleNamespace(upload_id="upl_final")
|
|
318
248
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
patch.object(client, "uploads") as uploads,
|
|
323
|
-
patch("hotdata_framework.client.urllib3.PoolManager") as MockPool,
|
|
324
|
-
):
|
|
325
|
-
pool = MockPool.return_value
|
|
326
|
-
pool.request.return_value = _http_resp()
|
|
327
|
-
pool.clear.return_value = None
|
|
328
|
-
uploads.return_value.create_upload_session_handler.return_value = session
|
|
329
|
-
uploads.return_value.finalize_upload_handler.return_value = finalized
|
|
330
|
-
|
|
331
|
-
client.upload_parquet("/tmp/data.parquet")
|
|
332
|
-
|
|
333
|
-
assert read_sizes, "expected chunked reads"
|
|
334
|
-
assert all(n == part_size for n in read_sizes)
|
|
249
|
+
assert isinstance(classify_sdk_error(ApiException(status=501)), HotdataTerminalError)
|
|
250
|
+
# The rest of 5xx stays transient.
|
|
251
|
+
assert isinstance(classify_sdk_error(ApiException(status=503)), HotdataTransientError)
|
|
335
252
|
|
|
336
253
|
|
|
337
254
|
def test_load_managed_table_with_upload_id():
|
|
@@ -391,6 +308,16 @@ def test_load_managed_table_defaults_to_replace():
|
|
|
391
308
|
assert _load_and_capture_request(_client()).mode == "replace"
|
|
392
309
|
|
|
393
310
|
|
|
311
|
+
def test_load_managed_table_passes_key():
|
|
312
|
+
request = _load_and_capture_request(_client(), mode="delete", key=["id"])
|
|
313
|
+
assert request.mode == "delete"
|
|
314
|
+
assert request.key == ["id"]
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test_load_managed_table_key_defaults_to_none():
|
|
318
|
+
assert _load_and_capture_request(_client()).key is None
|
|
319
|
+
|
|
320
|
+
|
|
394
321
|
@pytest.mark.parametrize("mode", ["append", "delete", "update", "upsert"])
|
|
395
322
|
def test_load_managed_table_forwards_mode(mode: str):
|
|
396
323
|
assert _load_and_capture_request(_client(), mode=mode).mode == mode
|
|
@@ -163,7 +163,13 @@ def _load_recording_runtime(calls: list[str]) -> SimpleNamespace:
|
|
|
163
163
|
with a transient error, so retry behaviour is observable via ``calls``."""
|
|
164
164
|
|
|
165
165
|
def load_managed_table(
|
|
166
|
-
database: str,
|
|
166
|
+
database: str,
|
|
167
|
+
table: str,
|
|
168
|
+
*,
|
|
169
|
+
schema: str,
|
|
170
|
+
upload_id: str,
|
|
171
|
+
mode: str,
|
|
172
|
+
key: list[str] | None = None,
|
|
167
173
|
) -> SimpleNamespace:
|
|
168
174
|
calls.append(mode)
|
|
169
175
|
raise TimeoutError("commit succeeded but response was lost")
|
|
@@ -209,3 +215,34 @@ def test_idempotent_load_retries_on_transient(monkeypatch: pytest.MonkeyPatch) -
|
|
|
209
215
|
client.load_managed_table("db", "orders", schema="public", upload_id="u1", mode="replace")
|
|
210
216
|
|
|
211
217
|
assert calls == ["replace", "replace", "replace"] # retried up to max_retries
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_load_managed_table_forwards_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
221
|
+
"""A per-load ``key`` is passed straight through to the runtime client."""
|
|
222
|
+
monkeypatch.setattr(mc.time, "sleep", lambda _seconds: None)
|
|
223
|
+
captured: dict[str, Any] = {}
|
|
224
|
+
|
|
225
|
+
def load_managed_table(
|
|
226
|
+
database: str,
|
|
227
|
+
table: str,
|
|
228
|
+
*,
|
|
229
|
+
schema: str,
|
|
230
|
+
upload_id: str,
|
|
231
|
+
mode: str,
|
|
232
|
+
key: list[str] | None = None,
|
|
233
|
+
) -> SimpleNamespace:
|
|
234
|
+
captured["mode"] = mode
|
|
235
|
+
captured["key"] = key
|
|
236
|
+
return SimpleNamespace(
|
|
237
|
+
connection_id="c", schema_name=schema, table_name=table, row_count=0
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
client = _managed_client(max_retries=1)
|
|
241
|
+
runtime = _fake_runtime()
|
|
242
|
+
runtime.load_managed_table = load_managed_table
|
|
243
|
+
client._runtime = runtime
|
|
244
|
+
|
|
245
|
+
client.load_managed_table(
|
|
246
|
+
"db", "orders", schema="public", upload_id="u1", mode="delete", key=["id"]
|
|
247
|
+
)
|
|
248
|
+
assert captured == {"mode": "delete", "key": ["id"]}
|
|
@@ -86,7 +86,7 @@ wheels = [
|
|
|
86
86
|
|
|
87
87
|
[[package]]
|
|
88
88
|
name = "hotdata"
|
|
89
|
-
version = "0.
|
|
89
|
+
version = "0.8.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/8a/38/30ed3d1d99413e7684672fa424baef85a347db465b90c774443320cf1cea/hotdata-0.8.0.tar.gz", hash = "sha256:cdac515ffa193ed028491e4b7abcd1b6404d4e3f96986c9c0a2b2f407db42eb6", size = 216848, upload-time = "2026-07-20T06:30:37.659Z" }
|
|
98
98
|
wheels = [
|
|
99
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
99
|
+
{ url = "https://files.pythonhosted.org/packages/bc/0f/f2ccaeb0f910c3f8cd84a911d934704277f0d6e5a20f8b49cf0eedb2a8aa/hotdata-0.8.0-py3-none-any.whl", hash = "sha256:5d64bfc185a0e7e2bf34ebdcea50787d3418fb250dacc53e00dc74d64e99f753", size = 316923, upload-time = "2026-07-20T06:30:35.778Z" },
|
|
100
100
|
]
|
|
101
101
|
|
|
102
102
|
[[package]]
|
|
103
103
|
name = "hotdata-framework"
|
|
104
|
-
version = "0.
|
|
104
|
+
version = "0.8.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.8.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.7.2 → hotdata_framework-0.8.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
|