hotdata-framework 0.7.2__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.
Files changed (40) hide show
  1. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/CHANGELOG.md +10 -0
  2. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/PKG-INFO +1 -1
  3. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/hotdata_framework/client.py +20 -68
  4. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/hotdata_framework/errors.py +4 -0
  5. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/pyproject.toml +1 -1
  6. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/tests/test_databases.py +35 -118
  7. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/uv.lock +1 -1
  8. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/.github/dependabot.yml +0 -0
  9. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/.github/workflows/check-release.yml +0 -0
  10. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/.github/workflows/ci.yml +0 -0
  11. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/.github/workflows/dependabot-automerge.yml +0 -0
  12. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/.github/workflows/publish.yml +0 -0
  13. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/.github/workflows/release.yml +0 -0
  14. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/.gitignore +0 -0
  15. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/CONTRACT.md +0 -0
  16. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/README.md +0 -0
  17. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/RELEASING.md +0 -0
  18. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/examples/basic_usage.py +0 -0
  19. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/hotdata_framework/__init__.py +0 -0
  20. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/hotdata_framework/databases.py +0 -0
  21. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/hotdata_framework/env.py +0 -0
  22. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/hotdata_framework/health.py +0 -0
  23. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/hotdata_framework/http.py +0 -0
  24. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/hotdata_framework/managed_client.py +0 -0
  25. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/hotdata_framework/py.typed +0 -0
  26. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/hotdata_framework/result.py +0 -0
  27. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/scripts/check-release.py +0 -0
  28. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/scripts/extract-changelog.py +0 -0
  29. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/scripts/publish-workflow.sh +0 -0
  30. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/scripts/release.sh +0 -0
  31. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/scripts/update_changelog.py +0 -0
  32. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/tests/test_client.py +0 -0
  33. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/tests/test_contract.py +0 -0
  34. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/tests/test_errors.py +0 -0
  35. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/tests/test_health.py +0 -0
  36. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/tests/test_managed_client.py +0 -0
  37. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/tests/test_request_timeout.py +0 -0
  38. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/tests/test_result.py +0 -0
  39. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/tests/test_update_changelog.py +0 -0
  40. {hotdata_framework-0.7.2 → hotdata_framework-0.7.3}/tests/test_version.py +0 -0
@@ -8,6 +8,16 @@ 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
+
11
21
  ## [0.7.2] - 2026-07-15
12
22
 
13
23
  ### Removed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hotdata-framework
3
- Version: 0.7.2
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
- from hotdata.api.uploads_api import UploadsApi
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,74 +306,29 @@ 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
- 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
- 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
319
  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),
320
+ finalized = self.uploads().upload_file(
321
+ path, content_type="application/octet-stream"
377
322
  )
378
323
  except ApiException as e:
379
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
380
332
  return finalized.upload_id
381
333
 
382
334
  def load_managed_table(
@@ -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.2"
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 MagicMock, mock_open, patch
4
+ from unittest.mock import patch
6
5
 
7
6
  import pytest
8
7
  from hotdata.exceptions import ApiException
@@ -195,143 +194,61 @@ def test_upload_parquet_rejects_non_parquet():
195
194
  client.upload_parquet("/tmp/data.csv")
196
195
 
197
196
 
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():
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
- 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
-
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
- 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
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
- data = b"PAR1tiny"
257
- session = _session("single", url="https://s/put")
258
- finalized = SimpleNamespace(upload_id="upl_single")
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
- 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
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
- upload_id = client.upload_parquet("/tmp/data.parquet")
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_raises_on_501():
282
233
  client = _client()
234
+ sdk_err = StorageError(status=503, part_number=3, body="upstream unavailable")
283
235
 
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"):
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"):
293
239
  client.upload_parquet("/tmp/data.parquet")
294
240
 
295
- uploads.return_value.upload_file.assert_not_called()
296
-
297
241
 
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"]
242
+ def test_classify_501_is_terminal():
243
+ from hotdata_framework.errors import (
244
+ HotdataTerminalError,
245
+ HotdataTransientError,
246
+ classify_sdk_error,
316
247
  )
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
248
 
333
- assert read_sizes, "expected chunked reads"
334
- assert all(n == part_size for n in read_sizes)
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)
335
252
 
336
253
 
337
254
  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.2"
104
+ version = "0.7.3"
105
105
  source = { editable = "." }
106
106
  dependencies = [
107
107
  { name = "hotdata" },