hotdata-framework 0.7.0__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.7.0 → hotdata_framework-0.7.1}/CHANGELOG.md +6 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/PKG-INFO +1 -1
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/hotdata_framework/client.py +70 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/pyproject.toml +1 -1
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/tests/test_databases.py +94 -4
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/uv.lock +1 -1
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/.github/dependabot.yml +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/.github/workflows/check-release.yml +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/.github/workflows/ci.yml +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/.github/workflows/dependabot-automerge.yml +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/.github/workflows/publish.yml +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/.github/workflows/release.yml +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/.gitignore +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/CONTRACT.md +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/README.md +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/RELEASING.md +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/examples/basic_usage.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/hotdata_framework/__init__.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/hotdata_framework/databases.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/hotdata_framework/env.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/hotdata_framework/errors.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/hotdata_framework/health.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/hotdata_framework/http.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/hotdata_framework/managed_client.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/hotdata_framework/py.typed +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/hotdata_framework/result.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/scripts/check-release.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/scripts/extract-changelog.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/scripts/publish-workflow.sh +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/scripts/release.sh +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/scripts/update_changelog.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/tests/test_client.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/tests/test_contract.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/tests/test_errors.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/tests/test_health.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/tests/test_managed_client.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/tests/test_request_timeout.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/tests/test_result.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/tests/test_update_changelog.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/tests/test_version.py +0 -0
|
@@ -8,6 +8,12 @@ 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
|
+
|
|
11
17
|
## [0.7.0] - 2026-07-14
|
|
12
18
|
|
|
13
19
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hotdata-framework
|
|
3
|
-
Version: 0.7.
|
|
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
|
|
@@ -1,7 +1,9 @@
|
|
|
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
9
|
from typing import Any, Literal
|
|
@@ -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
|
|
@@ -306,6 +311,71 @@ class HotdataClient:
|
|
|
306
311
|
def upload_parquet(self, path: str) -> str:
|
|
307
312
|
if not is_parquet_path(path):
|
|
308
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)."""
|
|
309
379
|
with open(path, "rb") as f:
|
|
310
380
|
data = f.read()
|
|
311
381
|
try:
|
|
@@ -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.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"
|
|
@@ -1,7 +1,8 @@
|
|
|
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
|
|
@@ -194,16 +195,105 @@ def test_upload_parquet_rejects_non_parquet():
|
|
|
194
195
|
client.upload_parquet("/tmp/data.csv")
|
|
195
196
|
|
|
196
197
|
|
|
197
|
-
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():
|
|
198
255
|
client = _client()
|
|
199
|
-
|
|
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():
|
|
282
|
+
client = _client()
|
|
283
|
+
uploaded = SimpleNamespace(id="upl_post")
|
|
284
|
+
|
|
200
285
|
with (
|
|
201
286
|
patch("builtins.open", mock_open(read_data=b"PAR1")),
|
|
287
|
+
patch("os.path.getsize", return_value=4),
|
|
202
288
|
patch.object(client, "uploads") as uploads,
|
|
203
289
|
):
|
|
290
|
+
err = ApiException(status=501)
|
|
291
|
+
uploads.return_value.create_upload_session_handler.side_effect = err
|
|
204
292
|
uploads.return_value.upload_file.return_value = uploaded
|
|
293
|
+
|
|
205
294
|
upload_id = client.upload_parquet("/tmp/data.parquet")
|
|
206
|
-
|
|
295
|
+
|
|
296
|
+
assert upload_id == "upl_post"
|
|
207
297
|
|
|
208
298
|
|
|
209
299
|
def test_load_managed_table_with_upload_id():
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hotdata_framework-0.7.0 → 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
|
|
File without changes
|
|
File without changes
|