hotdata-framework 0.7.1__tar.gz → 0.7.3__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.1 → hotdata_framework-0.7.3}/CHANGELOG.md +16 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/PKG-INFO +1 -1
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/hotdata_framework/client.py +20 -76
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/hotdata_framework/errors.py +4 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/pyproject.toml +1 -1
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/tests/test_databases.py +38 -83
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/uv.lock +1 -1
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/.github/dependabot.yml +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/.github/workflows/check-release.yml +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/.github/workflows/ci.yml +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/.github/workflows/dependabot-automerge.yml +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/.github/workflows/publish.yml +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/.github/workflows/release.yml +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/.gitignore +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/CONTRACT.md +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/README.md +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/RELEASING.md +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/examples/basic_usage.py +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/hotdata_framework/__init__.py +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/hotdata_framework/databases.py +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/hotdata_framework/env.py +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/hotdata_framework/health.py +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/hotdata_framework/http.py +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/hotdata_framework/managed_client.py +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/hotdata_framework/py.typed +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/hotdata_framework/result.py +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/scripts/check-release.py +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/scripts/extract-changelog.py +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/scripts/publish-workflow.sh +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/scripts/release.sh +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/scripts/update_changelog.py +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/tests/test_client.py +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/tests/test_contract.py +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/tests/test_errors.py +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/tests/test_health.py +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/tests/test_managed_client.py +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/tests/test_request_timeout.py +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/tests/test_result.py +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/tests/test_update_changelog.py +0 -0
- {hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/tests/test_version.py +0 -0
|
@@ -8,6 +8,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
10
|
|
|
11
|
+
## [0.7.3] - 2026-07-16
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- `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.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- `classify_sdk_error` now classifies HTTP 501 (Not Implemented) as terminal instead of transient — a permanent capability gap must not burn retries.
|
|
20
|
+
|
|
21
|
+
## [0.7.2] - 2026-07-15
|
|
22
|
+
|
|
23
|
+
### Removed
|
|
24
|
+
|
|
25
|
+
- The `POST /v1/files` fallback in `upload_parquet()`. Presigned upload sessions (`POST /v1/uploads`) are now required; a server that responds 501 raises a clear `RuntimeError` instead of silently falling back to the full-file-in-memory upload path.
|
|
26
|
+
|
|
11
27
|
## [0.7.1] - 2026-07-15
|
|
12
28
|
|
|
13
29
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hotdata-framework
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.3
|
|
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
|
|
@@ -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,84 +306,31 @@ 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
|
-
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),
|
|
320
|
+
finalized = self.uploads().upload_file(
|
|
321
|
+
path, content_type="application/octet-stream"
|
|
372
322
|
)
|
|
373
323
|
except ApiException as e:
|
|
374
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
|
|
375
332
|
return finalized.upload_id
|
|
376
333
|
|
|
377
|
-
def _upload_parquet_post(self, path: str) -> str:
|
|
378
|
-
"""Fallback for storage backends that do not support presigned URLs (501)."""
|
|
379
|
-
with open(path, "rb") as f:
|
|
380
|
-
data = f.read()
|
|
381
|
-
try:
|
|
382
|
-
uploaded = self.uploads().upload_file(
|
|
383
|
-
data,
|
|
384
|
-
_content_type="application/octet-stream",
|
|
385
|
-
)
|
|
386
|
-
except ApiException as e:
|
|
387
|
-
raise RuntimeError(api_error_message(e)) from e
|
|
388
|
-
return uploaded.id
|
|
389
|
-
|
|
390
334
|
def load_managed_table(
|
|
391
335
|
self,
|
|
392
336
|
database: str,
|
|
@@ -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)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hotdata-framework"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.3"
|
|
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"
|
|
@@ -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,105 +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_fallback_on_501():
|
|
282
233
|
client = _client()
|
|
283
|
-
|
|
234
|
+
sdk_err = StorageError(status=503, part_number=3, body="upstream unavailable")
|
|
284
235
|
|
|
285
|
-
with (
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
):
|
|
290
|
-
err = ApiException(status=501)
|
|
291
|
-
uploads.return_value.create_upload_session_handler.side_effect = err
|
|
292
|
-
uploads.return_value.upload_file.return_value = uploaded
|
|
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"):
|
|
239
|
+
client.upload_parquet("/tmp/data.parquet")
|
|
293
240
|
|
|
294
|
-
upload_id = client.upload_parquet("/tmp/data.parquet")
|
|
295
241
|
|
|
296
|
-
|
|
242
|
+
def test_classify_501_is_terminal():
|
|
243
|
+
from hotdata_framework.errors import (
|
|
244
|
+
HotdataTerminalError,
|
|
245
|
+
HotdataTransientError,
|
|
246
|
+
classify_sdk_error,
|
|
247
|
+
)
|
|
248
|
+
|
|
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)
|
|
297
252
|
|
|
298
253
|
|
|
299
254
|
def test_load_managed_table_with_upload_id():
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hotdata_framework-0.7.1 → hotdata_framework-0.7.3}/.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
|
|
File without changes
|