Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ Marimo only shows **what you `return` from a cell**. Calling `mo.vstack(...)` or

See `examples/hotdata_basic.py` for a full notebook: five Python cells (`mo.vstack` for **controls only**, then a separate cell `return hm.query_result(editor.result)` so results show immediately — **avoid** `mo.lazy` here: it only renders after the block scrolls into view, which looks like an empty cell). If Marimo shows **empty cells**, quit and remove `examples/__marimo__/` so the UI reloads from the `.py` file only.

## Examples

- `examples/hotdata_basic.py` — end-to-end editor + browser + result rendering flow.

Run:

```bash
marimo edit examples/hotdata_basic.py --no-token
```

## 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`**.
Expand Down
62 changes: 37 additions & 25 deletions hotdata_marimo/display.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,20 @@

import marimo as mo

from hotdata_runtime.client import HotdataClient
from hotdata_runtime.health import workspace_health_lines
from hotdata_runtime.result import QueryResult
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(
Expand All @@ -27,17 +38,18 @@ def query_result(
)
else:
trunc = None
meta = result.metadata_dict()
meta_bits = []
if result.result_id:
meta_bits.append(f"**result_id** `{result.result_id}`")
if result.query_run_id:
meta_bits.append(f"**query_run_id** `{result.query_run_id}`")
if result.execution_time_ms is not None:
meta_bits.append(f"**execution_time_ms** {result.execution_time_ms}")
if result.warning:
meta_bits.append(f"**warning** {result.warning}")
if result.error_message:
meta_bits.append(f"**error** {result.error_message}")
if meta["result_id"]:
meta_bits.append(f"**result_id** `{meta['result_id']}`")
if meta["query_run_id"]:
meta_bits.append(f"**query_run_id** `{meta['query_run_id']}`")
if meta["execution_time_ms"] is not None:
meta_bits.append(f"**execution_time_ms** {meta['execution_time_ms']}")
if meta["warning"]:
meta_bits.append(f"**warning** {meta['warning']}")
if meta["error_message"]:
meta_bits.append(f"**error** {meta['error_message']}")
header = mo.md(" · ".join(meta_bits) if meta_bits else "_No metadata._")
df = result.to_pandas()
tbl = mo.ui.table(
Expand All @@ -59,11 +71,12 @@ def query_result(
class RecentResults:
def __init__(self, client: HotdataClient, *, limit: int = 50) -> None:
self._client = client
listing = client.results().list_results(limit=limit, offset=0)
self._results = listing.results
options = {
f"{r.created_at} · {r.status} · {r.id}": r.id for r in self._results
}
self._results = client.list_recent_results(limit=limit, offset=0)
option_pairs = [
(f"{r.created_at} · {r.status} · {r.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",
Expand Down Expand Up @@ -97,20 +110,19 @@ def run_history(
limit: int = 20,
label: str = "Run history",
):
runs = client.query_runs().list_query_runs(limit=limit).query_runs
runs = client.list_run_history(limit=limit)
if not runs:
return mo.md("_No query runs returned._")

rows: list[dict[str, object]] = []
for r in runs:
rows.append(
{
"created_at": getattr(r, "created_at", None),
"status": getattr(r, "status", None),
"execution_time_ms": getattr(r, "execution_time_ms", None),
"result_id": getattr(r, "result_id", None),
"query_run_id": getattr(r, "id", None)
or getattr(r, "query_run_id", None),
"created_at": r.created_at,
"status": r.status,
"execution_time_ms": r.execution_time_ms,
"result_id": r.result_id,
"query_run_id": r.query_run_id,
}
)

Expand Down
3 changes: 1 addition & 2 deletions hotdata_marimo/sql_editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@

import marimo as mo

from hotdata_runtime.client import HotdataClient
from hotdata_runtime.result import QueryResult
from hotdata_runtime import HotdataClient, QueryResult


class SqlEditor:
Expand Down
21 changes: 18 additions & 3 deletions hotdata_marimo/table_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,19 @@

import marimo as mo

from hotdata_runtime.client import HotdataClient
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
Comment thread
eddietejeda marked this conversation as resolved.


def connection_picker(
Expand All @@ -21,7 +33,7 @@ def connection_picker(
label=label,
full_width=full_width,
)
options = {c.name: c.id for c in conns}
options = _connection_options(conns)
return mo.ui.dropdown(
options=options,
label=label,
Expand Down Expand Up @@ -182,7 +194,10 @@ def ui(self):
stack.append(self.table_pick)
return mo.vstack(stack, gap=1)

cols = self._client.columns_for_qualified(sel)
cols = self._client.columns_for_qualified(
sel,
connection_id=self.selected_connection_id,
)
if not cols:
body = mo.md("_No column metadata returned (check catalog sync)._")
else:
Expand Down
25 changes: 9 additions & 16 deletions hotdata_marimo/workspace_selector.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
from __future__ import annotations

import marimo as mo
from hotdata_runtime.client import HotdataClient
from hotdata_runtime.env import (
from hotdata_runtime import (
HotdataClient,
default_api_key,
default_host,
default_session_id,
explicit_workspace_id,
list_workspaces,
resolve_workspace_selection,
)


Expand All @@ -25,25 +24,19 @@ def __init__(
self._api_key = api_key
self._host = host or default_host()
self._session_id = session_id
self._explicit = explicit_workspace_id()

workspaces = list_workspaces(api_key, self._host, session_id)
if not workspaces:
raise RuntimeError("No Hotdata workspaces found for this API key.")

selection = resolve_workspace_selection(api_key, self._host, session_id)
self._explicit = selection.source == "explicit_env"
if self._explicit:
self._pick = None
self._workspace_id = self._explicit
self._workspace_id = selection.workspace_id
return

workspaces = selection.workspaces
if len(workspaces) == 1:
self._pick = None
self._workspace_id = workspaces[0].public_id
return

active = [w for w in workspaces if w.active]
chosen = active[0] if active else workspaces[0]

labels: list[tuple[str, str]] = []
seen: set[str] = set()
for w in workspaces:
Expand All @@ -52,10 +45,10 @@ def __init__(
seen.add(base)
labels.append((label_text, w.public_id))

labels.sort(key=lambda t: 0 if t[1] == chosen.public_id else 1)
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)
self._workspace_id = chosen.public_id
self._workspace_id = selection.workspace_id
Comment on lines 34 to +51
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: the prior code raised RuntimeError("No Hotdata workspaces found for this API key.") before reaching the picker logic. With this refactor, if resolve_workspace_selection returns source != "explicit_env" with an empty workspaces list, neither branch fires and we fall through to build a dropdown with options={} (and selection.workspace_id is whatever the runtime defaulted it to). Either confirm the runtime contract guarantees a non-empty list / raises for this case, or restore an explicit guard here so the failure mode stays a clear error rather than a broken dropdown. (not blocking)


@property
def workspace_id(self) -> str:
Expand Down
26 changes: 26 additions & 0 deletions tests/test_architecture_guardrails.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from __future__ import annotations

import re
from pathlib import Path


REPO_ROOT = Path(__file__).resolve().parents[1]
SOURCE_ROOT = REPO_ROOT / "hotdata_marimo"


def test_source_uses_hotdata_runtime_root_imports() -> None:
violations: list[str] = []
pattern = re.compile(
r"(?m)^\s*(?:from\s+hotdata_runtime\.(client|env|result|health)\s+import"
r"|import\s+hotdata_runtime\.(client|env|result|health)(?:\s|$|,|as))"
)

for path in SOURCE_ROOT.rglob("*.py"):
text = path.read_text(encoding="utf-8")
if pattern.search(text):
violations.append(str(path.relative_to(REPO_ROOT)))

assert not violations, (
"Use `from hotdata_runtime import ...` in package source; "
f"found submodule imports in: {', '.join(violations)}"
)
31 changes: 31 additions & 0 deletions tests/test_options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from __future__ import annotations

from types import SimpleNamespace

from hotdata_marimo.display import _option_map_with_unique_labels
from hotdata_marimo.table_browser import _connection_options


def test_option_map_with_unique_labels_keeps_all_values():
options = _option_map_with_unique_labels(
[("dup", "a"), ("dup", "b"), ("dup", "c")]
)
assert options == {
"dup": "a",
"dup (2)": "b",
"dup (3)": "c",
}


def test_connection_options_disambiguates_duplicate_names():
conns = [
SimpleNamespace(name="Warehouse", id="conn_1"),
SimpleNamespace(name="Warehouse", id="conn_2"),
SimpleNamespace(name="Analytics", id="conn_3"),
]
options = _connection_options(conns)
assert options == {
"Warehouse": "conn_1",
"Warehouse (conn_2)": "conn_2",
"Analytics": "conn_3",
}
5 changes: 4 additions & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading