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.
Files changed (40) hide show
  1. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/CHANGELOG.md +12 -0
  2. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/PKG-INFO +1 -1
  3. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/hotdata_framework/client.py +68 -6
  4. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/pyproject.toml +1 -1
  5. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/tests/test_databases.py +134 -6
  6. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/uv.lock +1 -1
  7. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/.github/dependabot.yml +0 -0
  8. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/.github/workflows/check-release.yml +0 -0
  9. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/.github/workflows/ci.yml +0 -0
  10. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/.github/workflows/dependabot-automerge.yml +0 -0
  11. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/.github/workflows/publish.yml +0 -0
  12. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/.github/workflows/release.yml +0 -0
  13. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/.gitignore +0 -0
  14. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/CONTRACT.md +0 -0
  15. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/README.md +0 -0
  16. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/RELEASING.md +0 -0
  17. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/examples/basic_usage.py +0 -0
  18. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/hotdata_framework/__init__.py +0 -0
  19. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/hotdata_framework/databases.py +0 -0
  20. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/hotdata_framework/env.py +0 -0
  21. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/hotdata_framework/errors.py +0 -0
  22. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/hotdata_framework/health.py +0 -0
  23. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/hotdata_framework/http.py +0 -0
  24. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/hotdata_framework/managed_client.py +0 -0
  25. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/hotdata_framework/py.typed +0 -0
  26. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/hotdata_framework/result.py +0 -0
  27. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/scripts/check-release.py +0 -0
  28. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/scripts/extract-changelog.py +0 -0
  29. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/scripts/publish-workflow.sh +0 -0
  30. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/scripts/release.sh +0 -0
  31. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/scripts/update_changelog.py +0 -0
  32. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/tests/test_client.py +0 -0
  33. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/tests/test_contract.py +0 -0
  34. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/tests/test_errors.py +0 -0
  35. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/tests/test_health.py +0 -0
  36. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/tests/test_managed_client.py +0 -0
  37. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/tests/test_request_timeout.py +0 -0
  38. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/tests/test_result.py +0 -0
  39. {hotdata_framework-0.7.0 → hotdata_framework-0.7.2}/tests/test_update_changelog.py +0 -0
  40. {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.0
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
- with open(path, "rb") as f:
310
- data = f.read()
314
+ file_size = os.path.getsize(path)
311
315
  try:
312
- uploaded = self.uploads().upload_file(
313
- data,
314
- _content_type="application/octet-stream",
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 uploaded.id
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.0"
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 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():
198
224
  client = _client()
199
- uploaded = SimpleNamespace(id="upl_123")
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", mock_open(read_data=b"PAR1")),
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
- uploads.return_value.upload_file.return_value = uploaded
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
- assert upload_id == "upl_123"
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():
@@ -101,7 +101,7 @@ wheels = [
101
101
 
102
102
  [[package]]
103
103
  name = "hotdata-framework"
104
- version = "0.7.0"
104
+ version = "0.7.2"
105
105
  source = { editable = "." }
106
106
  dependencies = [
107
107
  { name = "hotdata" },