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.
Files changed (40) hide show
  1. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/CHANGELOG.md +6 -0
  2. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/PKG-INFO +1 -1
  3. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/hotdata_framework/client.py +70 -0
  4. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/pyproject.toml +1 -1
  5. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/tests/test_databases.py +94 -4
  6. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/uv.lock +1 -1
  7. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/.github/dependabot.yml +0 -0
  8. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/.github/workflows/check-release.yml +0 -0
  9. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/.github/workflows/ci.yml +0 -0
  10. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/.github/workflows/dependabot-automerge.yml +0 -0
  11. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/.github/workflows/publish.yml +0 -0
  12. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/.github/workflows/release.yml +0 -0
  13. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/.gitignore +0 -0
  14. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/CONTRACT.md +0 -0
  15. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/README.md +0 -0
  16. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/RELEASING.md +0 -0
  17. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/examples/basic_usage.py +0 -0
  18. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/hotdata_framework/__init__.py +0 -0
  19. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/hotdata_framework/databases.py +0 -0
  20. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/hotdata_framework/env.py +0 -0
  21. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/hotdata_framework/errors.py +0 -0
  22. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/hotdata_framework/health.py +0 -0
  23. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/hotdata_framework/http.py +0 -0
  24. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/hotdata_framework/managed_client.py +0 -0
  25. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/hotdata_framework/py.typed +0 -0
  26. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/hotdata_framework/result.py +0 -0
  27. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/scripts/check-release.py +0 -0
  28. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/scripts/extract-changelog.py +0 -0
  29. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/scripts/publish-workflow.sh +0 -0
  30. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/scripts/release.sh +0 -0
  31. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/scripts/update_changelog.py +0 -0
  32. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/tests/test_client.py +0 -0
  33. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/tests/test_contract.py +0 -0
  34. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/tests/test_errors.py +0 -0
  35. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/tests/test_health.py +0 -0
  36. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/tests/test_managed_client.py +0 -0
  37. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/tests/test_request_timeout.py +0 -0
  38. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/tests/test_result.py +0 -0
  39. {hotdata_framework-0.7.0 → hotdata_framework-0.7.1}/tests/test_update_changelog.py +0 -0
  40. {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.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
@@ -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.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"
@@ -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 test_upload_parquet_returns_upload_id():
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
- uploaded = SimpleNamespace(id="upl_123")
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
- assert upload_id == "upl_123"
295
+
296
+ assert upload_id == "upl_post"
207
297
 
208
298
 
209
299
  def test_load_managed_table_with_upload_id():
@@ -101,7 +101,7 @@ wheels = [
101
101
 
102
102
  [[package]]
103
103
  name = "hotdata-framework"
104
- version = "0.7.0"
104
+ version = "0.7.1"
105
105
  source = { editable = "." }
106
106
  dependencies = [
107
107
  { name = "hotdata" },