Skip to content

Commit 3828f24

Browse files
committed
feat: add managed database widgets for Marimo
Add databases_panel and ManagedDatabaseWriter so notebooks can create Hotdata-owned catalogs and load parquet tables, with demo tab wiring and tests. Depends on hotdata-runtime feat/managed-databases until that lands on main and PyPI.
1 parent 798077b commit 3828f24

9 files changed

Lines changed: 440 additions & 11 deletions

File tree

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Marimo UI helpers for [Hotdata](https://hotdata.dev): run SQL from a notebook, b
1010
- **SQL editor widget** — run SQL against Hotdata, cache the latest successful result, and render results in downstream reactive cells.
1111
- **Native `mo.sql` engine** — register `HotdataMarimoEngine` so Marimo SQL cells can execute through a live `HotdataClient` with `engine=client`.
1212
- **Result display helpers** — render query results, recent results, and run history as notebook-friendly UI.
13+
- **Managed databases** — create Hotdata-owned catalogs, declare tables, and load parquet files (replaces dataset uploads for writes).
1314
- **Marimo UI aliases** — importing `hotdata_marimo` attaches helpers such as `mo.ui.hotdata_sql_editor` and `mo.ui.hotdata_table_browser` for discoverability.
1415

1516
## Install
@@ -81,7 +82,7 @@ See `examples/demo.py` for a full runnable notebook flow.
8182

8283
## Examples
8384

84-
- `examples/demo.py` — tabbed explorer with workspace selection, connection health, recent results (selectable table), run history, and a native `mo.sql` cell.
85+
- `examples/demo.py` — tabbed explorer with workspace selection, connection health, managed databases (create + parquet load), recent results (selectable table), run history, and a native `mo.sql` cell.
8586

8687
Run locally (single-user machine):
8788

@@ -93,7 +94,7 @@ On a **shared or networked host**, omit `--no-token` and use the access token pr
9394

9495
## Layout
9596

96-
This repo is intentionally thin: **API client, env helpers, and result models** live in **hotdata-runtime**; **hotdata-marimo** only adds Marimo widgets (`sql_editor`, `table_browser`, `display` for tables/status/history, `workspace_selector`). Import `HotdataClient` / `QueryResult` / `from_env` from **`hotdata_marimo`** or directly from **`hotdata_runtime`**.
97+
This repo is intentionally thin: **API client, env helpers, and result models** live in **hotdata-runtime**; **hotdata-marimo** only adds Marimo widgets (`sql_editor`, `table_browser`, `managed_database_writer`, `display` for tables/status/history, `workspace_selector`). Import `HotdataClient` / `QueryResult` / `from_env` from **`hotdata_marimo`** or directly from **`hotdata_runtime`**.
9798

9899
## Development
99100

examples/demo.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,34 +36,43 @@ def _(hm, mo, os):
3636
def _(hm, workspace):
3737
client = workspace.client
3838
status = hm.connections_panel(client)
39+
db_writer = hm.managed_database_writer(client)
3940
recent = hm.recent_results(client, limit=20)
4041
history = hm.run_history(client, limit=10)
41-
return client, history, recent, status
42+
return client, db_writer, history, recent, status
4243

4344

4445
@app.cell
4546
def _(mo):
4647
mo.md(r"""
4748
## HotData explorer
48-
Use the tabs below to switch between workspaces, connection status, recent results, and run history.
49+
Use the tabs below to switch between workspaces, connections, managed databases,
50+
recent results, and run history.
4951
5052
On a shared or networked host, run Marimo **without** `--no-token` and open the printed URL
5153
with its access token so only you can use this notebook.
5254
""")
5355
return
5456

5557

58+
@app.cell
59+
def _(db_writer):
60+
databases_tab = db_writer.tab_ui
61+
return (databases_tab,)
62+
63+
5664
@app.cell
5765
def _(recent):
5866
recent_tab = recent.tab_ui
5967
return (recent_tab,)
6068

6169

6270
@app.cell
63-
def _(history, mo, recent_tab, status, workspace):
71+
def _(databases_tab, history, mo, recent_tab, status, workspace):
6472
mo.ui.tabs({
6573
"Workspaces": workspace.ui,
6674
"Connections": status,
75+
"Databases": databases_tab,
6776
"Recent results": recent_tab,
6877
"Run history": history,
6978
})

hotdata_marimo/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99

1010
from hotdata_runtime import HotdataClient, QueryResult, from_env
1111

12+
from hotdata_marimo.databases import (
13+
ManagedDatabaseWriter,
14+
databases_panel,
15+
managed_database_writer,
16+
)
1217
from hotdata_marimo.display import (
1318
RecentResults,
1419
connection_status,
@@ -31,20 +36,25 @@
3136
"HotdataClient",
3237
"HotdataMarimoEngine",
3338
"QueryResult",
39+
"ManagedDatabaseWriter",
3440
"RecentResults",
3541
"SqlEditor",
3642
"TableBrowser",
3743
"WorkspaceSelector",
3844
"connection_picker",
3945
"connection_status",
4046
"connections_panel",
47+
"databases_panel",
4148
"from_env",
4249
"hotdata_connection_picker",
50+
"hotdata_databases_panel",
51+
"hotdata_managed_database_writer",
4352
"hotdata_query_result",
4453
"hotdata_recent_results",
4554
"hotdata_sql_editor",
4655
"hotdata_table_browser",
4756
"hotdata_workspace_selector",
57+
"managed_database_writer",
4858
"query_result",
4959
"recent_results",
5060
"register_hotdata_sql_engine",
@@ -60,6 +70,8 @@
6070
hotdata_table_browser = table_browser
6171
hotdata_query_result = query_result
6272
hotdata_connection_picker = connection_picker
73+
hotdata_databases_panel = databases_panel
74+
hotdata_managed_database_writer = managed_database_writer
6375
hotdata_workspace_selector = workspace_selector_from_env
6476
hotdata_recent_results = recent_results
6577

@@ -73,6 +85,8 @@ def register_mo_ui_hotdata_aliases() -> None:
7385
mo.ui.hotdata_query_result = hotdata_query_result # type: ignore[attr-defined]
7486
mo.ui.hotdata_connection_status = connection_status # type: ignore[attr-defined]
7587
mo.ui.hotdata_connection_picker = hotdata_connection_picker # type: ignore[attr-defined]
88+
mo.ui.hotdata_databases_panel = hotdata_databases_panel # type: ignore[attr-defined]
89+
mo.ui.hotdata_managed_database_writer = hotdata_managed_database_writer # type: ignore[attr-defined]
7690
mo.ui.hotdata_workspace_selector = hotdata_workspace_selector # type: ignore[attr-defined]
7791
mo.ui.hotdata_recent_results = hotdata_recent_results # type: ignore[attr-defined]
7892

hotdata_marimo/databases.py

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
"""Marimo UI for managed Hotdata databases (create + parquet table loads)."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import tempfile
7+
8+
import marimo as mo
9+
10+
from hotdata_runtime import (
11+
DEFAULT_SCHEMA,
12+
HotdataClient,
13+
LoadManagedTableResult,
14+
ManagedDatabase,
15+
)
16+
17+
from hotdata_marimo._options import empty_dropdown
18+
19+
20+
def _parse_table_names(text: str) -> list[str]:
21+
return [line.strip() for line in text.splitlines() if line.strip()]
22+
23+
24+
def _upload_parquet_bytes(client: HotdataClient, contents: bytes) -> str:
25+
with tempfile.NamedTemporaryFile(suffix=".parquet", delete=False) as tmp:
26+
tmp.write(contents)
27+
path = tmp.name
28+
try:
29+
return client.upload_parquet(path)
30+
finally:
31+
os.unlink(path)
32+
33+
34+
def databases_panel(client: HotdataClient):
35+
"""Table of managed databases in the workspace."""
36+
dbs = client.list_managed_databases()
37+
if not dbs:
38+
return mo.vstack(
39+
[
40+
mo.md("### Managed databases"),
41+
mo.md("_No managed databases yet._"),
42+
mo.md(
43+
"Create one below, or with the CLI: "
44+
"`hotdata databases create --name <name> --table <table>`."
45+
),
46+
],
47+
gap=1,
48+
)
49+
rows: list[dict[str, object]] = [
50+
{"name": db.name, "id": db.id, "sql_prefix": f"{db.name}.{{schema}}.{{table}}"}
51+
for db in dbs
52+
]
53+
return mo.vstack(
54+
[
55+
mo.md("### Managed databases"),
56+
mo.ui.table(
57+
rows,
58+
label="Managed databases",
59+
pagination=True,
60+
page_size=min(10, len(rows)),
61+
selection=None,
62+
max_height=240,
63+
),
64+
mo.md("_Query as `database.schema.table` in SQL._"),
65+
],
66+
gap=1,
67+
)
68+
69+
70+
class ManagedDatabaseWriter:
71+
"""Create managed databases and load parquet files into declared tables.
72+
73+
Instantiate in one cell and use ``.tab_ui`` in another (see package README).
74+
"""
75+
76+
def __init__(
77+
self,
78+
client: HotdataClient,
79+
*,
80+
default_schema: str = DEFAULT_SCHEMA,
81+
) -> None:
82+
self._client = client
83+
self._default_schema = default_schema
84+
self._last_create_n: int | None = None
85+
self._last_load_n: int | None = None
86+
self._create_result: ManagedDatabase | None = None
87+
self._load_result: LoadManagedTableResult | None = None
88+
self._create_error: str | None = None
89+
self._load_error: str | None = None
90+
self._show_create_success = False
91+
self._show_load_success = False
92+
93+
self.name = mo.ui.text("", label="Database name", full_width=True)
94+
self.schema = mo.ui.text(default_schema, label="Schema", full_width=True)
95+
self.tables = mo.ui.text_area(
96+
"",
97+
label="Tables to declare (one per line)",
98+
full_width=True,
99+
)
100+
self.create = mo.ui.button(
101+
value=0,
102+
on_click=lambda n: n + 1,
103+
label="Create database",
104+
kind="success",
105+
)
106+
107+
self._rebuild_database_pick()
108+
self.table = mo.ui.text("", label="Table name", full_width=True)
109+
self.file = mo.ui.file(
110+
filetypes=[".parquet"],
111+
label="Parquet file",
112+
kind="area",
113+
)
114+
self.load = mo.ui.button(
115+
value=0,
116+
on_click=lambda n: n + 1,
117+
label="Load table",
118+
kind="success",
119+
)
120+
121+
def _rebuild_database_pick(self) -> None:
122+
dbs = self._client.list_managed_databases()
123+
if not dbs:
124+
self.database = empty_dropdown(
125+
label="Database",
126+
message="(create one first)",
127+
)
128+
return
129+
self.database = mo.ui.dropdown(
130+
options={db.name: db.name for db in dbs},
131+
label="Database",
132+
full_width=True,
133+
)
134+
135+
def _maybe_create(self) -> None:
136+
create_n = self.create.value
137+
if create_n == 0 or create_n == self._last_create_n:
138+
return
139+
self._last_create_n = create_n
140+
self._create_error = None
141+
self._create_result = None
142+
self._show_create_success = False
143+
self._show_load_success = False
144+
db_name = self.name.value.strip()
145+
if not db_name:
146+
self._create_error = "Enter a database name."
147+
return
148+
schema = self.schema.value.strip() or self._default_schema
149+
tables = _parse_table_names(self.tables.value)
150+
try:
151+
self._create_result = self._client.create_managed_database(
152+
db_name,
153+
schema=schema,
154+
tables=tables or None,
155+
)
156+
self._rebuild_database_pick()
157+
self._show_create_success = True
158+
except (RuntimeError, ValueError, KeyError) as e:
159+
self._create_error = str(e)
160+
161+
def _maybe_load(self) -> None:
162+
load_n = self.load.value
163+
if load_n == 0 or load_n == self._last_load_n:
164+
return
165+
self._last_load_n = load_n
166+
self._load_error = None
167+
self._load_result = None
168+
self._show_load_success = False
169+
database = self.database.value
170+
table = self.table.value.strip()
171+
if not database:
172+
self._load_error = "Choose or create a database first."
173+
return
174+
if not table:
175+
self._load_error = "Enter a table name."
176+
return
177+
uploads = self.file.value
178+
if not uploads:
179+
self._load_error = "Choose a parquet file to upload."
180+
return
181+
schema = self.schema.value.strip() or self._default_schema
182+
try:
183+
upload_id = _upload_parquet_bytes(self._client, uploads[0].contents)
184+
self._load_result = self._client.load_managed_table(
185+
database,
186+
table,
187+
schema=schema,
188+
upload_id=upload_id,
189+
)
190+
self._show_load_success = True
191+
self._show_create_success = False
192+
except (RuntimeError, ValueError, KeyError, OSError) as e:
193+
self._load_error = str(e)
194+
195+
@property
196+
def result_panel(self):
197+
_ = self.create.value
198+
_ = self.load.value
199+
self._maybe_create()
200+
self._maybe_load()
201+
202+
if self._create_error:
203+
return mo.callout(mo.md(self._create_error), kind="danger")
204+
if self._show_create_success and self._create_result is not None:
205+
db = self._create_result
206+
return mo.callout(
207+
mo.md(
208+
f"Created **{db.name}** (`{db.id}`). "
209+
"Load parquet into a declared table below."
210+
),
211+
kind="success",
212+
)
213+
214+
if self._load_error:
215+
return mo.callout(mo.md(self._load_error), kind="danger")
216+
if self._show_load_success and self._load_result is not None:
217+
loaded = self._load_result
218+
return mo.callout(
219+
mo.md(
220+
f"Loaded **{loaded.full_name}** · **{loaded.row_count}** rows."
221+
),
222+
kind="success",
223+
)
224+
225+
return mo.md("_Create a database or load a parquet table to see results here._")
226+
227+
@property
228+
def ui(self):
229+
_ = self.create.value
230+
_ = self.load.value
231+
_ = self.database.value
232+
return mo.vstack(
233+
[
234+
mo.md("### Create database"),
235+
self.name,
236+
self.schema,
237+
self.tables,
238+
self.create,
239+
mo.md("### Load parquet table"),
240+
self.database,
241+
self.table,
242+
self.file,
243+
self.load,
244+
],
245+
gap=1,
246+
)
247+
248+
@property
249+
def tab_ui(self):
250+
_ = self.create.value
251+
_ = self.load.value
252+
if hasattr(self.database, "value"):
253+
_ = self.database.value
254+
return mo.vstack(
255+
[
256+
databases_panel(self._client),
257+
self.ui,
258+
self.result_panel,
259+
],
260+
gap=2,
261+
)
262+
263+
264+
def managed_database_writer(
265+
client: HotdataClient,
266+
*,
267+
default_schema: str = DEFAULT_SCHEMA,
268+
) -> ManagedDatabaseWriter:
269+
return ManagedDatabaseWriter(client, default_schema=default_schema)

0 commit comments

Comments
 (0)