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.
Files changed (41) hide show
  1. hotdata_framework-0.9.0/.github/CODEOWNERS +1 -0
  2. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/CHANGELOG.md +13 -0
  3. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/CONTRACT.md +3 -2
  4. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/PKG-INFO +1 -1
  5. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/hotdata_framework/client.py +30 -14
  6. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/pyproject.toml +1 -1
  7. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/tests/test_client.py +123 -0
  8. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/uv.lock +1 -1
  9. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/.github/dependabot.yml +0 -0
  10. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/.github/workflows/check-release.yml +0 -0
  11. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/.github/workflows/ci.yml +0 -0
  12. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/.github/workflows/dependabot-automerge.yml +0 -0
  13. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/.github/workflows/publish.yml +0 -0
  14. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/.github/workflows/release.yml +0 -0
  15. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/.gitignore +0 -0
  16. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/README.md +0 -0
  17. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/RELEASING.md +0 -0
  18. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/examples/basic_usage.py +0 -0
  19. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/hotdata_framework/__init__.py +0 -0
  20. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/hotdata_framework/databases.py +0 -0
  21. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/hotdata_framework/env.py +0 -0
  22. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/hotdata_framework/errors.py +0 -0
  23. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/hotdata_framework/health.py +0 -0
  24. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/hotdata_framework/http.py +0 -0
  25. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/hotdata_framework/managed_client.py +0 -0
  26. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/hotdata_framework/py.typed +0 -0
  27. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/hotdata_framework/result.py +0 -0
  28. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/scripts/check-release.py +0 -0
  29. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/scripts/extract-changelog.py +0 -0
  30. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/scripts/publish-workflow.sh +0 -0
  31. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/scripts/release.sh +0 -0
  32. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/scripts/update_changelog.py +0 -0
  33. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/tests/test_contract.py +0 -0
  34. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/tests/test_databases.py +0 -0
  35. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/tests/test_errors.py +0 -0
  36. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/tests/test_health.py +0 -0
  37. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/tests/test_managed_client.py +0 -0
  38. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/tests/test_request_timeout.py +0 -0
  39. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/tests/test_result.py +0 -0
  40. {hotdata_framework-0.8.0 → hotdata_framework-0.9.0}/tests/test_update_changelog.py +0 -0
  41. {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.8.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.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,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.resolve_managed_database(database)
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.resolve_managed_database(database)
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.resolve_managed_database(database)
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(self, sql: str, *, database: str | None = None) -> QueryResult:
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. The name
576
- 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
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.resolve_managed_database(database).id if database else None
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.8.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
 
@@ -101,7 +101,7 @@ wheels = [
101
101
 
102
102
  [[package]]
103
103
  name = "hotdata-framework"
104
- version = "0.8.0"
104
+ version = "0.9.0"
105
105
  source = { editable = "." }
106
106
  dependencies = [
107
107
  { name = "hotdata" },