Skip to content

Commit 82a05bb

Browse files
authored
Merge pull request #4 from hotdata-dev/feat/managed-databases
feat: add managed database helpers to HotdataClient
2 parents ba9934c + 2d3d410 commit 82a05bb

9 files changed

Lines changed: 514 additions & 9 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: 151 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_message,
38+
create_connection_request,
39+
is_parquet_path,
40+
managed_database_from_connection,
41+
)
2942
from hotdata_runtime.http import default_http_retries
3043
from hotdata_runtime.result import QueryResult
3144

@@ -135,6 +148,144 @@ 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_from_connection(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_from_connection(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_message(e)) from e
190+
return managed_database_from_connection(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_message(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_message(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+
if upload_id is not None:
251+
resolved_upload_id = upload_id
252+
else:
253+
assert file is not None
254+
resolved_upload_id = self.upload_parquet(file)
255+
request = LoadManagedTableRequest(
256+
mode="replace",
257+
upload_id=resolved_upload_id,
258+
)
259+
try:
260+
loaded = self.connections().load_managed_table(
261+
db.id,
262+
schema,
263+
table,
264+
request,
265+
)
266+
except ApiException as e:
267+
raise RuntimeError(api_error_message(e)) from e
268+
return LoadManagedTableResult(
269+
connection_id=loaded.connection_id,
270+
schema_name=loaded.schema_name,
271+
table_name=loaded.table_name,
272+
row_count=loaded.row_count,
273+
full_name=f"{db.name}.{loaded.schema_name}.{loaded.table_name}",
274+
)
275+
276+
def delete_managed_table(
277+
self,
278+
database: str,
279+
table: str,
280+
*,
281+
schema: str = DEFAULT_SCHEMA,
282+
) -> None:
283+
db = self.resolve_managed_database(database)
284+
try:
285+
self.connections().delete_managed_table(db.id, schema, table)
286+
except ApiException as e:
287+
raise RuntimeError(api_error_message(e)) from e
288+
138289
def list_recent_results(
139290
self,
140291
*,

hotdata_runtime/databases.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
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+
12+
MANAGED_SOURCE_TYPE = "managed"
13+
DEFAULT_SCHEMA = "public"
14+
15+
16+
@dataclass(frozen=True)
17+
class ManagedDatabase:
18+
id: str
19+
name: str
20+
source_type: str
21+
22+
def to_dict(self) -> dict[str, Any]:
23+
return asdict(self)
24+
25+
26+
@dataclass(frozen=True)
27+
class ManagedTable:
28+
full_name: str
29+
schema: str
30+
table: str
31+
synced: bool
32+
last_sync: str | None
33+
34+
def to_dict(self) -> dict[str, Any]:
35+
return asdict(self)
36+
37+
38+
@dataclass(frozen=True)
39+
class LoadManagedTableResult:
40+
connection_id: str
41+
schema_name: str
42+
table_name: str
43+
row_count: int
44+
full_name: str
45+
46+
def to_dict(self) -> dict[str, Any]:
47+
return asdict(self)
48+
49+
50+
def is_parquet_path(path: str) -> bool:
51+
return Path(path).suffix.lower() == ".parquet"
52+
53+
54+
def build_managed_config(schema: str, tables: list[str]) -> dict[str, Any]:
55+
if not tables:
56+
return {}
57+
return {
58+
"schemas": [
59+
{
60+
"name": schema,
61+
"tables": [{"name": table} for table in tables],
62+
}
63+
]
64+
}
65+
66+
67+
def create_connection_request(
68+
name: str,
69+
*,
70+
schema: str = DEFAULT_SCHEMA,
71+
tables: list[str] | None = None,
72+
) -> CreateConnectionRequest:
73+
table_list = tables or []
74+
return CreateConnectionRequest(
75+
name=name,
76+
source_type=MANAGED_SOURCE_TYPE,
77+
config=build_managed_config(schema, table_list),
78+
skip_discovery=True,
79+
)
80+
81+
82+
def managed_database_from_connection(conn: Any) -> ManagedDatabase:
83+
return ManagedDatabase(
84+
id=str(conn.id),
85+
name=str(conn.name),
86+
source_type=str(conn.source_type),
87+
)
88+
89+
90+
def api_error_message(exc: ApiException) -> str:
91+
return exc.reason or str(exc)

pyproject.toml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "hotdata-runtime"
7-
version = "0.1.0"
7+
version = "0.1.1"
88
description = "Workspace/session runtime primitives for Hotdata integrations"
99
readme = "README.md"
1010
requires-python = ">=3.10"
1111
license = { text = "MIT" }
1212
dependencies = [
13-
"hotdata>=0.1.0",
13+
"hotdata>=0.2.0",
1414
"pandas>=2.0",
1515
]
1616

@@ -23,6 +23,10 @@ dev = [
2323
[tool.uv]
2424
default-groups = ["dev"]
2525

26+
# Resolve hotdata from a sibling checkout until v0.2.0 is on PyPI.
27+
[tool.uv.sources]
28+
hotdata = { path = "../sdk-python", editable = true }
29+
2630
[tool.hatch.build.targets.wheel]
2731
packages = ["hotdata_runtime"]
2832

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)