Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


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

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

## Examples

- `examples/demo.py` — tabbed explorer with workspace selection, connection health, recent results (selectable table), run history, and a native `mo.sql` cell.
- `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.

Run locally (single-user machine):

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

## Layout

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`**.
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`**.

## Development

Expand Down
15 changes: 12 additions & 3 deletions examples/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,34 +36,43 @@ def _(hm, mo, os):
def _(hm, workspace):
client = workspace.client
status = hm.connections_panel(client)
db_writer = hm.managed_database_writer(client)
recent = hm.recent_results(client, limit=20)
history = hm.run_history(client, limit=10)
return client, history, recent, status
return client, db_writer, history, recent, status


@app.cell
def _(mo):
mo.md(r"""
## HotData explorer
Use the tabs below to switch between workspaces, connection status, recent results, and run history.
Use the tabs below to switch between workspaces, connections, managed databases,
recent results, and run history.

On a shared or networked host, run Marimo **without** `--no-token` and open the printed URL
with its access token so only you can use this notebook.
""")
return


@app.cell
def _(db_writer):
databases_tab = db_writer.tab_ui
return (databases_tab,)


@app.cell
def _(recent):
recent_tab = recent.tab_ui
return (recent_tab,)


@app.cell
def _(history, mo, recent_tab, status, workspace):
def _(databases_tab, history, mo, recent_tab, status, workspace):
mo.ui.tabs({
"Workspaces": workspace.ui,
"Connections": status,
"Databases": databases_tab,
"Recent results": recent_tab,
"Run history": history,
})
Expand Down
14 changes: 14 additions & 0 deletions hotdata_marimo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@

from hotdata_runtime import HotdataClient, QueryResult, from_env

from hotdata_marimo.databases import (
ManagedDatabaseWriter,
databases_panel,
managed_database_writer,
)
from hotdata_marimo.display import (
RecentResults,
connection_status,
Expand All @@ -31,20 +36,25 @@
"HotdataClient",
"HotdataMarimoEngine",
"QueryResult",
"ManagedDatabaseWriter",
"RecentResults",
"SqlEditor",
"TableBrowser",
"WorkspaceSelector",
"connection_picker",
"connection_status",
"connections_panel",
"databases_panel",
"from_env",
"hotdata_connection_picker",
"hotdata_databases_panel",
"hotdata_managed_database_writer",
"hotdata_query_result",
"hotdata_recent_results",
"hotdata_sql_editor",
"hotdata_table_browser",
"hotdata_workspace_selector",
"managed_database_writer",
"query_result",
"recent_results",
"register_hotdata_sql_engine",
Expand All @@ -60,6 +70,8 @@
hotdata_table_browser = table_browser
hotdata_query_result = query_result
hotdata_connection_picker = connection_picker
hotdata_databases_panel = databases_panel
hotdata_managed_database_writer = managed_database_writer
hotdata_workspace_selector = workspace_selector_from_env
hotdata_recent_results = recent_results

Expand All @@ -73,6 +85,8 @@ def register_mo_ui_hotdata_aliases() -> None:
mo.ui.hotdata_query_result = hotdata_query_result # type: ignore[attr-defined]
mo.ui.hotdata_connection_status = connection_status # type: ignore[attr-defined]
mo.ui.hotdata_connection_picker = hotdata_connection_picker # type: ignore[attr-defined]
mo.ui.hotdata_databases_panel = hotdata_databases_panel # type: ignore[attr-defined]
mo.ui.hotdata_managed_database_writer = hotdata_managed_database_writer # type: ignore[attr-defined]
mo.ui.hotdata_workspace_selector = hotdata_workspace_selector # type: ignore[attr-defined]
mo.ui.hotdata_recent_results = hotdata_recent_results # type: ignore[attr-defined]

Expand Down
269 changes: 269 additions & 0 deletions hotdata_marimo/databases.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
"""Marimo UI for managed Hotdata databases (create + parquet table loads)."""

from __future__ import annotations

import os
import tempfile

import marimo as mo

from hotdata_runtime import (
DEFAULT_SCHEMA,
HotdataClient,
LoadManagedTableResult,
ManagedDatabase,
)

from hotdata_marimo._options import empty_dropdown


def _parse_table_names(text: str) -> list[str]:
return [line.strip() for line in text.splitlines() if line.strip()]


def _upload_parquet_bytes(client: HotdataClient, contents: bytes) -> str:
with tempfile.NamedTemporaryFile(suffix=".parquet", delete=False) as tmp:
tmp.write(contents)
path = tmp.name
try:
return client.upload_parquet(path)
finally:
os.unlink(path)


def databases_panel(client: HotdataClient):
"""Table of managed databases in the workspace."""
dbs = client.list_managed_databases()
if not dbs:
return mo.vstack(
[
mo.md("### Managed databases"),
mo.md("_No managed databases yet._"),
mo.md(
"Create one below, or with the CLI: "
"`hotdata databases create --name <name> --table <table>`."
),
],
gap=1,
)
rows: list[dict[str, object]] = [
{"name": db.name, "id": db.id, "sql_prefix": f"{db.name}.{{schema}}.{{table}}"}
for db in dbs
]
return mo.vstack(
[
mo.md("### Managed databases"),
mo.ui.table(
rows,
label="Managed databases",
pagination=True,
page_size=min(10, len(rows)),
selection=None,
max_height=240,
),
mo.md("_Query as `database.schema.table` in SQL._"),
],
gap=1,
)


class ManagedDatabaseWriter:
"""Create managed databases and load parquet files into declared tables.

Instantiate in one cell and use ``.tab_ui`` in another (see package README).
"""

def __init__(
self,
client: HotdataClient,
*,
default_schema: str = DEFAULT_SCHEMA,
) -> None:
self._client = client
self._default_schema = default_schema
self._last_create_n: int | None = None
self._last_load_n: int | None = None
self._create_result: ManagedDatabase | None = None
self._load_result: LoadManagedTableResult | None = None
self._create_error: str | None = None
self._load_error: str | None = None
self._show_create_success = False
self._show_load_success = False

self.name = mo.ui.text("", label="Database name", full_width=True)
self.schema = mo.ui.text(default_schema, label="Schema", full_width=True)
self.tables = mo.ui.text_area(
"",
label="Tables to declare (one per line)",
full_width=True,
)
self.create = mo.ui.button(
value=0,
on_click=lambda n: n + 1,
label="Create database",
kind="success",
)

self._rebuild_database_pick()
self.table = mo.ui.text("", label="Table name", full_width=True)
self.file = mo.ui.file(
filetypes=[".parquet"],
label="Parquet file",
kind="area",
)
self.load = mo.ui.button(
value=0,
on_click=lambda n: n + 1,
label="Load table",
kind="success",
)

def _rebuild_database_pick(self) -> None:
dbs = self._client.list_managed_databases()
if not dbs:
self.database = empty_dropdown(
label="Database",
message="(create one first)",
)
return
self.database = mo.ui.dropdown(
options={db.name: db.name for db in dbs},
label="Database",
full_width=True,
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: (not blocking) On a successful create, _rebuild_database_pick() replaces self.database with a fresh mo.ui.dropdown whose .value defaults to the first option. If the user had already picked a different database earlier in the session, that selection is silently dropped. Consider preserving the previously selected value when rebuilding (e.g., pass value=current_value if it's still in the new options).


def _maybe_create(self) -> None:
create_n = self.create.value
if create_n == 0 or create_n == self._last_create_n:
return
self._last_create_n = create_n
self._create_error = None
self._create_result = None
self._show_create_success = False
self._show_load_success = False
db_name = self.name.value.strip()
if not db_name:
self._create_error = "Enter a database name."
return
schema = self.schema.value.strip() or self._default_schema
tables = _parse_table_names(self.tables.value)
try:
self._create_result = self._client.create_managed_database(
db_name,
schema=schema,
tables=tables or None,
)
self._rebuild_database_pick()
self._show_create_success = True
except (RuntimeError, ValueError, KeyError) as e:
self._create_error = str(e)

def _maybe_load(self) -> None:
load_n = self.load.value
if load_n == 0 or load_n == self._last_load_n:
return
self._last_load_n = load_n
self._load_error = None
self._load_result = None
self._show_load_success = False
database = self.database.value
table = self.table.value.strip()
if not database:
self._load_error = "Choose or create a database first."
return
if not table:
self._load_error = "Enter a table name."
return
uploads = self.file.value
if not uploads:
self._load_error = "Choose a parquet file to upload."
return
schema = self.schema.value.strip() or self._default_schema
try:
upload_id = _upload_parquet_bytes(self._client, uploads[0].contents)
self._load_result = self._client.load_managed_table(
database,
table,
schema=schema,
upload_id=upload_id,
)
self._show_load_success = True
self._show_create_success = False
except (RuntimeError, ValueError, KeyError, OSError) as e:
self._load_error = str(e)

@property
def result_panel(self):
_ = self.create.value
_ = self.load.value
self._maybe_create()
self._maybe_load()

if self._create_error:
return mo.callout(mo.md(self._create_error), kind="danger")
if self._show_create_success and self._create_result is not None:
db = self._create_result
return mo.callout(
mo.md(
f"Created **{db.name}** (`{db.id}`). "
"Load parquet into a declared table below."
),
kind="success",
)

if self._load_error:
return mo.callout(mo.md(self._load_error), kind="danger")
if self._show_load_success and self._load_result is not None:
loaded = self._load_result
return mo.callout(
mo.md(
f"Loaded **{loaded.full_name}** · **{loaded.row_count}** rows."
),
kind="success",
)

return mo.md("_Create a database or load a parquet table to see results here._")

@property
def ui(self):
_ = self.create.value
_ = self.load.value
_ = self.database.value
return mo.vstack(
[
mo.md("### Create database"),
self.name,
self.schema,
self.tables,
self.create,
mo.md("### Load parquet table"),
self.database,
self.table,
self.file,
self.load,
],
gap=1,
)

@property
def tab_ui(self):
_ = self.create.value
_ = self.load.value
if hasattr(self.database, "value"):
_ = self.database.value
return mo.vstack(
[
databases_panel(self._client),
self.ui,
self.result_panel,
],
gap=2,
)


def managed_database_writer(
client: HotdataClient,
*,
default_schema: str = DEFAULT_SCHEMA,
) -> ManagedDatabaseWriter:
return ManagedDatabaseWriter(client, default_schema=default_schema)
Loading
Loading