Skip to content

Commit cbccf1e

Browse files
committed
feat: add managed database helpers to HotdataClient
Expose create/list/load/delete managed database operations through the runtime contract, mirroring hotdata-cli and the latest SDK managed-table APIs.
1 parent 7c73a21 commit cbccf1e

9 files changed

Lines changed: 513 additions & 6 deletions

File tree

CONTRACT.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ The supported import surface is:
3030
- `ResultSummary`
3131
- `RunHistoryItem`
3232
- `WorkspaceSelection`
33+
- `ManagedDatabase`
34+
- `ManagedTable`
35+
- `LoadManagedTableResult`
36+
- `MANAGED_SOURCE_TYPE`
37+
- `DEFAULT_SCHEMA`
38+
- `build_managed_config`
39+
- `create_connection_request`
40+
- `is_parquet_path`
3341

3442
Adapters should import from `hotdata_runtime` and treat this surface as the stable API.
3543

@@ -49,6 +57,15 @@ Adapters should import from `hotdata_runtime` and treat this surface as the stab
4957
- `list_qualified_table_names(...)` returns sorted fully qualified table names.
5058
- `columns_for_qualified(qualified, connection_id=...)` resolves table columns, and
5159
adapters should pass `connection_id` when known.
60+
- `uploads()` returns the uploads API wrapper for parquet staging.
61+
- `list_managed_databases()` returns managed-catalog connections (`source_type: managed`).
62+
- `resolve_managed_database(name_or_id)` resolves a managed database by name or id.
63+
- `create_managed_database(name, schema=..., tables=...)` creates a managed database and optionally declares tables up front.
64+
- `delete_managed_database(name_or_id)` deletes a managed database connection.
65+
- `list_managed_tables(database, schema=...)` lists tables in a managed database.
66+
- `upload_parquet(path)` uploads a local parquet file and returns an upload id.
67+
- `load_managed_table(database, table, schema=..., upload_id=..., file=...)` publishes parquet data into a declared managed table.
68+
- `delete_managed_table(database, table, schema=...)` deletes a managed table.
5269

5370
### `QueryResult`
5471

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Runtime boundary and guarantees are defined in `CONTRACT.md`.
1313
- **SQL execution helper** — run SQL through `POST /v1/query`, poll async query runs when needed, and return a `QueryResult`.
1414
- **Result utilities** — convert query results to records, pandas DataFrames, or metadata dictionaries for adapter display layers.
1515
- **History helpers** — list recent results and query run history with normalized dataclasses.
16+
- **Managed databases** — create Hotdata-owned catalogs, declare tables, upload parquet, and load managed tables (mirrors `hotdata databases` in the CLI).
1617
- **Health helpers** — build compact API/workspace health summaries for UI integrations.
1718

1819
Install:

hotdata_runtime/__init__.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@
88
RunHistoryItem,
99
from_env,
1010
)
11+
from hotdata_runtime.databases import (
12+
DEFAULT_SCHEMA,
13+
LoadManagedTableResult,
14+
ManagedDatabase,
15+
ManagedTable,
16+
MANAGED_SOURCE_TYPE,
17+
build_managed_config,
18+
create_connection_request,
19+
is_parquet_path,
20+
)
1121
from hotdata_runtime.env import (
1222
default_api_key,
1323
default_host,
@@ -29,8 +39,16 @@
2939

3040
__all__ = [
3141
"__version__",
42+
"DEFAULT_SCHEMA",
3243
"HotdataClient",
44+
"LoadManagedTableResult",
45+
"MANAGED_SOURCE_TYPE",
46+
"ManagedDatabase",
47+
"ManagedTable",
3348
"QueryResult",
49+
"build_managed_config",
50+
"create_connection_request",
51+
"is_parquet_path",
3452
"workspace_health_lines",
3553
"default_api_key",
3654
"default_host",

hotdata_runtime/client.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@
1313
from hotdata.api.query_api import QueryApi
1414
from hotdata.api.query_runs_api import QueryRunsApi
1515
from hotdata.api.results_api import ResultsApi
16+
from hotdata.api.uploads_api import UploadsApi
1617
from hotdata.exceptions import ApiException
1718
from hotdata.models.async_query_response import AsyncQueryResponse
1819
from hotdata.models.query_request import QueryRequest
1920
from hotdata.models.query_response import QueryResponse
21+
from hotdata.models.load_managed_table_request import LoadManagedTableRequest
2022
from hotdata.models.table_info import TableInfo
2123

2224
from hotdata_runtime.env import (
@@ -26,6 +28,17 @@
2628
normalize_host,
2729
pick_workspace,
2830
)
31+
from hotdata_runtime.databases import (
32+
DEFAULT_SCHEMA,
33+
LoadManagedTableResult,
34+
ManagedDatabase,
35+
ManagedTable,
36+
MANAGED_SOURCE_TYPE,
37+
_api_error,
38+
_managed_database,
39+
create_connection_request,
40+
is_parquet_path,
41+
)
2942
from hotdata_runtime.http import default_http_retries
3043
from hotdata_runtime.result import QueryResult
3144

@@ -135,6 +148,140 @@ def query_runs(self) -> QueryRunsApi:
135148
def results(self) -> ResultsApi:
136149
return self._results_api()
137150

151+
def uploads(self) -> UploadsApi:
152+
return UploadsApi(self._api)
153+
154+
def list_managed_databases(self) -> list[ManagedDatabase]:
155+
listing = self.connections().list_connections()
156+
return [
157+
_managed_database(c)
158+
for c in listing.connections
159+
if c.source_type == MANAGED_SOURCE_TYPE
160+
]
161+
162+
def resolve_managed_database(self, name_or_id: str) -> ManagedDatabase:
163+
listing = self.connections().list_connections()
164+
match = None
165+
for c in listing.connections:
166+
if c.id == name_or_id or c.name == name_or_id:
167+
match = c
168+
break
169+
if match is None:
170+
raise KeyError(f"No database named or with id {name_or_id!r}")
171+
if match.source_type != MANAGED_SOURCE_TYPE:
172+
raise ValueError(
173+
f"{match.name!r} is not a managed database "
174+
f"(source_type: {match.source_type})"
175+
)
176+
return _managed_database(match)
177+
178+
def create_managed_database(
179+
self,
180+
name: str,
181+
*,
182+
schema: str = DEFAULT_SCHEMA,
183+
tables: list[str] | None = None,
184+
) -> ManagedDatabase:
185+
request = create_connection_request(name, schema=schema, tables=tables)
186+
try:
187+
created = self.connections().create_connection(request)
188+
except ApiException as e:
189+
raise RuntimeError(_api_error(e)) from e
190+
return _managed_database(created)
191+
192+
def delete_managed_database(self, name_or_id: str) -> None:
193+
db = self.resolve_managed_database(name_or_id)
194+
try:
195+
self.connections().delete_connection(db.id)
196+
except ApiException as e:
197+
raise RuntimeError(_api_error(e)) from e
198+
199+
def list_managed_tables(
200+
self,
201+
database: str,
202+
*,
203+
schema: str | None = None,
204+
) -> list[ManagedTable]:
205+
db = self.resolve_managed_database(database)
206+
rows: list[ManagedTable] = []
207+
for t in self.iter_tables(connection_id=db.id):
208+
if schema is not None and t.var_schema != schema:
209+
continue
210+
rows.append(
211+
ManagedTable(
212+
full_name=f"{db.name}.{t.var_schema}.{t.table}",
213+
schema=t.var_schema,
214+
table=t.table,
215+
synced=t.synced,
216+
last_sync=t.last_sync,
217+
)
218+
)
219+
rows.sort(key=lambda row: (row.schema, row.table))
220+
return rows
221+
222+
def upload_parquet(self, path: str) -> str:
223+
if not is_parquet_path(path):
224+
raise ValueError(
225+
f"Managed table loads require a parquet file (got {path!r})"
226+
)
227+
with open(path, "rb") as f:
228+
data = f.read()
229+
try:
230+
uploaded = self.uploads().upload_file(
231+
data,
232+
_content_type="application/octet-stream",
233+
)
234+
except ApiException as e:
235+
raise RuntimeError(_api_error(e)) from e
236+
return uploaded.id
237+
238+
def load_managed_table(
239+
self,
240+
database: str,
241+
table: str,
242+
*,
243+
schema: str = DEFAULT_SCHEMA,
244+
upload_id: str | None = None,
245+
file: str | None = None,
246+
) -> LoadManagedTableResult:
247+
if (upload_id is None) == (file is None):
248+
raise ValueError("Exactly one of upload_id or file is required")
249+
db = self.resolve_managed_database(database)
250+
resolved_upload_id = upload_id or self.upload_parquet(file or "")
251+
request = LoadManagedTableRequest(
252+
mode="replace",
253+
upload_id=resolved_upload_id,
254+
)
255+
try:
256+
loaded = self.connections().load_managed_table(
257+
db.id,
258+
schema,
259+
table,
260+
request,
261+
)
262+
except ApiException as e:
263+
raise RuntimeError(_api_error(e)) from e
264+
return LoadManagedTableResult(
265+
connection_id=loaded.connection_id,
266+
schema_name=loaded.schema_name,
267+
table_name=loaded.table_name,
268+
row_count=loaded.row_count,
269+
full_name=f"{db.name}.{loaded.schema_name}.{loaded.table_name}",
270+
)
271+
272+
def delete_managed_table(
273+
self,
274+
database: str,
275+
table: str,
276+
*,
277+
schema: str = DEFAULT_SCHEMA,
278+
) -> None:
279+
db = self.resolve_managed_database(database)
280+
try:
281+
self.connections().delete_managed_table(db.id, schema, table)
282+
except ApiException as e:
283+
raise RuntimeError(_api_error(e)) from e
284+
138285
def list_recent_results(
139286
self,
140287
*,

hotdata_runtime/databases.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Managed database helpers (Hotdata-owned catalogs with parquet table loads)."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import asdict, dataclass
6+
from pathlib import Path
7+
from typing import Any
8+
9+
from hotdata.exceptions import ApiException
10+
from hotdata.models.create_connection_request import CreateConnectionRequest
11+
from hotdata.models.load_managed_table_request import LoadManagedTableRequest
12+
13+
MANAGED_SOURCE_TYPE = "managed"
14+
DEFAULT_SCHEMA = "public"
15+
16+
17+
@dataclass(frozen=True)
18+
class ManagedDatabase:
19+
id: str
20+
name: str
21+
source_type: str
22+
23+
def to_dict(self) -> dict[str, Any]:
24+
return asdict(self)
25+
26+
27+
@dataclass(frozen=True)
28+
class ManagedTable:
29+
full_name: str
30+
schema: str
31+
table: str
32+
synced: bool
33+
last_sync: str | None
34+
35+
def to_dict(self) -> dict[str, Any]:
36+
return asdict(self)
37+
38+
39+
@dataclass(frozen=True)
40+
class LoadManagedTableResult:
41+
connection_id: str
42+
schema_name: str
43+
table_name: str
44+
row_count: int
45+
full_name: str
46+
47+
def to_dict(self) -> dict[str, Any]:
48+
return asdict(self)
49+
50+
51+
def is_parquet_path(path: str) -> bool:
52+
lowered = path.lower()
53+
if lowered.endswith(".parquet"):
54+
return True
55+
return Path(path).suffix.lower() == ".parquet"
56+
57+
58+
def build_managed_config(schema: str, tables: list[str]) -> dict[str, Any]:
59+
if not tables:
60+
return {}
61+
return {
62+
"schemas": [
63+
{
64+
"name": schema,
65+
"tables": [{"name": table} for table in tables],
66+
}
67+
]
68+
}
69+
70+
71+
def create_connection_request(
72+
name: str,
73+
*,
74+
schema: str = DEFAULT_SCHEMA,
75+
tables: list[str] | None = None,
76+
) -> CreateConnectionRequest:
77+
table_list = tables or []
78+
return CreateConnectionRequest(
79+
name=name,
80+
source_type=MANAGED_SOURCE_TYPE,
81+
config=build_managed_config(schema, table_list),
82+
skip_discovery=True,
83+
)
84+
85+
86+
def _managed_database(conn: Any) -> ManagedDatabase:
87+
return ManagedDatabase(
88+
id=str(conn.id),
89+
name=str(conn.name),
90+
source_type=str(conn.source_type),
91+
)
92+
93+
94+
def _api_error(exc: ApiException) -> str:
95+
return exc.reason or str(exc)

pyproject.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
requires = ["hatchling"]
33
build-backend = "hatchling.build"
44

5+
[tool.hatch.metadata]
6+
allow-direct-references = true
7+
58
[project]
69
name = "hotdata-runtime"
710
version = "0.1.0"
@@ -10,7 +13,7 @@ readme = "README.md"
1013
requires-python = ">=3.10"
1114
license = { text = "MIT" }
1215
dependencies = [
13-
"hotdata>=0.1.0",
16+
"hotdata @ git+https://github.com/hotdata-dev/sdk-python.git",
1417
"pandas>=2.0",
1518
]
1619

@@ -23,6 +26,9 @@ dev = [
2326
[tool.uv]
2427
default-groups = ["dev"]
2528

29+
[tool.uv.sources]
30+
hotdata = { path = "../sdk-python", editable = true }
31+
2632
[tool.hatch.build.targets.wheel]
2733
packages = ["hotdata_runtime"]
2834

tests/test_contract.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,16 @@
1111
def test_public_exports_contract():
1212
assert hr.__all__ == [
1313
"__version__",
14+
"DEFAULT_SCHEMA",
1415
"HotdataClient",
16+
"LoadManagedTableResult",
17+
"MANAGED_SOURCE_TYPE",
18+
"ManagedDatabase",
19+
"ManagedTable",
1520
"QueryResult",
21+
"build_managed_config",
22+
"create_connection_request",
23+
"is_parquet_path",
1624
"workspace_health_lines",
1725
"default_api_key",
1826
"default_host",

0 commit comments

Comments
 (0)