hotdata-framework 0.7.3__tar.gz → 0.9.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 (41) hide show
  1. hotdata_framework-0.9.0/.github/CODEOWNERS +1 -0
  2. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/CHANGELOG.md +22 -0
  3. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/CONTRACT.md +3 -2
  4. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/PKG-INFO +2 -2
  5. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/hotdata_framework/client.py +32 -14
  6. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/hotdata_framework/managed_client.py +6 -0
  7. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/pyproject.toml +2 -2
  8. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/tests/test_client.py +123 -0
  9. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/tests/test_databases.py +10 -0
  10. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/tests/test_managed_client.py +38 -1
  11. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/uv.lock +5 -5
  12. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/.github/dependabot.yml +0 -0
  13. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/.github/workflows/check-release.yml +0 -0
  14. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/.github/workflows/ci.yml +0 -0
  15. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/.github/workflows/dependabot-automerge.yml +0 -0
  16. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/.github/workflows/publish.yml +0 -0
  17. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/.github/workflows/release.yml +0 -0
  18. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/.gitignore +0 -0
  19. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/README.md +0 -0
  20. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/RELEASING.md +0 -0
  21. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/examples/basic_usage.py +0 -0
  22. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/hotdata_framework/__init__.py +0 -0
  23. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/hotdata_framework/databases.py +0 -0
  24. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/hotdata_framework/env.py +0 -0
  25. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/hotdata_framework/errors.py +0 -0
  26. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/hotdata_framework/health.py +0 -0
  27. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/hotdata_framework/http.py +0 -0
  28. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/hotdata_framework/py.typed +0 -0
  29. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/hotdata_framework/result.py +0 -0
  30. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/scripts/check-release.py +0 -0
  31. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/scripts/extract-changelog.py +0 -0
  32. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/scripts/publish-workflow.sh +0 -0
  33. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/scripts/release.sh +0 -0
  34. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/scripts/update_changelog.py +0 -0
  35. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/tests/test_contract.py +0 -0
  36. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/tests/test_errors.py +0 -0
  37. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/tests/test_health.py +0 -0
  38. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/tests/test_request_timeout.py +0 -0
  39. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/tests/test_result.py +0 -0
  40. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/tests/test_update_changelog.py +0 -0
  41. {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/tests/test_version.py +0 -0
@@ -0,0 +1 @@
1
+ * @hotdata-dev/engineers
@@ -8,6 +8,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
  ## [Unreleased]
9
9
 
10
10
 
11
+ ## [0.9.0] - 2026-07-23
12
+
13
+ ### Added
14
+
15
+ - `list_managed_tables`, `load_managed_table`, `add_managed_table`,
16
+ `delete_managed_table`, `delete_managed_database`, and `execute_sql` accept an
17
+ already-resolved `ManagedDatabase` (as returned by `create_managed_database`)
18
+ in place of a name/id. When passed one, they skip the `get_database` /
19
+ `list_databases` read probe. This lets an API key scoped to create + load but
20
+ not read `/databases` bootstrap a managed database and load into it within a
21
+ single run: the caller holds the `ManagedDatabase` from `create` and drives
22
+ the load/add/query ops with zero reads. The name/id string path is unchanged.
23
+
24
+ ## [0.8.0] - 2026-07-20
25
+
26
+ ### Changed
27
+
28
+ - `load_managed_table` accepts a `key` argument — the merge key columns for
29
+ `delete`/`update`/`upsert` loads, matched per-load instead of requiring a key
30
+ declared at table creation. Omit it to use the table's declared key; ignored
31
+ for `replace`/`append`. Requires `hotdata>=0.8.0`.
32
+
11
33
  ## [0.7.3] - 2026-07-16
12
34
 
13
35
  ### Changed
@@ -56,13 +56,14 @@ Adapters should import from `hotdata_framework` and treat this surface as the st
56
56
  adapters should pass `connection_id` when known.
57
57
  - `uploads()` returns the uploads API wrapper for parquet staging.
58
58
  - `list_managed_databases()` returns all databases via the `/databases` API.
59
- - `resolve_managed_database(name_or_id)` resolves a database by id (direct lookup) or description (list scan).
60
- - `create_managed_database(description=..., schema=..., tables=..., expires_at=...)` creates a database via the `/databases` API and optionally declares tables up front.
59
+ - `resolve_managed_database(name_or_id)` resolves a database by id (direct lookup) or description (list scan). A `403` from `/databases` surfaces as `RuntimeError` (forbidden, not absent), preserving the underlying `ApiException` as `__cause__`.
60
+ - `create_managed_database(description=..., schema=..., tables=..., expires_at=...)` creates a database via the `/databases` API and optionally declares tables up front. Returns a `ManagedDatabase` (id + `default_connection_id`) sufficient to load without a further read.
61
61
  - `delete_managed_database(name_or_id)` deletes a database via the `/databases` API.
62
62
  - `list_managed_tables(database, schema=...)` lists tables in a managed database.
63
63
  - `upload_parquet(path)` uploads a local parquet file and returns an upload id.
64
64
  - `load_managed_table(database, table, schema=..., upload_id=..., file=...)` publishes parquet data into a declared managed table.
65
65
  - `delete_managed_table(database, table, schema=...)` deletes a managed table.
66
+ - The `database` argument of `list_managed_tables`, `load_managed_table`, `add_managed_table`, `delete_managed_table`, `delete_managed_database`, and `execute_sql` accepts a name/id **or** an already-resolved `ManagedDatabase`. Passing a `ManagedDatabase` skips the name/id read probe, so a create-scoped key that cannot read `/databases` can load into a database it just created.
66
67
 
67
68
  ### `QueryResult`
68
69
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hotdata-framework
3
- Version: 0.7.3
3
+ Version: 0.9.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
@@ -241,6 +241,19 @@ class HotdataClient:
241
241
  raise RuntimeError(api_error_message(e)) from e
242
242
  return managed_database_from_detail(detail)
243
243
 
244
+ def _as_managed_database(self, database: str | ManagedDatabase) -> ManagedDatabase:
245
+ """Return ``database`` as-is if it is already a resolved ``ManagedDatabase``,
246
+ otherwise resolve it by name or id.
247
+
248
+ Passing an already-resolved ``ManagedDatabase`` (e.g. the value returned by
249
+ :meth:`create_managed_database`) skips the id/name read probe, so callers
250
+ whose API key may create but not read ``/databases`` can drive loads without
251
+ a forbidden read.
252
+ """
253
+ if isinstance(database, ManagedDatabase):
254
+ return database
255
+ return self.resolve_managed_database(database)
256
+
244
257
  def create_managed_database(
245
258
  self,
246
259
  description: str | None = None,
@@ -275,8 +288,8 @@ class HotdataClient:
275
288
  raise RuntimeError(api_error_message(e)) from e
276
289
  return managed_database_from_detail(created)
277
290
 
278
- def delete_managed_database(self, name_or_id: str) -> None:
279
- db = self.resolve_managed_database(name_or_id)
291
+ def delete_managed_database(self, name_or_id: str | ManagedDatabase) -> None:
292
+ db = self._as_managed_database(name_or_id)
280
293
  try:
281
294
  self._databases_api().delete_database(db.id)
282
295
  except ApiException as e:
@@ -284,11 +297,11 @@ class HotdataClient:
284
297
 
285
298
  def list_managed_tables(
286
299
  self,
287
- database: str,
300
+ database: str | ManagedDatabase,
288
301
  *,
289
302
  schema: str | None = None,
290
303
  ) -> list[ManagedTable]:
291
- db = self.resolve_managed_database(database)
304
+ db = self._as_managed_database(database)
292
305
  rows: list[ManagedTable] = []
293
306
  for t in self.iter_tables(connection_id=db.default_connection_id):
294
307
  if schema is not None and t.var_schema != schema:
@@ -333,17 +346,18 @@ class HotdataClient:
333
346
 
334
347
  def load_managed_table(
335
348
  self,
336
- database: str,
349
+ database: str | ManagedDatabase,
337
350
  table: str,
338
351
  *,
339
352
  schema: str = DEFAULT_SCHEMA,
340
353
  upload_id: str | None = None,
341
354
  file: str | None = None,
342
355
  mode: ManagedLoadMode = "replace",
356
+ key: list[str] | None = None,
343
357
  ) -> LoadManagedTableResult:
344
358
  if (upload_id is None) == (file is None):
345
359
  raise ValueError("Exactly one of upload_id or file is required")
346
- db = self.resolve_managed_database(database)
360
+ db = self._as_managed_database(database)
347
361
  if upload_id is not None:
348
362
  resolved_upload_id = upload_id
349
363
  else:
@@ -352,6 +366,7 @@ class HotdataClient:
352
366
  request = LoadManagedTableRequest(
353
367
  mode=mode,
354
368
  upload_id=resolved_upload_id,
369
+ key=key,
355
370
  )
356
371
  try:
357
372
  loaded = self.connections().load_managed_table(
@@ -372,7 +387,7 @@ class HotdataClient:
372
387
 
373
388
  def add_managed_table(
374
389
  self,
375
- database: str,
390
+ database: str | ManagedDatabase,
376
391
  table: str,
377
392
  *,
378
393
  schema: str = DEFAULT_SCHEMA,
@@ -385,7 +400,7 @@ class HotdataClient:
385
400
  schema after creation without recreating it. ``key`` sets the
386
401
  row-identity columns for delete/update/upsert; omit for keyless.
387
402
  """
388
- db = self.resolve_managed_database(database)
403
+ db = self._as_managed_database(database)
389
404
  request = AddManagedTableRequest(name=table, key=list(key or []))
390
405
  try:
391
406
  self._databases_api().add_database_table(db.id, schema, request)
@@ -401,12 +416,12 @@ class HotdataClient:
401
416
 
402
417
  def delete_managed_table(
403
418
  self,
404
- database: str,
419
+ database: str | ManagedDatabase,
405
420
  table: str,
406
421
  *,
407
422
  schema: str = DEFAULT_SCHEMA,
408
423
  ) -> None:
409
- db = self.resolve_managed_database(database)
424
+ db = self._as_managed_database(database)
410
425
  try:
411
426
  self.connections().delete_managed_table(db.default_connection_id, schema, table)
412
427
  except ApiException as e:
@@ -567,16 +582,19 @@ class HotdataClient:
567
582
  f"(last status: {getattr(last, 'status', None)})"
568
583
  )
569
584
 
570
- def execute_sql(self, sql: str, *, database: str | None = None) -> QueryResult:
585
+ def execute_sql(
586
+ self, sql: str, *, database: str | ManagedDatabase | None = None
587
+ ) -> QueryResult:
571
588
  """Execute SQL and return a :class:`QueryResult`.
572
589
 
573
- Pass ``database`` to scope the query to a managed database. The name
574
- is resolved to a database ID once before the retry loop, and the
590
+ Pass ``database`` to scope the query to a managed database. A name or
591
+ id is resolved to a database ID once before the retry loop; an
592
+ already-resolved ``ManagedDatabase`` is used as-is (no read probe). The
575
593
  ``X-Database-Id`` header is sent with every attempt. Inside a managed
576
594
  database the built-in catalog is always ``"default"``, so table
577
595
  references should use ``"default"."<schema>"."<table>"``.
578
596
  """
579
- database_id = self.resolve_managed_database(database).id if database else None
597
+ database_id = self._as_managed_database(database).id if database else None
580
598
  last_err: BaseException | None = None
581
599
  for attempt in range(3):
582
600
  try:
@@ -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.3"
7
+ version = "0.9.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
  ]
@@ -4,11 +4,134 @@ from types import SimpleNamespace
4
4
  from unittest.mock import patch
5
5
 
6
6
  import pytest
7
+ from hotdata.exceptions import ForbiddenException
7
8
 
8
9
  from hotdata_framework.client import HotdataClient
10
+ from hotdata_framework.databases import ManagedDatabase
9
11
  from hotdata_framework.env import normalize_host, pick_workspace, resolve_workspace_selection
10
12
 
11
13
 
14
+ class _ForbiddenDatabasesApi:
15
+ """A `/databases` API that a create-scoped key would see: every read is 403,
16
+ while the declare-table write succeeds. Counts reads so tests can assert none
17
+ happened."""
18
+
19
+ def __init__(self) -> None:
20
+ self.read_calls = 0
21
+ self.add_calls: list[tuple[str, str, str]] = []
22
+
23
+ def get_database(self, database_id: str):
24
+ self.read_calls += 1
25
+ raise ForbiddenException(status=403)
26
+
27
+ def list_databases(self):
28
+ self.read_calls += 1
29
+ raise ForbiddenException(status=403)
30
+
31
+ def add_database_table(self, database_id, var_schema, request):
32
+ self.add_calls.append((database_id, var_schema, request.name))
33
+ return SimpleNamespace(
34
+ connection_id="conn", var_schema=var_schema, table=request.name
35
+ )
36
+
37
+
38
+ class _FakeConnectionsApi:
39
+ def __init__(self) -> None:
40
+ self.load_calls: list[tuple[str, str, str]] = []
41
+
42
+ def load_managed_table(self, connection_id, schema, table, request):
43
+ self.load_calls.append((connection_id, schema, table))
44
+ return SimpleNamespace(
45
+ connection_id=connection_id,
46
+ schema_name=schema,
47
+ table_name=table,
48
+ row_count=3,
49
+ )
50
+
51
+
52
+ def test_load_managed_table_with_object_skips_read_probe():
53
+ client = HotdataClient("k", "ws", host="https://api.hotdata.dev")
54
+ db = ManagedDatabase(id="db_1", description="mydb", default_connection_id="conn_1")
55
+ databases = _ForbiddenDatabasesApi()
56
+ connections = _FakeConnectionsApi()
57
+
58
+ with (
59
+ patch.object(client, "_databases_api", return_value=databases),
60
+ patch.object(client, "connections", return_value=connections),
61
+ ):
62
+ result = client.load_managed_table(db, "orders", schema="public", upload_id="up_1")
63
+
64
+ assert databases.read_calls == 0
65
+ assert connections.load_calls == [("conn_1", "public", "orders")]
66
+ assert result.full_name == "db_1.public.orders"
67
+ assert result.row_count == 3
68
+
69
+
70
+ def test_add_managed_table_with_object_skips_read_probe():
71
+ client = HotdataClient("k", "ws", host="https://api.hotdata.dev")
72
+ db = ManagedDatabase(id="db_1", description="mydb", default_connection_id="conn_1")
73
+ databases = _ForbiddenDatabasesApi()
74
+
75
+ with patch.object(client, "_databases_api", return_value=databases):
76
+ result = client.add_managed_table(db, "orders", schema="public")
77
+
78
+ assert databases.read_calls == 0
79
+ assert databases.add_calls == [("db_1", "public", "orders")]
80
+ assert result.full_name == "db_1.public.orders"
81
+
82
+
83
+ def test_execute_sql_with_object_skips_read_probe():
84
+ from hotdata.models.query_response import QueryResponse as _QR
85
+
86
+ client = HotdataClient("k", "ws", host="https://api.hotdata.dev")
87
+ db = ManagedDatabase(id="db_abc", description="mydb", default_connection_id="conn_1")
88
+ databases = _ForbiddenDatabasesApi()
89
+
90
+ class FakeQueryApi:
91
+ def __init__(self):
92
+ self.calls: list[dict] = []
93
+
94
+ def query(self, request, **kwargs):
95
+ self.calls.append(kwargs)
96
+ return _QR(
97
+ columns=["n"],
98
+ rows=[[1]],
99
+ row_count=1,
100
+ preview_row_count=1,
101
+ truncated=False,
102
+ nullable=[False],
103
+ result_id="res_1",
104
+ query_run_id="qrun_1",
105
+ execution_time_ms=1,
106
+ )
107
+
108
+ fake_q = FakeQueryApi()
109
+ with (
110
+ patch.object(client, "_query_api", return_value=fake_q),
111
+ patch.object(client, "_databases_api", return_value=databases),
112
+ ):
113
+ client.execute_sql("SELECT 1", database=db)
114
+
115
+ assert databases.read_calls == 0
116
+ assert fake_q.calls == [{"x_database_id": "db_abc"}]
117
+
118
+
119
+ def test_load_managed_table_with_name_still_resolves():
120
+ client = HotdataClient("k", "ws", host="https://api.hotdata.dev")
121
+ connections = _FakeConnectionsApi()
122
+ resolved = ManagedDatabase(id="db_1", description="mydb", default_connection_id="conn_1")
123
+
124
+ with (
125
+ patch.object(client, "resolve_managed_database", return_value=resolved) as resolve,
126
+ patch.object(client, "connections", return_value=connections),
127
+ ):
128
+ result = client.load_managed_table("mydb", "orders", schema="public", upload_id="up_1")
129
+
130
+ resolve.assert_called_once_with("mydb")
131
+ assert connections.load_calls == [("conn_1", "public", "orders")]
132
+ assert result.full_name == "db_1.public.orders"
133
+
134
+
12
135
  def _clear_workspace_env(monkeypatch: pytest.MonkeyPatch) -> None:
13
136
  monkeypatch.delenv("HOTDATA_WORKSPACE", raising=False)
14
137
 
@@ -308,6 +308,16 @@ def test_load_managed_table_defaults_to_replace():
308
308
  assert _load_and_capture_request(_client()).mode == "replace"
309
309
 
310
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
+
311
321
  @pytest.mark.parametrize("mode", ["append", "delete", "update", "upsert"])
312
322
  def test_load_managed_table_forwards_mode(mode: str):
313
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.3"
104
+ version = "0.9.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
  ]