From 3828f24b19890858376f8d087f18beb5e09424dd Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Mon, 18 May 2026 20:41:35 -0700 Subject: [PATCH 1/7] 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. --- README.md | 5 +- examples/demo.py | 15 +- hotdata_marimo/__init__.py | 14 ++ hotdata_marimo/databases.py | 269 +++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- tests/conftest.py | 1 + tests/test_databases_marimo.py | 127 ++++++++++++++++ tests/test_package.py | 2 + uv.lock | 16 +- 9 files changed, 440 insertions(+), 11 deletions(-) create mode 100644 hotdata_marimo/databases.py create mode 100644 tests/test_databases_marimo.py diff --git a/README.md b/README.md index bb99a0a..96c232e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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): @@ -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 diff --git a/examples/demo.py b/examples/demo.py index a998c0d..9555868 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -36,16 +36,18 @@ 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. @@ -53,6 +55,12 @@ def _(mo): return +@app.cell +def _(db_writer): + databases_tab = db_writer.tab_ui + return (databases_tab,) + + @app.cell def _(recent): recent_tab = recent.tab_ui @@ -60,10 +68,11 @@ def _(recent): @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, }) diff --git a/hotdata_marimo/__init__.py b/hotdata_marimo/__init__.py index 08fbae8..6bab468 100644 --- a/hotdata_marimo/__init__.py +++ b/hotdata_marimo/__init__.py @@ -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, @@ -31,6 +36,7 @@ "HotdataClient", "HotdataMarimoEngine", "QueryResult", + "ManagedDatabaseWriter", "RecentResults", "SqlEditor", "TableBrowser", @@ -38,13 +44,17 @@ "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", @@ -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 @@ -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] diff --git a/hotdata_marimo/databases.py b/hotdata_marimo/databases.py new file mode 100644 index 0000000..9d90ecb --- /dev/null +++ b/hotdata_marimo/databases.py @@ -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 --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, + ) + + 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) diff --git a/pyproject.toml b/pyproject.toml index def2017..297a661 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ readme = "README.md" requires-python = ">=3.10" license = { text = "MIT" } dependencies = [ - "hotdata-runtime @ git+https://github.com/hotdata-dev/hotdata-runtime.git", + "hotdata-runtime @ git+https://github.com/hotdata-dev/hotdata-runtime.git@feat/managed-databases", "marimo>=0.10.0", ] diff --git a/tests/conftest.py b/tests/conftest.py index cc3ec90..fd2abd8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,4 +32,5 @@ def mock_client(sample_result: QueryResult): client.connections.return_value.list_connections.return_value = SimpleNamespace( connections=[] ) + client.list_managed_databases.return_value = [] return client diff --git a/tests/test_databases_marimo.py b/tests/test_databases_marimo.py new file mode 100644 index 0000000..7a61382 --- /dev/null +++ b/tests/test_databases_marimo.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import hotdata_marimo as hm +from hotdata_marimo.databases import ManagedDatabaseWriter, databases_panel +from hotdata_runtime import LoadManagedTableResult, ManagedDatabase + + +def test_databases_panel_empty_state(mock_client): + mock_client.list_managed_databases.return_value = [] + with patch("hotdata_marimo.databases.mo.md", side_effect=lambda x: x): + panel = databases_panel(mock_client) + assert panel is not None + + +def test_databases_panel_lists_managed_databases(mock_client): + mock_client.list_managed_databases.return_value = [ + ManagedDatabase(id="c1", name="sales", source_type="managed"), + ] + with patch("hotdata_marimo.databases.mo.vstack", return_value="panel"), patch( + "hotdata_marimo.databases.mo.md", side_effect=lambda x: x + ), patch("hotdata_marimo.databases.mo.ui.table", return_value=MagicMock()): + panel = databases_panel(mock_client) + assert panel == "panel" + + +def test_managed_database_writer_creates_database(mock_client): + mock_client.list_managed_databases.return_value = [] + mock_client.create_managed_database.return_value = ManagedDatabase( + id="conn_new", + name="sales", + source_type="managed", + ) + create = MagicMock() + create.value = 1 + name = MagicMock() + name.value = "sales" + schema = MagicMock() + schema.value = "public" + tables = MagicMock() + tables.value = "orders\ncustomers" + load = MagicMock() + load.value = 0 + database = MagicMock() + database.value = "" + table = MagicMock() + table.value = "" + file = MagicMock() + file.value = [] + + with patch("hotdata_marimo.databases.mo.ui.button", side_effect=[create, load]), patch( + "hotdata_marimo.databases.mo.ui.text", side_effect=[name, schema, table] + ), patch( + "hotdata_marimo.databases.mo.ui.text_area", return_value=tables + ), patch( + "hotdata_marimo.databases.empty_dropdown", return_value=database + ), patch( + "hotdata_marimo.databases.mo.ui.file", return_value=file + ), patch( + "hotdata_marimo.databases.databases_panel", return_value="list" + ): + writer = ManagedDatabaseWriter(mock_client) + panel = writer.result_panel + + mock_client.create_managed_database.assert_called_once_with( + "sales", + schema="public", + tables=["orders", "customers"], + ) + assert "Created" in str(panel) or panel is not None + + +def test_managed_database_writer_loads_parquet(mock_client): + mock_client.list_managed_databases.return_value = [ + ManagedDatabase(id="c1", name="sales", source_type="managed"), + ] + mock_client.upload_parquet.return_value = "upl_1" + mock_client.load_managed_table.return_value = LoadManagedTableResult( + connection_id="c1", + schema_name="public", + table_name="orders", + row_count=10, + full_name="sales.public.orders", + ) + + create = MagicMock() + create.value = 0 + load = MagicMock() + load.value = 1 + name = MagicMock() + name.value = "" + schema = MagicMock() + schema.value = "public" + tables = MagicMock() + tables.value = "" + database = MagicMock() + database.value = "sales" + table = MagicMock() + table.value = "orders" + file = MagicMock() + file.value = [SimpleNamespace(name="orders.parquet", contents=b"PAR1")] + + with patch("hotdata_marimo.databases.mo.ui.button", side_effect=[create, load]), patch( + "hotdata_marimo.databases.mo.ui.text", side_effect=[name, schema, table] + ), patch( + "hotdata_marimo.databases.mo.ui.text_area", return_value=tables + ), patch( + "hotdata_marimo.databases.mo.ui.dropdown", return_value=database + ), patch( + "hotdata_marimo.databases.mo.ui.file", return_value=file + ), patch( + "hotdata_marimo.databases.databases_panel", return_value="list" + ), patch( + "hotdata_marimo.databases._upload_parquet_bytes", return_value="upl_1" + ): + writer = ManagedDatabaseWriter(mock_client) + panel = writer.result_panel + + mock_client.load_managed_table.assert_called_once_with( + "sales", + "orders", + schema="public", + upload_id="upl_1", + ) + assert panel is not None diff --git a/tests/test_package.py b/tests/test_package.py index 3ffa0fb..827c875 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -47,6 +47,8 @@ def test_runtime_primitives_are_reexported(): ("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"), ], diff --git a/uv.lock b/uv.lock index 08e605f..3d834c7 100644 --- a/uv.lock +++ b/uv.lock @@ -170,17 +170,23 @@ wheels = [ [[package]] name = "hotdata" version = "0.1.0" -source = { registry = "https://pypi.org/simple" } +source = { editable = "../sdk-python" } dependencies = [ { name = "pydantic" }, { name = "python-dateutil" }, { name = "typing-extensions" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/a2/7e997581dc23fca35330c355cd433135c4d18cc5506fb77fb35fd0180e97/hotdata-0.1.0.tar.gz", hash = "sha256:6795ff7381fb8f2f258ee3f0c31f9b1ba2f5908728c51fa399840fdf603acc46", size = 97691, upload-time = "2026-04-25T17:57:00.102Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/21/e04ca377e7e3db50215bf207867ef02a56af11f61022390b7689e6ff2db3/hotdata-0.1.0-py3-none-any.whl", hash = "sha256:304f46d7c7ed5b586a9102684ef42e45972955dfb66a492c5e0b016e8bc545fa", size = 242376, upload-time = "2026-04-25T17:56:58.126Z" }, + +[package.metadata] +requires-dist = [ + { name = "pyarrow", marker = "extra == 'arrow'", specifier = ">=14" }, + { name = "pydantic", specifier = ">=2" }, + { name = "python-dateutil", specifier = ">=2.8.2" }, + { name = "typing-extensions", specifier = ">=4.7.1" }, + { name = "urllib3", specifier = ">=2.1.0,<3.0.0" }, ] +provides-extras = ["arrow"] [[package]] name = "hotdata-marimo" @@ -217,7 +223,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "hotdata", specifier = ">=0.1.0" }, + { name = "hotdata", editable = "../sdk-python" }, { name = "pandas", specifier = ">=2.0" }, ] From 0f582c7bc4e78600ce18f3cd1dfd4e3403f4e4f8 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Mon, 18 May 2026 20:43:31 -0700 Subject: [PATCH 2/7] chore: pin hotdata-runtime>=0.1.1 and bump to 0.1.1 Replace git branch dependency with a PyPI version constraint aligned with the runtime release that adds managed database helpers. --- pyproject.toml | 11 +++-------- uv.lock | 6 +++--- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 297a661..38d03d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,18 +2,15 @@ requires = ["hatchling"] build-backend = "hatchling.build" -[tool.hatch.metadata] -allow-direct-references = true - [project] name = "hotdata-marimo" -version = "0.1.0" +version = "0.1.1" description = "Marimo integration for Hotdata runtime" readme = "README.md" requires-python = ">=3.10" license = { text = "MIT" } dependencies = [ - "hotdata-runtime @ git+https://github.com/hotdata-dev/hotdata-runtime.git@feat/managed-databases", + "hotdata-runtime>=0.1.1", "marimo>=0.10.0", ] @@ -25,8 +22,7 @@ dev = [ [tool.uv] default-groups = ["dev"] -# Resolve hotdata-runtime from a sibling checkout (../hotdata-runtime). Remove this -# block and re-run `uv lock` before committing a lockfile meant for CI that only clones this repo. +# Resolve hotdata-runtime from a sibling checkout until v0.1.1 is on PyPI. [tool.uv.sources] hotdata-runtime = { path = "../hotdata-runtime", editable = true } @@ -39,4 +35,3 @@ testpaths = ["tests"] [tool.marimo.runtime] # Reduces stale cell state when opening the example; overrides personal marimo.toml if unset. auto_instantiate = true - diff --git a/uv.lock b/uv.lock index 3d834c7..04b9ae2 100644 --- a/uv.lock +++ b/uv.lock @@ -169,7 +169,7 @@ wheels = [ [[package]] name = "hotdata" -version = "0.1.0" +version = "0.1.1" source = { editable = "../sdk-python" } dependencies = [ { name = "pydantic" }, @@ -190,7 +190,7 @@ provides-extras = ["arrow"] [[package]] name = "hotdata-marimo" -version = "0.1.0" +version = "0.1.1" source = { editable = "." } dependencies = [ { name = "hotdata-runtime" }, @@ -213,7 +213,7 @@ dev = [{ name = "pytest", specifier = ">=8.0" }] [[package]] name = "hotdata-runtime" -version = "0.1.0" +version = "0.1.1" source = { editable = "../hotdata-runtime" } dependencies = [ { name = "hotdata" }, From 7464652dd540ad5898069e82c764ede70fc35494 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Mon, 18 May 2026 20:58:24 -0700 Subject: [PATCH 3/7] chore: re-lock for hotdata 0.2.0 transitive dependency --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 04b9ae2..505f5bd 100644 --- a/uv.lock +++ b/uv.lock @@ -169,7 +169,7 @@ wheels = [ [[package]] name = "hotdata" -version = "0.1.1" +version = "0.2.0" source = { editable = "../sdk-python" } dependencies = [ { name = "pydantic" }, From 5393488855472e6d6ec5f07c0d7a276fe5e9630c Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Mon, 18 May 2026 22:04:34 -0700 Subject: [PATCH 4/7] docs: simplify README with usage examples and SQL screenshot --- README.md | 113 ++++++++++++---------- docs/images/mo-sql-hotdata-connection.png | Bin 0 -> 49195 bytes 2 files changed, 64 insertions(+), 49 deletions(-) create mode 100644 docs/images/mo-sql-hotdata-connection.png diff --git a/README.md b/README.md index 96c232e..0eb0d27 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,37 @@ # hotdata-marimo -Marimo UI helpers for [Hotdata](https://hotdata.dev): run SQL from a notebook, browse catalog metadata, and render results as tables. +Marimo widgets for [Hotdata](https://hotdata.dev): run SQL, browse catalogs, load managed databases, and display results in notebooks. -## Features - -- **Workspace-aware setup** — build a `HotdataClient` from environment variables, or use `workspace_selector_from_env()` to choose a workspace interactively when no workspace is pinned. -- **Connection health** — show a compact status callout with API, workspace, and optional sandbox context. -- **Catalog browsing** — browse Hotdata connections, schemas, tables, and columns from Marimo UI controls. -- **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. +Requires Python 3.10+, [Marimo](https://marimo.io/), and [hotdata-runtime](https://github.com/hotdata-dev/hotdata-runtime) (installed automatically). ## Install ```bash -uv pip install hotdata-marimo -# or: pip install hotdata-marimo +pip install hotdata-marimo ``` -Requires Python 3.10+, **Marimo**, and [**hotdata-runtime**](https://github.com/hotdata-dev/hotdata-runtime) (Hotdata SDK + runtime/session semantics — pulled in automatically when you `pip install hotdata-marimo`). +Set `HOTDATA_API_KEY`. Optionally set `HOTDATA_WORKSPACE`, `HOTDATA_API_URL`, or `HOTDATA_SANDBOX`. -## Environment +## Connect -| Variable | Required | Description | -|----------|----------|-------------| -| `HOTDATA_API_KEY` | Yes | API key for the Hotdata API | -| `HOTDATA_API_URL` | No | API base URL (default: `https://api.hotdata.dev`) | -| `HOTDATA_WORKSPACE` | No | Workspace id; if unset, the first active workspace is used | -| `HOTDATA_SANDBOX` | No | Sandbox session id, passed through to the SDK | +```python +import hotdata_marimo as hm -## Minimal notebook +client = hm.from_env() +``` + +If `HOTDATA_WORKSPACE` is unset, pick a workspace interactively: + +```python +ws = hm.workspace_selector_from_env() +client = ws.client +``` + +## SQL editor widget + +Run SQL in one cell; show results in the next. Marimo only renders what you **`return`**. + +**Cell 1 — editor** ```python import marimo as mo @@ -42,25 +42,30 @@ editor = hm.sql_editor(client, default_sql="SELECT 1 AS ok") return editor.ui ``` +**Cell 2 — result** + ```python return hm.query_result(editor.result) ``` -Importing `hotdata_marimo` registers discoverability aliases on Marimo’s UI namespace, so you can also use `mo.ui.hotdata_sql_editor`, `mo.ui.hotdata_table_browser`, `mo.ui.hotdata_query_result`, and `mo.ui.hotdata_connection_status`. +Click **Run on Hotdata** after changing SQL. The editor caches the last successful result so downstream cells do not re-query on every refresh. -Use `hm.connection_status(client)` (or `mo.ui.hotdata_connection_status(client)`) for a small API/workspace health callout. +## Native Marimo SQL cells -## Marimo SQL Cells +Register the Hotdata engine once, then pass `engine=client` to `mo.sql`. Hotdata appears as **Hotdata** in the SQL connection picker. -Register the Hotdata SQL engine once during setup, then pass a `HotdataClient` to Marimo SQL cells: +**Setup cell** ```python +import marimo as mo import hotdata_marimo as hm hm.register_hotdata_sql_engine() client = hm.from_env() ``` +**SQL cell** + ```python _df = mo.sql( """ @@ -70,46 +75,56 @@ _df = mo.sql( ) ``` -The engine also exposes Hotdata catalog metadata to Marimo's data-source UI. Hotdata connections are labeled **Hotdata** in the SQL connection picker. +![Marimo SQL cell with Hotdata selected in the database connections picker](docs/images/mo-sql-hotdata-connection.png) + +## Browse tables + +```python +browser = hm.table_browser(client) +return browser.ui +``` + +Pick a connection, schema, and table to inspect columns. Use `browser.selected_table` in downstream cells. -## Two-cell pattern +## Managed databases -Keep the editor in one cell and consume `editor.result` in another. The editor caches the last successful run so downstream cells do not re-query the API on every refresh; click **Run on Hotdata** again after you change SQL. While a query is running, a Marimo status spinner is shown. +Create a Hotdata-owned catalog and load a parquet file from the notebook: -Marimo only shows **what you `return` from a cell**. Calling `mo.vstack(...)` or `hm.query_result(...)` without returning it produces no visible output. +```python +panel = hm.databases_panel(client) +return panel +``` -See `examples/demo.py` for a full runnable notebook flow. +Or use the lower-level writer API: -## Examples +```python +writer = hm.managed_database_writer(client) +return writer.ui +``` -- `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. +## Other helpers -Run locally (single-user machine): +```python +return hm.connection_status(client) # API / workspace callout +return hm.recent_results(client).ui # past query results +return hm.run_history(client) # recent query runs +``` + +Importing `hotdata_marimo` also registers `mo.ui.hotdata_*` aliases (e.g. `mo.ui.hotdata_sql_editor`). + +## Demo notebook ```bash uv run marimo edit examples/demo.py --no-token ``` -On a **shared or networked host**, omit `--no-token` and use the access token printed in the terminal URL. Without it, anyone who can reach the Marimo port can run queries against your Hotdata workspace. - -## 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`, `managed_database_writer`, `display` for tables/status/history, `workspace_selector`). Import `HotdataClient` / `QueryResult` / `from_env` from **`hotdata_marimo`** or directly from **`hotdata_runtime`**. +`examples/demo.py` combines workspace selection, catalog browsing, managed databases, query history, and a native `mo.sql` cell. ## Development -This package depends on [**hotdata-runtime**](https://github.com/hotdata-dev/hotdata-runtime) (PyPI name `hotdata-runtime`). Development uses **uv**; keep a sibling checkout at `../hotdata-runtime` so the lockfile resolves the runtime from disk (see `[tool.uv.sources]` in `pyproject.toml`). - ```bash uv sync --locked uv run pytest -marimo edit examples/demo.py --no-token ``` -To pin **hotdata-runtime** from Git instead of the sibling path, remove the `[tool.uv.sources]` block, set the dependency line as needed, and run `uv lock` again. - -For a **publishable** `uv.lock` (CI that only clones this repo), remove `[tool.uv.sources]`, point `hotdata-runtime` at PyPI or `git+https://…`, then `uv lock`. - -The **`[project] name`** in [hotdata-runtime](https://github.com/hotdata-dev/hotdata-runtime) `pyproject.toml` is **`hotdata-runtime`** and the import package is **`hotdata_runtime`**. - -Use **`--no-token`** for local development so the editor does not redirect to `/auth/login` (session auth is easy to hit with a global Marimo config). For a public or shared machine, omit it and use the printed URL with an access token instead. +See [hotdata-runtime](https://github.com/hotdata-dev/hotdata-runtime) for the underlying API client. diff --git a/docs/images/mo-sql-hotdata-connection.png b/docs/images/mo-sql-hotdata-connection.png new file mode 100644 index 0000000000000000000000000000000000000000..97ffba31e599c9debded2838904a5b3775fe1348 GIT binary patch literal 49195 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 literal 0 HcmV?d00001 From a424579c2d76f23fbc7680bebdbf91bdac5e6e61 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Mon, 18 May 2026 22:05:02 -0700 Subject: [PATCH 5/7] docs: add supported widgets table to README --- README.md | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0eb0d27..ed534a2 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,26 @@ Marimo widgets for [Hotdata](https://hotdata.dev): run SQL, browse catalogs, loa Requires Python 3.10+, [Marimo](https://marimo.io/), and [hotdata-runtime](https://github.com/hotdata-dev/hotdata-runtime) (installed automatically). +## Supported widgets + +Importing `hotdata_marimo` registers `mo.ui.hotdata_*` aliases for discoverability. + +| Widget | Function | Notes | +|--------|----------|-------| +| SQL editor | `hm.sql_editor(client)` | Returns `.ui` and `.result` | +| Table browser | `hm.table_browser(client)` | Browse connections, schemas, tables, columns | +| Managed databases panel | `hm.databases_panel(client)` | Create catalogs and load parquet files | +| Managed database writer | `hm.managed_database_writer(client)` | Lower-level create/load UI | +| Workspace selector | `hm.workspace_selector_from_env()` | Pick workspace when `HOTDATA_WORKSPACE` is unset | +| Connection picker | `hm.connection_picker(client)` | Dropdown of workspace connections | +| Connection status | `hm.connection_status(client)` | API / workspace health callout | +| Connections panel | `hm.connections_panel(client)` | Status callout plus connection list | +| Query result | `hm.query_result(result)` | Render a `QueryResult` as a table | +| Recent results | `hm.recent_results(client)` | Browse past query results | +| Run history | `hm.run_history(client)` | Recent query runs | + +Each widget also has a `mo.ui.hotdata_*` alias (e.g. `mo.ui.hotdata_sql_editor`). Native Marimo SQL cells are supported via `hm.register_hotdata_sql_engine()` and `mo.sql(..., engine=client)`. + ## Install ```bash @@ -104,14 +124,15 @@ return writer.ui ## Other helpers +See [Supported widgets](#supported-widgets) for the full list. Quick examples: + ```python -return hm.connection_status(client) # API / workspace callout -return hm.recent_results(client).ui # past query results -return hm.run_history(client) # recent query runs +return hm.connection_status(client) +return hm.connections_panel(client) +return hm.recent_results(client).ui +return hm.run_history(client) ``` -Importing `hotdata_marimo` also registers `mo.ui.hotdata_*` aliases (e.g. `mo.ui.hotdata_sql_editor`). - ## Demo notebook ```bash From 4c5f3b2f8efe4a197aa925d381e804b2431a14c2 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Mon, 18 May 2026 22:05:38 -0700 Subject: [PATCH 6/7] chore: regenerate demo notebook with marimo 0.23.6 --- examples/demo.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/demo.py b/examples/demo.py index 9555868..0c57d7c 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -1,6 +1,6 @@ import marimo -__generated_with = "0.23.5" +__generated_with = "0.23.6" app = marimo.App() @@ -82,7 +82,7 @@ def _(databases_tab, history, mo, recent_tab, status, workspace): @app.cell def _(client, mo): _df = mo.sql( - """ + f""" SELECT 1 AS example_value """, engine=client From 954177f364687e94454de68c647c4ced30dde8fc Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Mon, 18 May 2026 22:14:27 -0700 Subject: [PATCH 7/7] chore: re-lock for hotdata-runtime 0.1.1 on PyPI Remove the editable sibling runtime override now that 0.1.1 is published, preserve database dropdown selection after create, and tighten the create success test assertion. --- hotdata_marimo/databases.py | 6 +++++- pyproject.toml | 4 ---- tests/test_databases_marimo.py | 8 +++++++- uv.lock | 20 ++++++-------------- 4 files changed, 18 insertions(+), 20 deletions(-) diff --git a/hotdata_marimo/databases.py b/hotdata_marimo/databases.py index 9d90ecb..540b1f4 100644 --- a/hotdata_marimo/databases.py +++ b/hotdata_marimo/databases.py @@ -119,6 +119,7 @@ def __init__( ) def _rebuild_database_pick(self) -> None: + current = getattr(getattr(self, "database", None), "value", None) dbs = self._client.list_managed_databases() if not dbs: self.database = empty_dropdown( @@ -126,10 +127,13 @@ def _rebuild_database_pick(self) -> None: message="(create one first)", ) return + options = {db.name: db.name for db in dbs} + value = current if current in options else next(iter(options)) self.database = mo.ui.dropdown( - options={db.name: db.name for db in dbs}, + options=options, label="Database", full_width=True, + value=value, ) def _maybe_create(self) -> None: diff --git a/pyproject.toml b/pyproject.toml index 2efa1e4..d3b9482 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,10 +23,6 @@ dev = [ [tool.uv] default-groups = ["dev"] -# Resolve hotdata-runtime from a sibling checkout until v0.1.1 is on PyPI. -[tool.uv.sources] -hotdata-runtime = { path = "../hotdata-runtime", editable = true } - [tool.hatch.build.targets.wheel] packages = ["hotdata_marimo"] diff --git a/tests/test_databases_marimo.py b/tests/test_databases_marimo.py index 7a61382..c4b7c0f 100644 --- a/tests/test_databases_marimo.py +++ b/tests/test_databases_marimo.py @@ -56,10 +56,16 @@ def test_managed_database_writer_creates_database(mock_client): "hotdata_marimo.databases.mo.ui.text_area", return_value=tables ), patch( "hotdata_marimo.databases.empty_dropdown", return_value=database + ), patch( + "hotdata_marimo.databases.mo.ui.dropdown", return_value=database ), patch( "hotdata_marimo.databases.mo.ui.file", return_value=file ), patch( "hotdata_marimo.databases.databases_panel", return_value="list" + ), patch( + "hotdata_marimo.databases.mo.md", side_effect=lambda x: x + ), patch( + "hotdata_marimo.databases.mo.callout", side_effect=lambda body, **kw: body ): writer = ManagedDatabaseWriter(mock_client) panel = writer.result_panel @@ -69,7 +75,7 @@ def test_managed_database_writer_creates_database(mock_client): schema="public", tables=["orders", "customers"], ) - assert "Created" in str(panel) or panel is not None + assert "Created" in str(panel) def test_managed_database_writer_loads_parquet(mock_client): diff --git a/uv.lock b/uv.lock index 49cd05a..932a1ed 100644 --- a/uv.lock +++ b/uv.lock @@ -151,7 +151,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -200,7 +200,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "hotdata", specifier = ">=0.2.0" }, - { name = "hotdata-runtime", editable = "../hotdata-runtime" }, + { name = "hotdata-runtime", specifier = ">=0.1.1" }, { name = "marimo", specifier = ">=0.10.0" }, ] @@ -210,23 +210,15 @@ dev = [{ name = "pytest", specifier = ">=8.0" }] [[package]] name = "hotdata-runtime" version = "0.1.1" -source = { editable = "../hotdata-runtime" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "hotdata" }, { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] - -[package.metadata] -requires-dist = [ - { name = "hotdata", specifier = ">=0.2.0" }, - { name = "pandas", specifier = ">=2.0" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "packaging", specifier = ">=23" }, - { name = "pytest", specifier = ">=8.0" }, +sdist = { url = "https://files.pythonhosted.org/packages/86/0b/b2889774abaa555be7625999c8730361d86f588aec7219918c616817cdb1/hotdata_runtime-0.1.1.tar.gz", hash = "sha256:3ed64b430f258b3505cf2d1f6635069fc1afef6df6fc3fca5e52ac578e69ead7", size = 57795, upload-time = "2026-05-19T05:13:15.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/7b/98cf841d7900e4eb198d1a393828a0999d9b4d54ef792cec5fa3eb4c5a01/hotdata_runtime-0.1.1-py3-none-any.whl", hash = "sha256:51da53100329fbf634abbe95073b2edbbdad174886263b40652091a88f41f0ad", size = 10210, upload-time = "2026-05-19T05:13:14.28Z" }, ] [[package]]