Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 1 addition & 2 deletions contrib/bubseek-marimo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Open `http://localhost:2718/` — marimo gallery. Click **dashboard** for chat +

- Canonical runtime location: `<workspace>/insights/*.py`
- `dashboard.py` and `index.py` are generated starter notebooks, not hand-maintained repository assets
- `workspace` resolution order: `BUB_MARIMO_WORKSPACE` -> `BUB_WORKSPACE_PATH` -> current working directory
- `workspace` resolution order: `BUB_MARIMO_WORKSPACE` -> current working directory
- Packaged plugin installs still write generated notebooks into the active workspace, never into the installed package directory
- Format: single `.py` with `@app.cell`, PEP 723
- Gallery notebooks must contain the scanner markers `import marimo` and `marimo.App`
Expand All @@ -52,4 +52,3 @@ The E2E suite runs with `BUB_RUNTIME_ENABLED=0`, so it does not need a remote mo
| `BUB_MARIMO_HOST` | Bind host (default: `127.0.0.1`) |
| `BUB_MARIMO_PORT` | Bind port (default: `2718`) |
| `BUB_MARIMO_WORKSPACE` | Override the Bub workspace root used for runtime notebook output |
| `BUB_WORKSPACE_PATH` | Default Bub workspace root when `BUB_MARIMO_WORKSPACE` is unset |
87 changes: 7 additions & 80 deletions contrib/bubseek-marimo/src/bubseek_marimo/channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,78 +17,14 @@
from loguru import logger
from pydantic_settings import BaseSettings, SettingsConfigDict

from bubseek.oceanbase import resolve_tapestore_url
from bubseek_marimo.chat_store import MarimoChatStore, TurnConflictError
from bubseek_marimo.notebooks import ensure_seed_notebooks

if TYPE_CHECKING:
from aiohttp import web as web_types


def _discover_project_root_fallback(start: Path) -> Path | None:
"""Walk up from start for a directory containing .env (used when bubseek not installed)."""
for d in [start, *start.parents]:
if (d / ".env").is_file():
return d
return None


def discover_project_root(start: Path | str | None = None) -> Path | None:
"""Use bubseek.config when available, otherwise fall back to local discovery."""
if start is None:
start = Path.cwd()
start = Path(start)
try:
from bubseek.config import discover_project_root as _discover_project_root
except ImportError:
return _discover_project_root_fallback(start)
else:
return _discover_project_root(start)


def env_with_workspace_dotenv(workspace: Path | str) -> dict[str, str]:
"""Load environment variables from workspace/.env with a local fallback."""
workspace = Path(workspace)
try:
from bubseek.config import env_with_workspace_dotenv as _env_with_workspace_dotenv
except ImportError:
from dotenv import dotenv_values

env = dict(os.environ)
env_file = workspace / ".env"
if env_file.is_file():
for key, value in dotenv_values(env_file).items():
if isinstance(key, str) and isinstance(value, str):
env[key] = value
return env
else:
return _env_with_workspace_dotenv(workspace)


def resolve_tapestore_url(workspace: Path | str | None = None, discover_from: Path | str | None = None) -> str:
"""Resolve the tape store URL using bubseek helpers when they are installed."""
if workspace is not None:
workspace = Path(workspace)
try:
from bubseek.config import resolve_tapestore_url as _resolve_tapestore_url
except ImportError:
if workspace is not None:
url = (env_with_workspace_dotenv(workspace).get("BUB_TAPESTORE_SQLALCHEMY_URL") or "").strip()
else:
start = Path(discover_from).resolve() if discover_from else Path.cwd().resolve()
for d in [start, *start.parents]:
if (d / ".env").is_file():
url = (env_with_workspace_dotenv(d).get("BUB_TAPESTORE_SQLALCHEMY_URL") or "").strip()
break
else:
url = (os.environ.get("BUB_TAPESTORE_SQLALCHEMY_URL") or "").strip()
else:
url = _resolve_tapestore_url(workspace, discover_from)

if url:
return url
raise RuntimeError("BUB_TAPESTORE_SQLALCHEMY_URL is required for the marimo channel")


def _load_web() -> Any:
"""Load aiohttp.web or raise a clear runtime error."""
try:
Expand Down Expand Up @@ -148,39 +84,30 @@ def __init__(self, on_receive: MessageHandler) -> None:
def _workspace_dir(self) -> Path:
if self._config.workspace:
return Path(self._config.workspace).resolve()

workspace = os.environ.get("BUB_WORKSPACE_PATH")
if workspace:
return Path(workspace).resolve()

for start in (Path.cwd(), Path(__file__).resolve().parent):
root = discover_project_root(start)
if root is not None:
return root
return Path.cwd().resolve()

def _insights_dir(self) -> Path:
return self._workspace_dir() / "insights"

def _tapestore_url(self) -> str:
return resolve_tapestore_url(self._workspace_dir())
if url := resolve_tapestore_url():
return url
raise RuntimeError("BUB_TAPESTORE_SQLALCHEMY_URL is required for the marimo channel")

def _ensure_seed_notebooks(self) -> None:
insights_dir = self._insights_dir()
workspace = self._workspace_dir()
logger.info(
"Marimo workspace={} .env={} exists={}",
"Marimo workspace={}",
workspace,
workspace / ".env",
(workspace / ".env").is_file(),
)
ensure_seed_notebooks(insights_dir)
logger.info("Ensured starter notebooks under {}", insights_dir)

def _marimo_env(self) -> dict[str, str]:
workspace = self._workspace_dir()
env = env_with_workspace_dotenv(workspace)
env["BUB_WORKSPACE_PATH"] = str(workspace)
env = dict(os.environ)
env["BUB_MARIMO_WORKSPACE"] = str(workspace)
env["BUB_MARIMO_PORT"] = str(self._config.port)
return env

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def _(items, mo):
## 7. Embedded data

- For **self-contained** notebooks (e.g. Iris, demos), embed the full dataset **inside the first cell** (or in the same cell that uses it). Define any helper (e.g. `_row(...)`) in the same cell so it runs in the same isolated namespace; do not define it at module level and expect it to be visible in the cell.
- When the notebook depends on **environment or config** (e.g. DB URL), read via **bubseek settings** where possible (e.g. `BubSeekSettings().db.resolved_tapestore_url`) so it matches the rest of the app; document required env (e.g. `BUB_TAPESTORE_SQLALCHEMY_URL`) and add dependencies (e.g. `pyobvector`) in the script block when using OceanBase/seekdb.
- When the notebook depends on **environment or config** (e.g. DB URL), read via **bubseek runtime helpers** where possible (e.g. `resolve_tapestore_url()` from `bubseek.oceanbase`) so it matches the rest of the app; document required env (e.g. `BUB_TAPESTORE_SQLALCHEMY_URL`) and add dependencies (e.g. `pyobvector`) in the script block when using OceanBase/seekdb.

---

Expand Down
21 changes: 9 additions & 12 deletions contrib/bubseek-marimo/tests/test_marimo_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,14 @@ async def _noop_handler(*_args, **_kwargs) -> None:
return None


class _OceanBaseStubModule(ModuleType):
def resolve_tapestore_url(self, url: str | None = None) -> str:
return (url or os.environ.get("BUB_TAPESTORE_SQLALCHEMY_URL") or "").strip()


def _stub_bubseek_oceanbase(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setitem(sys.modules, "bubseek.oceanbase", ModuleType("bubseek.oceanbase"))
module = _OceanBaseStubModule("bubseek.oceanbase")
monkeypatch.setitem(sys.modules, "bubseek.oceanbase", module)


def _require_tapestore_url() -> str:
Expand Down Expand Up @@ -102,15 +108,13 @@ def _assert_notebook_loads(filename: str) -> tuple[int, str]:
return status, body


def test_workspace_resolution_priority(monkeypatch, tmp_path) -> None:
def test_workspace_resolution_uses_explicit_marimo_workspace(monkeypatch, tmp_path) -> None:
_stub_bubseek_oceanbase(monkeypatch)
from bubseek_marimo.channel import MarimoChannel

marimo_workspace = tmp_path / "marimo-workspace"
bubb_workspace = tmp_path / "bub-workspace"
monkeypatch.setenv("BUB_TAPESTORE_SQLALCHEMY_URL", "mysql+oceanbase://seek:secret@seekdb.example:2881/analytics")
monkeypatch.setenv("BUB_MARIMO_WORKSPACE", str(marimo_workspace))
monkeypatch.setenv("BUB_WORKSPACE_PATH", str(bubb_workspace))

channel = MarimoChannel(_noop_handler)

Expand All @@ -124,11 +128,7 @@ def test_workspace_resolution_falls_back_to_cwd(monkeypatch, tmp_path) -> None:

monkeypatch.setenv("BUB_TAPESTORE_SQLALCHEMY_URL", "mysql+oceanbase://seek:secret@seekdb.example:2881/analytics")
monkeypatch.delenv("BUB_MARIMO_WORKSPACE", raising=False)
monkeypatch.delenv("BUB_WORKSPACE_PATH", raising=False)
monkeypatch.chdir(tmp_path)
# When no env and cwd has no .env, discover finds repo from channel __file__; force fallback to cwd
monkeypatch.setattr("bubseek_marimo.channel.discover_project_root", lambda start: None)
monkeypatch.setattr("bubseek_marimo.channel._discover_project_root_fallback", lambda start: None)

channel = MarimoChannel(_noop_handler)

Expand All @@ -142,10 +142,7 @@ def test_marimo_channel_requires_explicit_tapestore_url(monkeypatch, tmp_path) -

monkeypatch.delenv("BUB_TAPESTORE_SQLALCHEMY_URL", raising=False)
monkeypatch.delenv("BUB_MARIMO_WORKSPACE", raising=False)
monkeypatch.delenv("BUB_WORKSPACE_PATH", raising=False)
monkeypatch.chdir(tmp_path)
monkeypatch.setattr("bubseek_marimo.channel.discover_project_root", lambda start: None)
monkeypatch.setattr("bubseek_marimo.channel._discover_project_root_fallback", lambda start: None)

with pytest.raises(RuntimeError, match="BUB_TAPESTORE_SQLALCHEMY_URL is required"):
MarimoChannel(_noop_handler)
Expand All @@ -165,7 +162,7 @@ def gateway_process():
MARIMO_PORT = _pick_free_port()
env["BUB_MARIMO_PORT"] = str(PORT)
env["BUB_MARIMO_MARIMO_PORT"] = str(MARIMO_PORT)
env["BUB_WORKSPACE_PATH"] = str(workspace)
env["BUB_MARIMO_WORKSPACE"] = str(workspace)
env["BUB_RUNTIME_ENABLED"] = "0"
if shutil.which("marimo") is None:
pytest.skip("marimo executable is not available in the current environment")
Expand Down
2 changes: 1 addition & 1 deletion contrib/bubseek-schedule/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ dependencies = [

## Debug: job in chat but not in Marimo kanban / DB

The gateway resolves the job store URL from `BUB_TAPESTORE_SQLALCHEMY_URL` in the workspace `.env` or process environment. Marimo must use the **same** URL.
The gateway resolves the job store URL from `BUB_TAPESTORE_SQLALCHEMY_URL` in the process environment. Marimo must use the **same** URL.

From the bubseek repo root:

Expand Down
10 changes: 5 additions & 5 deletions contrib/bubseek-schedule/src/bubseek_schedule/jobstore.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,17 @@


def _get_jobstore_url() -> str:
"""Resolve tapestore URL (workspace .env, BUB_WORKSPACE_PATH, cwd) like the rest of bubseek."""
from bubseek.config import resolve_tapestore_url
"""Resolve tapestore URL from the shared runtime environment."""
from bubseek.oceanbase import resolve_tapestore_url

return resolve_tapestore_url()


def _normalize_url(url: str) -> str:
"""Use mysql+oceanbase for pyobvector dialect when mysql is configured."""
if "mysql" in url.lower() and "oceanbase" not in url.lower():
return url.replace("mysql+pymysql", "mysql+oceanbase", 1).replace("mysql://", "mysql+oceanbase://", 1)
return url
from bubseek.oceanbase import normalize_oceanbase_url

return normalize_oceanbase_url(url)


class OceanBaseJobStore(BaseJobStore):
Expand Down
1 change: 0 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@ Visit http://127.0.0.1:2718 after enabling.
| `BUB_MAX_STEPS` | Max steps per conversation |
| `BUB_MAX_TOKENS` | Max tokens per response |
| `BUB_SEARCH_OLLAMA_API_KEY` | For web search tool |
| `BUB_WORKSPACE_PATH` | Workspace directory |

## Add contrib packages

Expand Down
51 changes: 6 additions & 45 deletions insights/schedule_kanban.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,65 +21,26 @@


@app.cell
def _(): # noqa: C901
def _():
import contextlib
import os
import pickle
from datetime import UTC, datetime
from pathlib import Path

import marimo as mo
from sqlalchemy import MetaData, Table, case, create_engine, inspect, select, text

_default_seekdb = "mysql+oceanbase://root:@127.0.0.1:2881/bub"

def _resolve_tapestore_like_gateway() -> str: # noqa: C901
"""Same DB as bubseek: env → workspace → repo .env (via __file__) → notebook_dir."""
def _resolve_tapestore_like_gateway() -> str:
"""Use the same tapestore URL as the gateway process."""
try:
from bubseek.config import discover_project_root, resolve_tapestore_url
from bubseek.oceanbase import resolve_tapestore_url
except Exception:
return _default_seekdb

direct = (os.environ.get("BUB_TAPESTORE_SQLALCHEMY_URL") or "").strip()
if direct:
return direct

ws = (os.environ.get("BUB_WORKSPACE_PATH") or "").strip()
if ws:
with contextlib.suppress(Exception):
return resolve_tapestore_url(workspace=Path(ws).resolve())

file_parent = None
with contextlib.suppress(NameError):
if __file__ and str(__file__).strip():
file_parent = Path(__file__).resolve().parent
if file_parent is not None:
root = discover_project_root(file_parent)
if root is not None:
with contextlib.suppress(Exception):
return resolve_tapestore_url(workspace=root)

discover = None
with contextlib.suppress(Exception):
nd = getattr(mo, "notebook_dir", None)
if callable(nd) and nd() is not None:
discover = Path(nd()).resolve()
if discover is not None:
with contextlib.suppress(Exception):
u = resolve_tapestore_url(workspace=None, discover_from=discover)
if u:
return u

if file_parent is not None:
with contextlib.suppress(Exception):
u = resolve_tapestore_url(workspace=None, discover_from=file_parent)
if u:
return u

with contextlib.suppress(Exception):
u = resolve_tapestore_url()
if u:
return u
if url := resolve_tapestore_url():
return url
return _default_seekdb

try:
Expand Down
18 changes: 4 additions & 14 deletions insights/tape_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# ///
"""Tape Monitor — compact seekdb tapestore dashboard (tabs: Summary / Runs / Tokens / More).

Tapestore URL: `bubseek.config.resolve_tapestore_url` · Marimo: `http://localhost:2718/?file=tape_monitor.py`
Tapestore URL: `bubseek.oceanbase.resolve_tapestore_url` · Marimo: `http://localhost:2718/?file=tape_monitor.py`
"""

import marimo as mo
Expand All @@ -19,7 +19,6 @@ def _():
import os
import re
from datetime import UTC, datetime
from pathlib import Path
from urllib.parse import urlparse

import marimo as mo
Expand All @@ -30,18 +29,9 @@ def _():
tapestore_url = None
try:
try:
from bubseek.config import resolve_tapestore_url

discover = None
with contextlib.suppress(Exception):
nd = getattr(mo, "notebook_dir", None)
if callable(nd) and nd() is not None:
discover = Path(nd()).resolve()
if discover is None:
with contextlib.suppress(NameError):
if __file__ and str(__file__).strip():
discover = Path(__file__).resolve().parent
tapestore_url = resolve_tapestore_url(workspace=None, discover_from=discover)
from bubseek.oceanbase import resolve_tapestore_url

tapestore_url = resolve_tapestore_url()
except Exception:
tapestore_url = (os.environ.get("BUB_TAPESTORE_SQLALCHEMY_URL") or "").strip() or _default_seekdb
if not tapestore_url:
Expand Down
2 changes: 1 addition & 1 deletion scripts/create-bub-db.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
from __future__ import annotations

if __name__ == "__main__":
from bubseek.database import ensure_database
from bubseek.oceanbase import ensure_database

ensure_database()
Loading
Loading