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.
- hotdata_framework-0.9.0/.github/CODEOWNERS +1 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/CHANGELOG.md +22 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/CONTRACT.md +3 -2
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/PKG-INFO +2 -2
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/hotdata_framework/client.py +32 -14
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/hotdata_framework/managed_client.py +6 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/pyproject.toml +2 -2
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/tests/test_client.py +123 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/tests/test_databases.py +10 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/tests/test_managed_client.py +38 -1
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/uv.lock +5 -5
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/.github/dependabot.yml +0 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/.github/workflows/check-release.yml +0 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/.github/workflows/ci.yml +0 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/.github/workflows/dependabot-automerge.yml +0 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/.github/workflows/publish.yml +0 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/.github/workflows/release.yml +0 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/.gitignore +0 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/README.md +0 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/RELEASING.md +0 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/examples/basic_usage.py +0 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/hotdata_framework/__init__.py +0 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/hotdata_framework/databases.py +0 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/hotdata_framework/env.py +0 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/hotdata_framework/errors.py +0 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/hotdata_framework/health.py +0 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/hotdata_framework/http.py +0 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/hotdata_framework/py.typed +0 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/hotdata_framework/result.py +0 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/scripts/check-release.py +0 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/scripts/extract-changelog.py +0 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/scripts/publish-workflow.sh +0 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/scripts/release.sh +0 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/scripts/update_changelog.py +0 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/tests/test_contract.py +0 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/tests/test_errors.py +0 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/tests/test_health.py +0 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/tests/test_request_timeout.py +0 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/tests/test_result.py +0 -0
- {hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/tests/test_update_changelog.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
|
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.
|
|
574
|
-
is resolved to a database ID once before the retry loop
|
|
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.
|
|
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
|
+
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.
|
|
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,
|
|
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.
|
|
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/
|
|
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/
|
|
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.
|
|
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.
|
|
123
|
+
{ name = "hotdata", specifier = ">=0.8.0" },
|
|
124
124
|
{ name = "pandas", specifier = ">=2.0" },
|
|
125
125
|
{ name = "pyarrow", specifier = ">=14.0" },
|
|
126
126
|
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hotdata_framework-0.7.3 → hotdata_framework-0.9.0}/.github/workflows/dependabot-automerge.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|