hotdata-framework 0.6.3__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.6.3 → hotdata_framework-0.7.1}/CHANGELOG.md +17 -0
  2. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/PKG-INFO +2 -2
  3. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/hotdata_framework/client.py +89 -5
  4. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/hotdata_framework/managed_client.py +12 -4
  5. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/pyproject.toml +3 -2
  6. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/tests/test_databases.py +172 -4
  7. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/tests/test_managed_client.py +53 -0
  8. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/uv.lock +5 -5
  9. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/.github/dependabot.yml +0 -0
  10. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/.github/workflows/check-release.yml +0 -0
  11. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/.github/workflows/ci.yml +0 -0
  12. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/.github/workflows/dependabot-automerge.yml +0 -0
  13. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/.github/workflows/publish.yml +0 -0
  14. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/.github/workflows/release.yml +0 -0
  15. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/.gitignore +0 -0
  16. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/CONTRACT.md +0 -0
  17. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/README.md +0 -0
  18. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/RELEASING.md +0 -0
  19. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/examples/basic_usage.py +0 -0
  20. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/hotdata_framework/__init__.py +0 -0
  21. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/hotdata_framework/databases.py +0 -0
  22. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/hotdata_framework/env.py +0 -0
  23. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/hotdata_framework/errors.py +0 -0
  24. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/hotdata_framework/health.py +0 -0
  25. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/hotdata_framework/http.py +0 -0
  26. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/hotdata_framework/py.typed +0 -0
  27. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/hotdata_framework/result.py +0 -0
  28. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/scripts/check-release.py +0 -0
  29. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/scripts/extract-changelog.py +0 -0
  30. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/scripts/publish-workflow.sh +0 -0
  31. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/scripts/release.sh +0 -0
  32. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/scripts/update_changelog.py +0 -0
  33. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/tests/test_client.py +0 -0
  34. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/tests/test_contract.py +0 -0
  35. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/tests/test_errors.py +0 -0
  36. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/tests/test_health.py +0 -0
  37. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/tests/test_request_timeout.py +0 -0
  38. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/tests/test_result.py +0 -0
  39. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/tests/test_update_changelog.py +0 -0
  40. {hotdata_framework-0.6.3 → hotdata_framework-0.7.1}/tests/test_version.py +0 -0
@@ -8,6 +8,23 @@ 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
+
17
+ ## [0.7.0] - 2026-07-14
18
+
19
+ ### Added
20
+
21
+ - `load_managed_table(..., mode=...)` selects the load mode (`replace` (default), `append`, `delete`, `update`, `upsert`) instead of always replacing the table. `replace`/`append` apply the upload directly; `delete`/`update`/`upsert` match rows by the table's declared key. Backward compatible — omitting `mode` still replaces.
22
+ - `create_managed_database(..., keys={table: [cols]})` and `add_managed_table(..., key=[cols])` declare a table's row-identity key, enabling the key-based load modes on it. Requires a `hotdata` client whose managed-table decl models carry `key` (see the dependency floor bump); tables declared without a key stay `replace`/`append`-only.
23
+
24
+ ### Fixed
25
+
26
+ - `load_managed_table(..., mode="append")` is no longer retried on transient errors. Every other mode is idempotent, but retrying an `append` whose commit succeeded before the response was received would duplicate the uploaded rows; `append` now runs at most once. `mode` is also now typed as a literal of the accepted values.
27
+
11
28
  ## [0.6.3] - 2026-07-08
12
29
 
13
30
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hotdata-framework
3
- Version: 0.6.3
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
@@ -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.6.0
24
+ Requires-Dist: hotdata>=0.7.0
25
25
  Requires-Dist: pandas>=2.0
26
26
  Requires-Dist: pyarrow>=14.0
27
27
  Description-Content-Type: text/markdown
@@ -1,10 +1,12 @@
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
- from typing import Any
9
+ from typing import Any, Literal
8
10
 
9
11
  from hotdata import ApiClient, Configuration
10
12
  from hotdata.api.connections_api import ConnectionsApi
@@ -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
@@ -46,6 +51,10 @@ from hotdata_framework.env import (
46
51
  from hotdata_framework.http import default_http_retries
47
52
  from hotdata_framework.result import QueryResult
48
53
 
54
+ # Load modes the managed-table endpoint accepts: replace overwrites, append adds
55
+ # rows, delete/update/upsert match by the table's declared key.
56
+ ManagedLoadMode = Literal["replace", "append", "delete", "update", "upsert"]
57
+
49
58
  _TERMINAL = frozenset({"succeeded", "failed", "cancelled"})
50
59
  _RESULT_FAILURE = frozenset({"failed", "cancelled"})
51
60
 
@@ -241,14 +250,21 @@ class HotdataClient:
241
250
  *,
242
251
  schema: str = DEFAULT_SCHEMA,
243
252
  tables: list[str] | None = None,
253
+ keys: dict[str, list[str]] | None = None,
244
254
  expires_at: str | None = None,
245
255
  ) -> ManagedDatabase:
256
+ """Create a managed database. ``keys`` maps a table to its key columns
257
+ (enabling delete/update/upsert on it); omitted tables are keyless."""
258
+ keys = keys or {}
246
259
  schemas = None
247
260
  if tables:
248
261
  schemas = [
249
262
  DatabaseDefaultSchemaDecl(
250
263
  name=schema,
251
- tables=[DatabaseDefaultTableDecl(name=t) for t in tables],
264
+ tables=[
265
+ DatabaseDefaultTableDecl(name=t, key=list(keys.get(t, [])))
266
+ for t in tables
267
+ ],
252
268
  )
253
269
  ]
254
270
  request = CreateDatabaseRequest(
@@ -295,6 +311,71 @@ class HotdataClient:
295
311
  def upload_parquet(self, path: str) -> str:
296
312
  if not is_parquet_path(path):
297
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)."""
298
379
  with open(path, "rb") as f:
299
380
  data = f.read()
300
381
  try:
@@ -314,6 +395,7 @@ class HotdataClient:
314
395
  schema: str = DEFAULT_SCHEMA,
315
396
  upload_id: str | None = None,
316
397
  file: str | None = None,
398
+ mode: ManagedLoadMode = "replace",
317
399
  ) -> LoadManagedTableResult:
318
400
  if (upload_id is None) == (file is None):
319
401
  raise ValueError("Exactly one of upload_id or file is required")
@@ -324,7 +406,7 @@ class HotdataClient:
324
406
  assert file is not None
325
407
  resolved_upload_id = self.upload_parquet(file)
326
408
  request = LoadManagedTableRequest(
327
- mode="replace",
409
+ mode=mode,
328
410
  upload_id=resolved_upload_id,
329
411
  )
330
412
  try:
@@ -350,15 +432,17 @@ class HotdataClient:
350
432
  table: str,
351
433
  *,
352
434
  schema: str = DEFAULT_SCHEMA,
435
+ key: list[str] | None = None,
353
436
  ) -> ManagedTable:
354
437
  """Declare a new table on an existing managed database.
355
438
 
356
439
  The table is added empty (declared-but-unloaded); populate it with
357
440
  :meth:`load_managed_table`. Use this to evolve a managed database's
358
- schema after creation without recreating it.
441
+ schema after creation without recreating it. ``key`` sets the
442
+ row-identity columns for delete/update/upsert; omit for keyless.
359
443
  """
360
444
  db = self.resolve_managed_database(database)
361
- request = AddManagedTableRequest(name=table)
445
+ request = AddManagedTableRequest(name=table, key=list(key or []))
362
446
  try:
363
447
  self._databases_api().add_database_table(db.id, schema, request)
364
448
  except ApiException as e:
@@ -21,6 +21,7 @@ from hotdata.models.query_request import QueryRequest
21
21
  from hotdata.models.query_response import QueryResponse
22
22
 
23
23
  from hotdata_framework.client import HotdataClient as RuntimeClient
24
+ from hotdata_framework.client import ManagedLoadMode
24
25
  from hotdata_framework.databases import LoadManagedTableResult, ManagedDatabase
25
26
  from hotdata_framework.errors import (
26
27
  HotdataTransientError,
@@ -203,23 +204,30 @@ class ManagedDatabaseClient:
203
204
  *,
204
205
  schema: str,
205
206
  upload_id: str,
207
+ mode: ManagedLoadMode = "replace",
206
208
  ) -> LoadManagedTableResult:
209
+ # append is the only non-idempotent mode: if the server commits the load
210
+ # but the response is lost, a retry re-appends the same rows. Run it
211
+ # at-most-once; every other mode is safe to retry.
207
212
  return self._request_with_retry(
208
213
  lambda: self._runtime.load_managed_table(
209
214
  database,
210
215
  table,
211
216
  schema=schema,
212
217
  upload_id=upload_id,
213
- )
218
+ mode=mode,
219
+ ),
220
+ retryable=(mode != "append"),
214
221
  )
215
222
 
216
- def _request_with_retry(self, operation: Callable[[], T]) -> T:
217
- for attempt in range(1, self._max_retries + 1):
223
+ def _request_with_retry(self, operation: Callable[[], T], *, retryable: bool = True) -> T:
224
+ max_attempts = self._max_retries if retryable else 1
225
+ for attempt in range(1, max_attempts + 1):
218
226
  try:
219
227
  return operation()
220
228
  except Exception as error:
221
229
  mapped_error = classify_sdk_error(error.__cause__ or error)
222
- if isinstance(mapped_error, HotdataTransientError) and attempt < self._max_retries:
230
+ if isinstance(mapped_error, HotdataTransientError) and attempt < max_attempts:
223
231
  backoff = min(self._retry_backoff_seconds * attempt, self._MAX_BACKOFF_SECONDS)
224
232
  time.sleep(backoff)
225
233
  continue
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hotdata-framework"
7
- version = "0.6.3"
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"
@@ -26,7 +26,8 @@ classifiers = [
26
26
  "Typing :: Typed",
27
27
  ]
28
28
  dependencies = [
29
- "hotdata>=0.6.0",
29
+ # 0.7.0 adds `key` to table decls (create_managed_database(keys=) / add_managed_table(key=))
30
+ "hotdata>=0.7.0",
30
31
  "pandas>=2.0",
31
32
  "pyarrow>=14.0",
32
33
  ]
@@ -1,10 +1,12 @@
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
9
+ from hotdata.models.database_default_table_decl import DatabaseDefaultTableDecl
8
10
 
9
11
  from hotdata_framework.client import HotdataClient
10
12
  from hotdata_framework.databases import (
@@ -13,6 +15,20 @@ from hotdata_framework.databases import (
13
15
  )
14
16
 
15
17
 
18
+ def _decl_key_supported() -> bool:
19
+ # `key` ships with the regenerated client; the key tests activate once it does.
20
+ try:
21
+ return DatabaseDefaultTableDecl(name="t", key=["k"]).key == ["k"]
22
+ except Exception:
23
+ return False
24
+
25
+
26
+ requires_key_field = pytest.mark.skipif(
27
+ not _decl_key_supported(),
28
+ reason="hotdata client without `key` on managed-table decls",
29
+ )
30
+
31
+
16
32
  def _client() -> HotdataClient:
17
33
  return HotdataClient("k", "ws", host="https://api.hotdata.dev")
18
34
 
@@ -179,16 +195,105 @@ def test_upload_parquet_rejects_non_parquet():
179
195
  client.upload_parquet("/tmp/data.csv")
180
196
 
181
197
 
182
- 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():
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_fallback_on_501():
183
282
  client = _client()
184
- uploaded = SimpleNamespace(id="upl_123")
283
+ uploaded = SimpleNamespace(id="upl_post")
284
+
185
285
  with (
186
286
  patch("builtins.open", mock_open(read_data=b"PAR1")),
287
+ patch("os.path.getsize", return_value=4),
187
288
  patch.object(client, "uploads") as uploads,
188
289
  ):
290
+ err = ApiException(status=501)
291
+ uploads.return_value.create_upload_session_handler.side_effect = err
189
292
  uploads.return_value.upload_file.return_value = uploaded
293
+
190
294
  upload_id = client.upload_parquet("/tmp/data.parquet")
191
- assert upload_id == "upl_123"
295
+
296
+ assert upload_id == "upl_post"
192
297
 
193
298
 
194
299
  def test_load_managed_table_with_upload_id():
@@ -230,6 +335,69 @@ def test_load_managed_table_requires_exactly_one_source():
230
335
  )
231
336
 
232
337
 
338
+ def _load_and_capture_request(client, **kwargs):
339
+ db = managed_database_from_detail(_detail())
340
+ loaded = SimpleNamespace(
341
+ connection_id="conn_1", schema_name="public", table_name="orders", row_count=1
342
+ )
343
+ with (
344
+ patch.object(client, "resolve_managed_database", return_value=db),
345
+ patch.object(client, "connections") as connections,
346
+ ):
347
+ connections.return_value.load_managed_table.return_value = loaded
348
+ client.load_managed_table("db_1", "orders", upload_id="upl_1", **kwargs)
349
+ return connections.return_value.load_managed_table.call_args.args[3]
350
+
351
+
352
+ def test_load_managed_table_defaults_to_replace():
353
+ assert _load_and_capture_request(_client()).mode == "replace"
354
+
355
+
356
+ @pytest.mark.parametrize("mode", ["append", "delete", "update", "upsert"])
357
+ def test_load_managed_table_forwards_mode(mode: str):
358
+ assert _load_and_capture_request(_client(), mode=mode).mode == mode
359
+
360
+
361
+ @requires_key_field
362
+ def test_create_managed_database_declares_keys():
363
+ client = _client()
364
+ with patch.object(client, "_databases_api") as dbs:
365
+ dbs.return_value.create_database.return_value = _detail(id="db_new")
366
+ client.create_managed_database(
367
+ "mydb", tables=["orders", "events"], keys={"orders": ["id"]}
368
+ )
369
+ req = dbs.return_value.create_database.call_args.args[0]
370
+ declared = {t.name: list(t.key) for t in req.schemas[0].tables}
371
+ assert declared == {"orders": ["id"], "events": []}
372
+
373
+
374
+ @requires_key_field
375
+ def test_add_managed_table_declares_key():
376
+ client = _client()
377
+ db = managed_database_from_detail(_detail())
378
+ with (
379
+ patch.object(client, "resolve_managed_database", return_value=db),
380
+ patch.object(client, "_databases_api") as dbs,
381
+ ):
382
+ client.add_managed_table("db_1", "line_items", key=["order_id", "sku"])
383
+ req = dbs.return_value.add_database_table.call_args.args[2]
384
+ assert req.name == "line_items"
385
+ assert list(req.key) == ["order_id", "sku"]
386
+
387
+
388
+ @requires_key_field
389
+ def test_add_managed_table_keyless_by_default():
390
+ client = _client()
391
+ db = managed_database_from_detail(_detail())
392
+ with (
393
+ patch.object(client, "resolve_managed_database", return_value=db),
394
+ patch.object(client, "_databases_api") as dbs,
395
+ ):
396
+ client.add_managed_table("db_1", "orders")
397
+ req = dbs.return_value.add_database_table.call_args.args[2]
398
+ assert list(req.key) == []
399
+
400
+
233
401
  def test_delete_managed_table_uses_default_connection_id():
234
402
  client = _client()
235
403
  db = managed_database_from_detail(_detail())
@@ -156,3 +156,56 @@ def test_fetch_table_carries_database_scope_on_result_reads(
156
156
  assert table is not None
157
157
  assert result_scopes == ["db1"]
158
158
  assert arrow_scopes == ["db1"]
159
+
160
+
161
+ def _load_recording_runtime(calls: list[str]) -> SimpleNamespace:
162
+ """A runtime whose ``load_managed_table`` records each mode and always fails
163
+ with a transient error, so retry behaviour is observable via ``calls``."""
164
+
165
+ def load_managed_table(
166
+ database: str, table: str, *, schema: str, upload_id: str, mode: str
167
+ ) -> SimpleNamespace:
168
+ calls.append(mode)
169
+ raise TimeoutError("commit succeeded but response was lost")
170
+
171
+ runtime = _fake_runtime()
172
+ runtime.load_managed_table = load_managed_table
173
+ return runtime
174
+
175
+
176
+ def _managed_client(max_retries: int) -> Any:
177
+ return mc.ManagedDatabaseClient(
178
+ api_key="k",
179
+ workspace_id="w",
180
+ api_base_url="https://example.test",
181
+ max_retries=max_retries,
182
+ retry_backoff_seconds=0.0,
183
+ )
184
+
185
+
186
+ def test_append_load_runs_at_most_once(monkeypatch: pytest.MonkeyPatch) -> None:
187
+ """``append`` is not idempotent: retrying after a commit whose response was
188
+ lost would duplicate rows. A transient failure must surface immediately
189
+ without re-appending, even with retries budgeted."""
190
+ monkeypatch.setattr(mc.time, "sleep", lambda _seconds: None)
191
+ calls: list[str] = []
192
+ client = _managed_client(max_retries=8)
193
+ client._runtime = _load_recording_runtime(calls)
194
+
195
+ with pytest.raises(mc.HotdataTransientError):
196
+ client.load_managed_table("db", "orders", schema="public", upload_id="u1", mode="append")
197
+
198
+ assert calls == ["append"] # tried once, never retried
199
+
200
+
201
+ def test_idempotent_load_retries_on_transient(monkeypatch: pytest.MonkeyPatch) -> None:
202
+ """Idempotent modes still exhaust the retry budget on transient errors."""
203
+ monkeypatch.setattr(mc.time, "sleep", lambda _seconds: None)
204
+ calls: list[str] = []
205
+ client = _managed_client(max_retries=3)
206
+ client._runtime = _load_recording_runtime(calls)
207
+
208
+ with pytest.raises(mc.HotdataTransientError):
209
+ client.load_managed_table("db", "orders", schema="public", upload_id="u1", mode="replace")
210
+
211
+ assert calls == ["replace", "replace", "replace"] # retried up to max_retries
@@ -86,7 +86,7 @@ wheels = [
86
86
 
87
87
  [[package]]
88
88
  name = "hotdata"
89
- version = "0.6.0"
89
+ version = "0.7.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/d2/17/6d9e7cbe7e766cd52968653096ce4199d0cb1aa55b73fcfc2979fb9805ff/hotdata-0.6.0.tar.gz", hash = "sha256:c655fd9691a1d65dd4ed51b86f1c27bf8e73268b4c44696987a57a624da35143", size = 214162, upload-time = "2026-07-07T16:44:32.65Z" }
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" }
98
98
  wheels = [
99
- { url = "https://files.pythonhosted.org/packages/dc/0a/4c9f23e1fa85a8d0336681f608c13100e4ab2cf4204846d90374b1bb141b/hotdata-0.6.0-py3-none-any.whl", hash = "sha256:3324758eb0aa37bbcc505ddac524566a8c10fbe0787e226932e8b04d565200c1", size = 312820, upload-time = "2026-07-07T16:44:31.121Z" },
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" },
100
100
  ]
101
101
 
102
102
  [[package]]
103
103
  name = "hotdata-framework"
104
- version = "0.6.3"
104
+ version = "0.7.1"
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.6.0" },
123
+ { name = "hotdata", specifier = ">=0.7.0" },
124
124
  { name = "pandas", specifier = ">=2.0" },
125
125
  { name = "pyarrow", specifier = ">=14.0" },
126
126
  ]