diff --git a/README.md b/README.md index ba6c02a..bb99a0a 100644 --- a/README.md +++ b/README.md @@ -81,14 +81,16 @@ See `examples/demo.py` for a full runnable notebook flow. ## Examples -- `examples/demo.py` — end-to-end browser + editor + result rendering flow. +- `examples/demo.py` — tabbed explorer with workspace selection, connection health, recent results (selectable table), run history, and a native `mo.sql` cell. -Run: +Run locally (single-user machine): ```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`, `display` for tables/status/history, `workspace_selector`). Import `HotdataClient` / `QueryResult` / `from_env` from **`hotdata_marimo`** or directly from **`hotdata_runtime`**. diff --git a/examples/demo.py b/examples/demo.py index b89af53..a998c0d 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -35,49 +35,39 @@ def _(hm, mo, os): @app.cell def _(hm, workspace): client = workspace.client - status = hm.connection_status(client) - browser = hm.table_browser(client) - editor = hm.sql_editor( - client, - default_sql="SELECT 1 AS ok", - ) + status = hm.connections_panel(client) recent = hm.recent_results(client, limit=20) history = hm.run_history(client, limit=10) - return browser, client, editor, history, recent, status, workspace + return client, history, recent, status @app.cell -def _(browser, editor, mo, recent, status, workspace): - return mo.vstack( - [ - workspace.ui, - status, - browser.ui, - editor.ui, - recent.ui, - ], - gap=2, - ) - - -@app.cell -def _(history): - return history +def _(mo): + mo.md(r""" + ## HotData explorer + Use the tabs below to switch between workspaces, connection status, 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 _(editor, hm): - # Explicitly touch nested widget values so Marimo reruns this cell on clicks. - _run = editor.run.value - _rerun = editor.rerun.value - _clear = editor.clear.value - return hm.query_result(editor.result), _clear, _rerun, _run +def _(recent): + recent_tab = recent.tab_ui + return (recent_tab,) @app.cell -def _(hm, recent): - _selected = recent.pick.value - return hm.query_result(recent.result, label="Recent result"), _selected +def _(history, mo, recent_tab, status, workspace): + mo.ui.tabs({ + "Workspaces": workspace.ui, + "Connections": status, + "Recent results": recent_tab, + "Run history": history, + }) + return @app.cell @@ -86,7 +76,7 @@ def _(client, mo): """ SELECT 1 AS example_value """, - engine=client, + engine=client ) return diff --git a/hotdata_marimo/__init__.py b/hotdata_marimo/__init__.py index cc714d7..08fbae8 100644 --- a/hotdata_marimo/__init__.py +++ b/hotdata_marimo/__init__.py @@ -12,6 +12,7 @@ from hotdata_marimo.display import ( RecentResults, connection_status, + connections_panel, query_result, recent_results, run_history, @@ -36,6 +37,7 @@ "WorkspaceSelector", "connection_picker", "connection_status", + "connections_panel", "from_env", "hotdata_connection_picker", "hotdata_query_result", diff --git a/hotdata_marimo/_options.py b/hotdata_marimo/_options.py new file mode 100644 index 0000000..779bb06 --- /dev/null +++ b/hotdata_marimo/_options.py @@ -0,0 +1,107 @@ +"""Shared dropdown option helpers for Marimo UI widgets.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import marimo as mo + +from hotdata_runtime import HotdataClient + + +def unique_label_options( + pairs: list[tuple[str, str]], + *, + disambiguate: Callable[[str, str, int], str] | None = None, +) -> dict[str, str]: + """Build a label→value map, suffixing repeated labels when needed.""" + counts: dict[str, int] = {} + options: dict[str, str] = {} + for label, value in pairs: + count = counts.get(label, 0) + counts[label] = count + 1 + if count == 0: + key = label + elif disambiguate is not None: + key = disambiguate(label, value, count) + else: + key = f"{label} ({count + 1})" + options[key] = value + return options + + +def empty_dropdown( + *, + label: str, + message: str, + full_width: bool = True, +): + return mo.ui.dropdown( + options={message: ""}, + label=label, + full_width=full_width, + ) + + +def connection_options(conns: list[Any]) -> dict[str, str]: + pairs = [(str(c.name), str(c.id)) for c in conns] + return unique_label_options( + pairs, + disambiguate=lambda label, value, count: f"{label} ({value})", + ) + + +def connection_picker_from_connections( + conns: list[Any], + *, + label: str = "Connection", + full_width: bool = True, +): + if not conns: + return empty_dropdown( + label=label, + message="(no connections)", + full_width=full_width, + ) + return mo.ui.dropdown( + options=connection_options(conns), + label=label, + full_width=full_width, + ) + + +def connection_picker( + client: HotdataClient, + *, + label: str = "Connection", + full_width: bool = True, +): + conns = client.connections().list_connections().connections + return connection_picker_from_connections( + conns, + label=label, + full_width=full_width, + ) + + +def resolve_connection_picker( + client: HotdataClient, + *, + label: str = "Connection", + full_width: bool = True, +) -> tuple[Any | None, str | None]: + """Return ``(dropdown_or_none, implicit_connection_id)`` for table browsers.""" + conns = client.connections().list_connections().connections + if not conns: + return None, "" + if len(conns) == 1: + return None, conns[0].id + return ( + connection_picker_from_connections( + conns, + label=label, + full_width=full_width, + ), + None, + ) diff --git a/hotdata_marimo/display.py b/hotdata_marimo/display.py index 365c03f..c6618ce 100644 --- a/hotdata_marimo/display.py +++ b/hotdata_marimo/display.py @@ -7,19 +7,6 @@ from hotdata_runtime import HotdataClient, QueryResult, workspace_health_lines -def _option_map_with_unique_labels( - pairs: list[tuple[str, str]], -) -> dict[str, str]: - counts: dict[str, int] = {} - options: dict[str, str] = {} - for label, value in pairs: - count = counts.get(label, 0) - counts[label] = count + 1 - key = label if count == 0 else f"{label} ({count + 1})" - options[key] = value - return options - - def query_result( result: QueryResult, *, @@ -72,32 +59,71 @@ class RecentResults: def __init__(self, client: HotdataClient, *, limit: int = 50) -> None: self._client = client self._results = client.list_recent_results(limit=limit, offset=0) - option_pairs = [ - (f"{r.created_at} · {r.status} · {r.result_id}", r.result_id) + self._rows: list[dict[str, object]] = [ + { + "created_at": r.created_at, + "status": r.status, + "result_id": r.result_id, + } for r in self._results ] - options = _option_map_with_unique_labels(option_pairs) - self.pick = mo.ui.dropdown( - options=options or {"(no results)": ""}, - label="Recent results", - full_width=True, + self.table = ( + mo.ui.table( + self._rows, + label="Recent results", + pagination=True, + page_size=min(10, limit), + selection="single", + max_height=320, + ) + if self._rows + else None ) @property def selected_result_id(self) -> str | None: - v = self.pick.value - return v if v else None + if self.table is None: + return None + selected = self.table.value + if not selected: + return None + row = selected[0] + if not isinstance(row, dict): + return None + rid = row.get("result_id") + return rid if rid else None @property def result(self) -> QueryResult: rid = self.selected_result_id - mo.stop(rid is None, mo.md("Pick a result id to load.")) + mo.stop(rid is None, mo.md("Select a result row to load.")) return self._client.get_result(rid or "") + @property + def result_panel(self): + rid = self.selected_result_id + if rid is None: + return mo.md("_Select a result row to load._") + return query_result(self._client.get_result(rid), label="Recent result") + + @property + def tab_ui(self): + if self.table is not None: + _ = self.table.value + return mo.vstack([self.ui, self.result_panel], gap=2) + @property def ui(self): - _ = self.pick.value - return mo.vstack([self.pick], gap=1) + if self.table is None: + return mo.md("_No recent results._") + _ = self.table.value + return mo.vstack( + [ + mo.md("### Recent results"), + self.table, + ], + gap=1, + ) def recent_results(client: HotdataClient, *, limit: int = 50) -> RecentResults: @@ -150,3 +176,34 @@ def connection_status(client: HotdataClient): mo.md(f"**API** error — {parts[0]}"), kind="danger", ) + + +def connections_panel(client: HotdataClient): + """Workspace health callout plus a table of configured connections.""" + status = connection_status(client) + conns = client.connections().list_connections().connections + if not conns: + return mo.vstack([status, mo.md("_No connections in this workspace._")], gap=1) + rows: list[dict[str, object]] = [] + for c in conns: + rows.append( + { + "name": c.name, + "id": c.id, + "source_type": getattr(c, "source_type", None), + } + ) + return mo.vstack( + [ + status, + mo.ui.table( + rows, + label="Connections", + pagination=True, + page_size=min(10, len(rows)), + selection=None, + max_height=320, + ), + ], + gap=1, + ) diff --git a/hotdata_marimo/sql_editor.py b/hotdata_marimo/sql_editor.py index 590ad86..ead9d9a 100644 --- a/hotdata_marimo/sql_editor.py +++ b/hotdata_marimo/sql_editor.py @@ -56,11 +56,6 @@ def ui(self): return mo.vstack( [ self.sql, - mo.md( - f"_Run clicks: {self.run.value} · " - f"Rerun clicks: {self.rerun.value} · " - f"Clear clicks: {self.clear.value}_" - ), mo.hstack( [ self.run, @@ -88,33 +83,22 @@ def ui(self): gap=1, ) - @property - def result(self) -> QueryResult: - run_n = self.run.value - rerun_n = self.rerun.value - clear_n = self.clear.value - + def _apply_clear(self, clear_n: int) -> bool: if clear_n > 0 and self._last_clear_n != clear_n: self._result_cache = None self._cached_sql = None self._last_clear_n = clear_n - mo.stop(True, mo.md("Result cleared. Click **Run on Hotdata** to execute again.")) + return True + return False - mo.stop( - run_n == 0 and rerun_n == 0, - mo.md( - "**Run on Hotdata** is on the SQL editor UI (a cell that **returns** " - "`editor.ui` or `mo.vstack([browser.ui, editor.ui])`). Click it there, " - "then this cell will run." - ), - ) + def _execute_or_cached(self) -> QueryResult | None: sql_text = self.sql.value + run_n = self.run.value + rerun_n = self.rerun.value if rerun_n > 0 and rerun_n != self._last_rerun_n: - mo.stop( - self._cached_sql is None, - mo.md("No previous SQL to rerun yet — click **Run on Hotdata** first."), - ) + if self._cached_sql is None: + return None with mo.status.spinner( title="Running on Hotdata", subtitle="Re-running last query and waiting for results…", @@ -138,8 +122,69 @@ def result(self) -> QueryResult: if self._result_cache is not None and sql_text == self._cached_sql: return self._result_cache + return None + + @property + def result_panel(self): + from hotdata_marimo.display import query_result + + run_n = self.run.value + rerun_n = self.rerun.value + clear_n = self.clear.value + + if self._apply_clear(clear_n): + return mo.md("Result cleared. Click **Run on Hotdata** to execute again.") + + if run_n == 0 and rerun_n == 0 and self._result_cache is None: + return mo.md("_Click **Run on Hotdata** to execute._") + + if rerun_n > 0 and rerun_n != self._last_rerun_n and self._cached_sql is None: + return mo.md("No previous SQL to rerun yet — click **Run on Hotdata** first.") + + result = self._execute_or_cached() + if result is not None: + return query_result(result) + + return mo.md("SQL changed — click **Run on Hotdata** again to execute.") + + @property + def tab_ui(self): + _ = self.run.value + _ = self.rerun.value + _ = self.clear.value + _ = self.show_history.value + return mo.vstack([self.ui, self.result_panel], gap=2) + + @property + def result(self) -> QueryResult: + run_n = self.run.value + rerun_n = self.rerun.value + clear_n = self.clear.value + + if self._apply_clear(clear_n): + mo.stop(True, mo.md("Result cleared. Click **Run on Hotdata** to execute again.")) + + mo.stop( + run_n == 0 and rerun_n == 0, + mo.md( + "**Run on Hotdata** is on the SQL editor UI (a cell that **returns** " + "`editor.ui` or `mo.vstack([browser.ui, editor.ui])`). Click it there, " + "then this cell will run." + ), + ) + + if rerun_n > 0 and rerun_n != self._last_rerun_n: + mo.stop( + self._cached_sql is None, + mo.md("No previous SQL to rerun yet — click **Run on Hotdata** first."), + ) + + result = self._execute_or_cached() + if result is not None: + return result + mo.stop( - self._cached_sql is None or sql_text != self._cached_sql, + True, mo.md("SQL changed — click **Run on Hotdata** again to execute."), ) diff --git a/hotdata_marimo/sql_engine.py b/hotdata_marimo/sql_engine.py index 44985cf..5b7a6e9 100644 --- a/hotdata_marimo/sql_engine.py +++ b/hotdata_marimo/sql_engine.py @@ -40,7 +40,6 @@ def __init__( ) -> None: super().__init__(connection, engine_name) self._connections_cache: list[Any] | None = None - self._connection_id_cache: dict[str, str] | None = None @property def source(self) -> str: @@ -71,15 +70,12 @@ def _resolve_should_auto_discover( return True return value - def _connection_ids(self) -> dict[str, str]: - if self._connection_id_cache is None: - self._connection_id_cache = { - str(c.name): str(c.id) for c in self._connections() - } - return self._connection_id_cache - def _connection_id(self, connection_name: str) -> str | None: - return self._connection_ids().get(connection_name) + try: + return self._connection.connection_id_by_name().get(connection_name) + except RuntimeError as e: + LOGGER.warning("%s", e) + return None def _connections(self) -> list[Any]: if self._connections_cache is None: diff --git a/hotdata_marimo/table_browser.py b/hotdata_marimo/table_browser.py index 782be26..e25d6f4 100644 --- a/hotdata_marimo/table_browser.py +++ b/hotdata_marimo/table_browser.py @@ -6,39 +6,11 @@ from hotdata_runtime import HotdataClient - -def _connection_options(conns: list[Any]) -> dict[str, str]: - counts: dict[str, int] = {} - options: dict[str, str] = {} - for c in conns: - label = c.name - count = counts.get(label, 0) - counts[label] = count + 1 - key = label if count == 0 else f"{label} ({c.id})" - options[key] = c.id - return options - - -def connection_picker( - client: HotdataClient, - *, - label: str = "Connection", - full_width: bool = True, -): - listing = client.connections().list_connections() - conns = listing.connections - if not conns: - return mo.ui.dropdown( - options={"(no connections)": ""}, - label=label, - full_width=full_width, - ) - options = _connection_options(conns) - return mo.ui.dropdown( - options=options, - label=label, - full_width=full_width, - ) +from hotdata_marimo._options import ( + connection_picker, + empty_dropdown, + resolve_connection_picker, +) class TableBrowser: @@ -66,44 +38,42 @@ def __init__( self._implicit_connection_id: str | None = None if self._override_connection_id is None: - listing = client.connections().list_connections() - conns = listing.connections - if len(conns) > 1: - self._conn_pick = connection_picker(client) - elif len(conns) == 1: - self._implicit_connection_id = conns[0].id - else: - self._implicit_connection_id = "" + self._conn_pick, self._implicit_connection_id = resolve_connection_picker( + client + ) self._table_pick_ctx: str | None = None + self._rebuilt_table_pick_this_run = False + self._init_table_pick() + def _init_table_pick(self) -> None: if self._conn_pick is not None: - self.table_pick = mo.ui.dropdown( - options={"(select connection above)": ""}, + self.table_pick = empty_dropdown( label="Table", - full_width=True, + message="(select connection above)", + ) + self._empty_catalog = True + self._all_names = [] + self._table_pick_ctx = "" + return + + names = self._names_for_active_connection() + self._all_names = names + if not names: + self.table_pick = empty_dropdown( + label="Table", + message="(no tables in catalog)", ) self._empty_catalog = True - self._all_names: list[str] = [] else: - names = self._names_for_active_connection() - self._all_names = names - if not names: - self.table_pick = mo.ui.dropdown( - options={"(no tables in catalog)": ""}, - label="Table", - full_width=True, - ) - self._empty_catalog = True - else: - self._empty_catalog = False - self.table_pick = mo.ui.dropdown( - options={n: n for n in names}, - label="Table", - full_width=True, - searchable=True, - ) - self._table_pick_ctx = self._active_connection_id() + self._empty_catalog = False + self.table_pick = mo.ui.dropdown( + options={n: n for n in names}, + label="Table", + full_width=True, + searchable=True, + ) + self._table_pick_ctx = self._active_connection_id() def _active_connection_id(self) -> str | None: if self._override_connection_id is not None: @@ -128,10 +98,9 @@ def _rebuild_table_pick(self, names: list[str]) -> None: self._all_names = names if not names: self._empty_catalog = True - self.table_pick = mo.ui.dropdown( - options={"(no tables in catalog)": ""}, + self.table_pick = empty_dropdown( label="Table", - full_width=True, + message="(no tables in catalog)", ) else: self._empty_catalog = False @@ -153,35 +122,42 @@ def selected_table(self) -> str | None: v = self.table_pick.value return v if v else None - @property - def ui(self): - self._rebuilt_table_pick_this_run = False - + def _sync_table_catalog(self) -> None: + """Refresh the table dropdown when the active connection changes.""" if self._conn_pick is not None: - _ = self._conn_pick.value - + _ = self._conn_pick.value # type: ignore[attr-defined] cid = self._active_connection_id() - names = self._names_for_active_connection() + if not cid: + return + if cid == self._table_pick_ctx: + return + self._rebuild_table_pick(self._names_for_active_connection()) - if cid and cid != self._table_pick_ctx: - self._rebuild_table_pick(names) + @property + def ui(self): + self._rebuilt_table_pick_this_run = False + self._sync_table_catalog() if not self._rebuilt_table_pick_this_run: _ = self.table_pick.value sel = None if self._rebuilt_table_pick_this_run else self.selected_table + cid = self._active_connection_id() conn_header = ( mo.md(f"**Connection** `{self._active_connection_id()}`") if self._active_connection_id() else None ) if not sel: - hint = ( - "_No tables returned from the information schema. " - "Try refreshing a connection in Hotdata._" - if self._empty_catalog - else "Choose a table below (search inside the dropdown when needed)." - ) + if self._conn_pick is not None and not cid: + hint = "Choose a connection above to load tables." + elif self._empty_catalog: + hint = ( + "_No tables returned from the information schema. " + "Try refreshing a connection in Hotdata._" + ) + else: + hint = "Choose a table below (search inside the dropdown when needed)." stack = [ mo.md( f"**Workspace** `{self._client.workspace_id}` — {hint}" diff --git a/hotdata_marimo/workspace_selector.py b/hotdata_marimo/workspace_selector.py index 9bdd63d..0b9425d 100644 --- a/hotdata_marimo/workspace_selector.py +++ b/hotdata_marimo/workspace_selector.py @@ -9,6 +9,8 @@ resolve_workspace_selection, ) +from hotdata_marimo._options import unique_label_options + class WorkspaceSelector: """Workspace picker that rebuilds `HotdataClient` as selection changes.""" @@ -37,17 +39,20 @@ def __init__( self._workspace_id = workspaces[0].public_id return - labels: list[tuple[str, str]] = [] - seen: set[str] = set() - for w in workspaces: - base = w.name - label_text = base if base not in seen else f"{base} ({w.public_id})" - seen.add(base) - labels.append((label_text, w.public_id)) - - labels.sort(key=lambda t: 0 if t[1] == selection.workspace_id else 1) - options = {k: v for k, v in labels} - self._pick = mo.ui.dropdown(options=options, label=label, full_width=True) + pairs = [(w.name, w.public_id) for w in workspaces] + options = unique_label_options( + pairs, + disambiguate=lambda name, public_id, count: f"{name} ({public_id})", + ) + items = sorted( + options.items(), + key=lambda item: 0 if item[1] == selection.workspace_id else 1, + ) + self._pick = mo.ui.dropdown( + options=dict(items), + label=label, + full_width=True, + ) self._workspace_id = selection.workspace_id @property diff --git a/tests/test_hotdata_marimo.py b/tests/test_hotdata_marimo.py index 377f249..7a6fe36 100644 --- a/tests/test_hotdata_marimo.py +++ b/tests/test_hotdata_marimo.py @@ -7,9 +7,10 @@ import hotdata_marimo as hm from hotdata_runtime import HotdataClient -from hotdata_marimo.display import _option_map_with_unique_labels +from hotdata_marimo._options import connection_options, unique_label_options +from hotdata_marimo.display import connections_panel from hotdata_marimo.sql_engine import HotdataMarimoEngine -from hotdata_marimo.table_browser import _connection_options +from hotdata_marimo.table_browser import TableBrowser from hotdata_marimo.workspace_selector import WorkspaceSelector, workspace_selector_from_env from marimo._types.ids import VariableName @@ -39,8 +40,8 @@ def _workspace_row(name: str, public_id: str, *, active: bool = True): ), ], ) -def test_option_map_with_unique_labels(labels, expected): - assert _option_map_with_unique_labels(labels) == expected +def test_unique_label_options(labels, expected): + assert unique_label_options(labels) == expected def test_connection_options_disambiguates_duplicate_names(): @@ -49,7 +50,7 @@ def test_connection_options_disambiguates_duplicate_names(): SimpleNamespace(name="Warehouse", id="conn_2"), SimpleNamespace(name="Analytics", id="conn_3"), ] - assert _connection_options(conns) == { + assert connection_options(conns) == { "Warehouse": "conn_1", "Warehouse (conn_2)": "conn_2", "Analytics": "conn_3", @@ -105,6 +106,72 @@ def test_workspace_selector(resolve, expect_dropdown, expected_workspace): assert selector.client.workspace_id == expected_workspace +def test_connections_panel_lists_connections(mock_client): + mock_client.connections.return_value.list_connections.return_value = ( + SimpleNamespace( + connections=[ + SimpleNamespace( + name="Warehouse", + id="conn_1", + source_type="postgres", + ), + SimpleNamespace( + name="Analytics", + id="conn_2", + source_type="snowflake", + ), + ] + ) + ) + with patch("hotdata_marimo.display.workspace_health_lines", return_value=(True, ["ok"])): + panel = connections_panel(mock_client) + assert panel is not None + + +def test_recent_results_table_selection(mock_client): + mock_client.list_recent_results.return_value = [ + SimpleNamespace( + created_at="2026-05-18T12:00:00Z", + status="succeeded", + result_id="res_1", + ), + SimpleNamespace( + created_at="2026-05-18T11:00:00Z", + status="failed", + result_id="res_2", + ), + ] + table = MagicMock() + table.value = [{"result_id": "res_2"}] + with patch("hotdata_marimo.display.mo.ui.table", return_value=table): + recent = hm.recent_results(mock_client, limit=20) + assert recent.selected_result_id == "res_2" + table.value = [] + assert recent.selected_result_id is None + + +def test_table_browser_rebuilds_tables_when_connection_changes(mock_client): + pick = MagicMock() + pick.value = "" + mock_client.list_qualified_table_names.return_value = [] + + with patch( + "hotdata_marimo.table_browser.resolve_connection_picker", + return_value=(pick, None), + ): + browser = TableBrowser(mock_client) + + browser._sync_table_catalog() + assert browser._table_pick_ctx == "" + + pick.value = "conn_1" + mock_client.list_qualified_table_names.return_value = ["azure.public.customer"] + browser._sync_table_catalog() + assert browser._table_pick_ctx == "conn_1" + assert browser._all_names == ["azure.public.customer"] + assert not browser._empty_catalog + + def test_workspace_selector_from_env_requires_api_key(monkeypatch: pytest.MonkeyPatch): monkeypatch.delenv("HOTDATA_API_KEY", raising=False) with pytest.raises(RuntimeError, match="HOTDATA_API_KEY"):