hotdata-framework 0.8.0__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.8.0 → hotdata_framework-0.9.0}/CHANGELOG.md +13 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/CONTRACT.md +3 -2
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/PKG-INFO +1 -1
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/hotdata_framework/client.py +30 -14
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/pyproject.toml +1 -1
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/tests/test_client.py +123 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/uv.lock +1 -1
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/.github/dependabot.yml +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/.github/workflows/check-release.yml +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/.github/workflows/ci.yml +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/.github/workflows/dependabot-automerge.yml +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/.github/workflows/publish.yml +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/.github/workflows/release.yml +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/.gitignore +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/README.md +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/RELEASING.md +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/examples/basic_usage.py +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/hotdata_framework/__init__.py +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/hotdata_framework/databases.py +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/hotdata_framework/env.py +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/hotdata_framework/errors.py +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/hotdata_framework/health.py +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/hotdata_framework/http.py +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/hotdata_framework/managed_client.py +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/hotdata_framework/py.typed +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/hotdata_framework/result.py +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/scripts/check-release.py +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/scripts/extract-changelog.py +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/scripts/publish-workflow.sh +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/scripts/release.sh +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/scripts/update_changelog.py +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/tests/test_contract.py +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/tests/test_databases.py +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/tests/test_errors.py +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/tests/test_health.py +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/tests/test_managed_client.py +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/tests/test_request_timeout.py +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/tests/test_result.py +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/tests/test_update_changelog.py +0 -0
- {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/tests/test_version.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
* @hotdata-dev/engineers
|
|
@@ -8,6 +8,19 @@ 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
|
+
|
|
11
24
|
## [0.8.0] - 2026-07-20
|
|
12
25
|
|
|
13
26
|
### 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
|
|
@@ -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,7 +346,7 @@ 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,
|
|
@@ -344,7 +357,7 @@ class HotdataClient:
|
|
|
344
357
|
) -> LoadManagedTableResult:
|
|
345
358
|
if (upload_id is None) == (file is None):
|
|
346
359
|
raise ValueError("Exactly one of upload_id or file is required")
|
|
347
|
-
db = self.
|
|
360
|
+
db = self._as_managed_database(database)
|
|
348
361
|
if upload_id is not None:
|
|
349
362
|
resolved_upload_id = upload_id
|
|
350
363
|
else:
|
|
@@ -374,7 +387,7 @@ class HotdataClient:
|
|
|
374
387
|
|
|
375
388
|
def add_managed_table(
|
|
376
389
|
self,
|
|
377
|
-
database: str,
|
|
390
|
+
database: str | ManagedDatabase,
|
|
378
391
|
table: str,
|
|
379
392
|
*,
|
|
380
393
|
schema: str = DEFAULT_SCHEMA,
|
|
@@ -387,7 +400,7 @@ class HotdataClient:
|
|
|
387
400
|
schema after creation without recreating it. ``key`` sets the
|
|
388
401
|
row-identity columns for delete/update/upsert; omit for keyless.
|
|
389
402
|
"""
|
|
390
|
-
db = self.
|
|
403
|
+
db = self._as_managed_database(database)
|
|
391
404
|
request = AddManagedTableRequest(name=table, key=list(key or []))
|
|
392
405
|
try:
|
|
393
406
|
self._databases_api().add_database_table(db.id, schema, request)
|
|
@@ -403,12 +416,12 @@ class HotdataClient:
|
|
|
403
416
|
|
|
404
417
|
def delete_managed_table(
|
|
405
418
|
self,
|
|
406
|
-
database: str,
|
|
419
|
+
database: str | ManagedDatabase,
|
|
407
420
|
table: str,
|
|
408
421
|
*,
|
|
409
422
|
schema: str = DEFAULT_SCHEMA,
|
|
410
423
|
) -> None:
|
|
411
|
-
db = self.
|
|
424
|
+
db = self._as_managed_database(database)
|
|
412
425
|
try:
|
|
413
426
|
self.connections().delete_managed_table(db.default_connection_id, schema, table)
|
|
414
427
|
except ApiException as e:
|
|
@@ -569,16 +582,19 @@ class HotdataClient:
|
|
|
569
582
|
f"(last status: {getattr(last, 'status', None)})"
|
|
570
583
|
)
|
|
571
584
|
|
|
572
|
-
def execute_sql(
|
|
585
|
+
def execute_sql(
|
|
586
|
+
self, sql: str, *, database: str | ManagedDatabase | None = None
|
|
587
|
+
) -> QueryResult:
|
|
573
588
|
"""Execute SQL and return a :class:`QueryResult`.
|
|
574
589
|
|
|
575
|
-
Pass ``database`` to scope the query to a managed database.
|
|
576
|
-
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
|
|
577
593
|
``X-Database-Id`` header is sent with every attempt. Inside a managed
|
|
578
594
|
database the built-in catalog is always ``"default"``, so table
|
|
579
595
|
references should use ``"default"."<schema>"."<table>"``.
|
|
580
596
|
"""
|
|
581
|
-
database_id = self.
|
|
597
|
+
database_id = self._as_managed_database(database).id if database else None
|
|
582
598
|
last_err: BaseException | None = None
|
|
583
599
|
for attempt in range(3):
|
|
584
600
|
try:
|
|
@@ -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"
|
|
@@ -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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hotdata_framework-0.8.0 → 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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|