hotdata-framework 0.7.0__tar.gz → 0.7.2__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.2}/CHANGELOG.md +12 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/PKG-INFO +1 -1
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/hotdata_framework/client.py +68 -6
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/pyproject.toml +1 -1
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/tests/test_databases.py +134 -6
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/uv.lock +1 -1
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/.github/dependabot.yml +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/.github/workflows/check-release.yml +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/.github/workflows/ci.yml +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/.github/workflows/dependabot-automerge.yml +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/.github/workflows/publish.yml +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/.github/workflows/release.yml +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/.gitignore +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/CONTRACT.md +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/README.md +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/RELEASING.md +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/examples/basic_usage.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/hotdata_framework/__init__.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/hotdata_framework/databases.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/hotdata_framework/env.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/hotdata_framework/errors.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/hotdata_framework/health.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/hotdata_framework/http.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/hotdata_framework/managed_client.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/hotdata_framework/py.typed +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/hotdata_framework/result.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/scripts/check-release.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/scripts/extract-changelog.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/scripts/publish-workflow.sh +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/scripts/release.sh +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/scripts/update_changelog.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/tests/test_client.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/tests/test_contract.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/tests/test_errors.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/tests/test_health.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/tests/test_managed_client.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/tests/test_request_timeout.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/tests/test_result.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/tests/test_update_changelog.py +0 -0
- {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/tests/test_version.py +0 -0
|
@@ -8,6 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
10
|
|
|
11
|
+
## [0.7.2] - 2026-07-15
|
|
12
|
+
|
|
13
|
+
### Removed
|
|
14
|
+
|
|
15
|
+
- 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.
|
|
16
|
+
|
|
17
|
+
## [0.7.1] - 2026-07-15
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- `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.
|
|
22
|
+
|
|
11
23
|
## [0.7.0] - 2026-07-14
|
|
12
24
|
|
|
13
25
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hotdata-framework
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
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,16 +311,73 @@ 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})")
|
|
309
|
-
|
|
310
|
-
data = f.read()
|
|
314
|
+
file_size = os.path.getsize(path)
|
|
311
315
|
try:
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
+
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),
|
|
315
377
|
)
|
|
316
378
|
except ApiException as e:
|
|
317
379
|
raise RuntimeError(api_error_message(e)) from e
|
|
318
|
-
return
|
|
380
|
+
return finalized.upload_id
|
|
319
381
|
|
|
320
382
|
def load_managed_table(
|
|
321
383
|
self,
|
|
@@ -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.2"
|
|
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,143 @@ 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():
|
|
198
224
|
client = _client()
|
|
199
|
-
|
|
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
|
+
|
|
200
229
|
with (
|
|
201
|
-
patch("builtins.open",
|
|
230
|
+
patch("builtins.open", _mock_open_bytes(data)),
|
|
231
|
+
patch("os.path.getsize", return_value=len(data)),
|
|
202
232
|
patch.object(client, "uploads") as uploads,
|
|
233
|
+
patch("hotdata_framework.client.urllib3.PoolManager") as MockPool,
|
|
203
234
|
):
|
|
204
|
-
|
|
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
|
+
|
|
205
241
|
upload_id = client.upload_parquet("/tmp/data.parquet")
|
|
206
|
-
|
|
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_raises_on_501():
|
|
282
|
+
client = _client()
|
|
283
|
+
|
|
284
|
+
with (
|
|
285
|
+
patch("builtins.open", mock_open(read_data=b"PAR1")),
|
|
286
|
+
patch("os.path.getsize", return_value=4),
|
|
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"):
|
|
293
|
+
client.upload_parquet("/tmp/data.parquet")
|
|
294
|
+
|
|
295
|
+
uploads.return_value.upload_file.assert_not_called()
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def test_upload_parquet_multipart_reads_are_part_size_bounded():
|
|
299
|
+
"""The OOM fix: multipart mode must never read more than part_size at once."""
|
|
300
|
+
client = _client()
|
|
301
|
+
part_size = 5
|
|
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"]
|
|
316
|
+
)
|
|
317
|
+
finalized = SimpleNamespace(upload_id="upl_final")
|
|
318
|
+
|
|
319
|
+
with (
|
|
320
|
+
patch("builtins.open", MagicMock(return_value=handle)),
|
|
321
|
+
patch("os.path.getsize", return_value=len(data)),
|
|
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)
|
|
207
335
|
|
|
208
336
|
|
|
209
337
|
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.2}/.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
|