hotdata-framework 0.7.2__tar.gz → 0.8.0__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.8.0}/CHANGELOG.md +19 -0
  2. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/PKG-INFO +2 -2
  3. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/hotdata_framework/client.py +22 -68
  4. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/hotdata_framework/errors.py +4 -0
  5. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/hotdata_framework/managed_client.py +6 -0
  6. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/pyproject.toml +2 -2
  7. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/tests/test_databases.py +45 -118
  8. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/tests/test_managed_client.py +38 -1
  9. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/uv.lock +5 -5
  10. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/.github/dependabot.yml +0 -0
  11. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/.github/workflows/check-release.yml +0 -0
  12. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/.github/workflows/ci.yml +0 -0
  13. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/.github/workflows/dependabot-automerge.yml +0 -0
  14. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/.github/workflows/publish.yml +0 -0
  15. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/.github/workflows/release.yml +0 -0
  16. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/.gitignore +0 -0
  17. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/CONTRACT.md +0 -0
  18. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/README.md +0 -0
  19. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/RELEASING.md +0 -0
  20. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/examples/basic_usage.py +0 -0
  21. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/hotdata_framework/__init__.py +0 -0
  22. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/hotdata_framework/databases.py +0 -0
  23. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/hotdata_framework/env.py +0 -0
  24. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/hotdata_framework/health.py +0 -0
  25. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/hotdata_framework/http.py +0 -0
  26. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/hotdata_framework/py.typed +0 -0
  27. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/hotdata_framework/result.py +0 -0
  28. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/scripts/check-release.py +0 -0
  29. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/scripts/extract-changelog.py +0 -0
  30. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/scripts/publish-workflow.sh +0 -0
  31. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/scripts/release.sh +0 -0
  32. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/scripts/update_changelog.py +0 -0
  33. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/tests/test_client.py +0 -0
  34. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/tests/test_contract.py +0 -0
  35. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/tests/test_errors.py +0 -0
  36. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/tests/test_health.py +0 -0
  37. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/tests/test_request_timeout.py +0 -0
  38. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/tests/test_result.py +0 -0
  39. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/tests/test_update_changelog.py +0 -0
  40. {hotdata_framework-0.7.2 → hotdata_framework-0.8.0}/tests/test_version.py +0 -0
@@ -8,6 +8,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
  ## [Unreleased]
9
9
 
10
10
 
11
+ ## [0.8.0] - 2026-07-20
12
+
13
+ ### Changed
14
+
15
+ - `load_managed_table` accepts a `key` argument — the merge key columns for
16
+ `delete`/`update`/`upsert` loads, matched per-load instead of requiring a key
17
+ declared at table creation. Omit it to use the table's declared key; ignored
18
+ for `replace`/`append`. Requires `hotdata>=0.8.0`.
19
+
20
+ ## [0.7.3] - 2026-07-16
21
+
22
+ ### Changed
23
+
24
+ - `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.
25
+
26
+ ### Fixed
27
+
28
+ - `classify_sdk_error` now classifies HTTP 501 (Not Implemented) as terminal instead of transient — a permanent capability gap must not burn retries.
29
+
11
30
  ## [0.7.2] - 2026-07-15
12
31
 
13
32
  ### Removed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hotdata-framework
3
- Version: 0.7.2
3
+ Version: 0.8.0
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
@@ -21,7 +21,7 @@ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
21
21
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
22
  Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.10
24
- Requires-Dist: hotdata>=0.7.0
24
+ Requires-Dist: hotdata>=0.8.0
25
25
  Requires-Dist: pandas>=2.0
26
26
  Requires-Dist: pyarrow>=14.0
27
27
  Description-Content-Type: text/markdown
@@ -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
319
  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
- 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(
@@ -388,6 +340,7 @@ class HotdataClient:
388
340
  upload_id: str | None = None,
389
341
  file: str | None = None,
390
342
  mode: ManagedLoadMode = "replace",
343
+ key: list[str] | None = None,
391
344
  ) -> LoadManagedTableResult:
392
345
  if (upload_id is None) == (file is None):
393
346
  raise ValueError("Exactly one of upload_id or file is required")
@@ -400,6 +353,7 @@ class HotdataClient:
400
353
  request = LoadManagedTableRequest(
401
354
  mode=mode,
402
355
  upload_id=resolved_upload_id,
356
+ key=key,
403
357
  )
404
358
  try:
405
359
  loaded = self.connections().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)
@@ -205,10 +205,15 @@ class ManagedDatabaseClient:
205
205
  schema: str,
206
206
  upload_id: str,
207
207
  mode: ManagedLoadMode = "replace",
208
+ key: list[str] | None = None,
208
209
  ) -> LoadManagedTableResult:
209
210
  # append is the only non-idempotent mode: if the server commits the load
210
211
  # but the response is lost, a retry re-appends the same rows. Run it
211
212
  # at-most-once; every other mode is safe to retry.
213
+ #
214
+ # `key` is the merge key for delete/update/upsert loads: when set it is
215
+ # matched per-load instead of a key declared at table creation. Omit it
216
+ # to use the table's declared key. Ignored for replace/append.
212
217
  return self._request_with_retry(
213
218
  lambda: self._runtime.load_managed_table(
214
219
  database,
@@ -216,6 +221,7 @@ class ManagedDatabaseClient:
216
221
  schema=schema,
217
222
  upload_id=upload_id,
218
223
  mode=mode,
224
+ key=key,
219
225
  ),
220
226
  retryable=(mode != "append"),
221
227
  )
@@ -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.8.0"
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"
@@ -27,7 +27,7 @@ classifiers = [
27
27
  ]
28
28
  dependencies = [
29
29
  # 0.7.0 adds `key` to table decls (create_managed_database(keys=) / add_managed_table(key=))
30
- "hotdata>=0.7.0",
30
+ "hotdata>=0.8.0",
31
31
  "pandas>=2.0",
32
32
  "pyarrow>=14.0",
33
33
  ]
@@ -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
241
 
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"]
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
248
 
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)
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():
@@ -391,6 +308,16 @@ def test_load_managed_table_defaults_to_replace():
391
308
  assert _load_and_capture_request(_client()).mode == "replace"
392
309
 
393
310
 
311
+ def test_load_managed_table_passes_key():
312
+ request = _load_and_capture_request(_client(), mode="delete", key=["id"])
313
+ assert request.mode == "delete"
314
+ assert request.key == ["id"]
315
+
316
+
317
+ def test_load_managed_table_key_defaults_to_none():
318
+ assert _load_and_capture_request(_client()).key is None
319
+
320
+
394
321
  @pytest.mark.parametrize("mode", ["append", "delete", "update", "upsert"])
395
322
  def test_load_managed_table_forwards_mode(mode: str):
396
323
  assert _load_and_capture_request(_client(), mode=mode).mode == mode
@@ -163,7 +163,13 @@ def _load_recording_runtime(calls: list[str]) -> SimpleNamespace:
163
163
  with a transient error, so retry behaviour is observable via ``calls``."""
164
164
 
165
165
  def load_managed_table(
166
- database: str, table: str, *, schema: str, upload_id: str, mode: str
166
+ database: str,
167
+ table: str,
168
+ *,
169
+ schema: str,
170
+ upload_id: str,
171
+ mode: str,
172
+ key: list[str] | None = None,
167
173
  ) -> SimpleNamespace:
168
174
  calls.append(mode)
169
175
  raise TimeoutError("commit succeeded but response was lost")
@@ -209,3 +215,34 @@ def test_idempotent_load_retries_on_transient(monkeypatch: pytest.MonkeyPatch) -
209
215
  client.load_managed_table("db", "orders", schema="public", upload_id="u1", mode="replace")
210
216
 
211
217
  assert calls == ["replace", "replace", "replace"] # retried up to max_retries
218
+
219
+
220
+ def test_load_managed_table_forwards_key(monkeypatch: pytest.MonkeyPatch) -> None:
221
+ """A per-load ``key`` is passed straight through to the runtime client."""
222
+ monkeypatch.setattr(mc.time, "sleep", lambda _seconds: None)
223
+ captured: dict[str, Any] = {}
224
+
225
+ def load_managed_table(
226
+ database: str,
227
+ table: str,
228
+ *,
229
+ schema: str,
230
+ upload_id: str,
231
+ mode: str,
232
+ key: list[str] | None = None,
233
+ ) -> SimpleNamespace:
234
+ captured["mode"] = mode
235
+ captured["key"] = key
236
+ return SimpleNamespace(
237
+ connection_id="c", schema_name=schema, table_name=table, row_count=0
238
+ )
239
+
240
+ client = _managed_client(max_retries=1)
241
+ runtime = _fake_runtime()
242
+ runtime.load_managed_table = load_managed_table
243
+ client._runtime = runtime
244
+
245
+ client.load_managed_table(
246
+ "db", "orders", schema="public", upload_id="u1", mode="delete", key=["id"]
247
+ )
248
+ assert captured == {"mode": "delete", "key": ["id"]}
@@ -86,7 +86,7 @@ wheels = [
86
86
 
87
87
  [[package]]
88
88
  name = "hotdata"
89
- version = "0.7.0"
89
+ version = "0.8.0"
90
90
  source = { registry = "https://pypi.org/simple" }
91
91
  dependencies = [
92
92
  { name = "pydantic" },
@@ -94,14 +94,14 @@ dependencies = [
94
94
  { name = "typing-extensions" },
95
95
  { name = "urllib3" },
96
96
  ]
97
- sdist = { url = "https://files.pythonhosted.org/packages/61/30/08681132e019f6c9ddb566dc38be6a302763c4b4848b2d97e3d7ace8d257/hotdata-0.7.0.tar.gz", hash = "sha256:ef4484a74c0f33ee543b0a0dbfb5a6c04e7b5812b277f42a6448000d41c2405d", size = 215130, upload-time = "2026-07-14T12:21:33.936Z" }
97
+ sdist = { url = "https://files.pythonhosted.org/packages/8a/38/30ed3d1d99413e7684672fa424baef85a347db465b90c774443320cf1cea/hotdata-0.8.0.tar.gz", hash = "sha256:cdac515ffa193ed028491e4b7abcd1b6404d4e3f96986c9c0a2b2f407db42eb6", size = 216848, upload-time = "2026-07-20T06:30:37.659Z" }
98
98
  wheels = [
99
- { url = "https://files.pythonhosted.org/packages/dc/ef/6c4236640629688074892759b1a7593c08f30e9d916b6951c2bbfec19d7f/hotdata-0.7.0-py3-none-any.whl", hash = "sha256:d9e3008e3084d22bcc27b7bf1d08d8c2c255fe3726c252c777bb614a3cae0576", size = 314103, upload-time = "2026-07-14T12:21:32.154Z" },
99
+ { url = "https://files.pythonhosted.org/packages/bc/0f/f2ccaeb0f910c3f8cd84a911d934704277f0d6e5a20f8b49cf0eedb2a8aa/hotdata-0.8.0-py3-none-any.whl", hash = "sha256:5d64bfc185a0e7e2bf34ebdcea50787d3418fb250dacc53e00dc74d64e99f753", size = 316923, upload-time = "2026-07-20T06:30:35.778Z" },
100
100
  ]
101
101
 
102
102
  [[package]]
103
103
  name = "hotdata-framework"
104
- version = "0.7.2"
104
+ version = "0.8.0"
105
105
  source = { editable = "." }
106
106
  dependencies = [
107
107
  { name = "hotdata" },
@@ -120,7 +120,7 @@ dev = [
120
120
 
121
121
  [package.metadata]
122
122
  requires-dist = [
123
- { name = "hotdata", specifier = ">=0.7.0" },
123
+ { name = "hotdata", specifier = ">=0.8.0" },
124
124
  { name = "pandas", specifier = ">=2.0" },
125
125
  { name = "pyarrow", specifier = ">=14.0" },
126
126
  ]