diff --git a/README.md b/README.md index d1d420d..4a9e12d 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ bubseek turns fragmented data across operational systems, repositories, and agent runtime traces into **explainable, actionable, and shareable insights** without heavy ETL. It keeps the Bub runtime and extension model while packaging a practical default distribution for real deployments. -`bubseek` now boots through a single distribution entry point and targets SeekDB/OceanBase tape storage through the SQLAlchemy URL or `OCEANBASE_*` settings. +`bubseek` packages a practical Bub distribution with SeekDB/OceanBase defaults, bundled channels, and builtin skills, without adding a second CLI surface on top of `bub`. ## Features @@ -16,7 +16,7 @@ bubseek turns fragmented data across operational systems, repositories, and agen - **Explainability first** — Conclusions are returned together with agent reasoning context. - **Cloud-edge ready** — Supports distributed deployment and local execution boundaries. - **Agent observability** — Treats agent behavior as governed, inspectable runtime data. -- **Bub-compatible** — Forwards Bub commands directly; no fork of the core runtime. +- **Bub-compatible** — Uses Bub directly as the runtime and command surface; no fork of the core runtime. ## Quick start @@ -26,23 +26,15 @@ Requires [uv](https://docs.astral.sh/uv/) (recommended) or pip, and Python 3.12+ git clone https://github.com/ob-labs/bubseek.git cd bubseek uv sync -uv run bubseek --help -uv run bubseek chat +uv run bub --help +uv run bub chat ``` -If your runtime reads credentials from `.env`, bubseek forwards them to the Bub subprocess: - -```dotenv -BUB_MODEL=openrouter:qwen/qwen3-coder-next -BUB_API_KEY=sk-or-v1-... -BUB_API_BASE=https://openrouter.ai/api/v1 -``` - -Configure SeekDB or OceanBase before running `bubseek`, using `BUB_TAPESTORE_SQLALCHEMY_URL=mysql+oceanbase://...` or the matching `OCEANBASE_*` variables. +Configure SeekDB or OceanBase before running `bubseek`, using `BUB_TAPESTORE_SQLALCHEMY_URL=mysql+oceanbase://...`. ## Add contrib -Contrib packages remain standard Python packages. Add them as normal dependencies. The bundled channel extras resolve from GitHub-hosted `bub-contrib` packages instead of local workspace packages. +Contrib packages remain standard Python packages. Add them as normal dependencies. bubseek ships its built-in channels and marimo support by default, and resolves bundled contrib packages from GitHub-hosted `bub-contrib` packages instead of local workspace packages. ```toml [project] @@ -58,8 +50,6 @@ Then sync your environment: uv sync ``` -- Optional extras: Feishu `uv sync --extra feishu`, DingTalk `uv sync --extra dingtalk`, WeChat `uv sync --extra wechat`, Discord `uv sync --extra discord`, Marimo `uv sync --extra marimo`. - ## Documentation ## Development diff --git a/contrib/bubseek-marimo/README.md b/contrib/bubseek-marimo/README.md index cbb09cc..7991a8b 100644 --- a/contrib/bubseek-marimo/README.md +++ b/contrib/bubseek-marimo/README.md @@ -12,15 +12,15 @@ Marimo channel for Bub — native marimo dashboard with chat and insights index. ## Installation ```bash -uv sync --extra marimo +uv sync # or -pip install bubseek[marimo] +pip install . ``` ## Gateway ```bash -bubseek gateway --enable-channel marimo +bub gateway --enable-channel marimo ``` Open `http://localhost:2718/` — marimo gallery. Click **dashboard** for chat + index. The dashboard queues turns asynchronously and refreshes transcript events from the channel backend. diff --git a/contrib/bubseek-marimo/scripts/verify_marimo.sh b/contrib/bubseek-marimo/scripts/verify_marimo.sh index 4717ac5..9d5ad74 100755 --- a/contrib/bubseek-marimo/scripts/verify_marimo.sh +++ b/contrib/bubseek-marimo/scripts/verify_marimo.sh @@ -3,5 +3,5 @@ # Requires: .env with OPENROUTER_API_KEY (or equivalent) for chat. set -e cd "$(dirname "$0")/../.." -uv sync --extra marimo +uv sync uv run pytest contrib/bubseek-marimo/tests/test_marimo_e2e.py -v "$@" diff --git a/contrib/bubseek-marimo/src/bubseek_marimo/channel.py b/contrib/bubseek-marimo/src/bubseek_marimo/channel.py index 824b52f..757d3f9 100644 --- a/contrib/bubseek-marimo/src/bubseek_marimo/channel.py +++ b/contrib/bubseek-marimo/src/bubseek_marimo/channel.py @@ -104,17 +104,13 @@ def _insights_dir(self) -> Path: def _tapestore_url(self) -> str: if resolve_tapestore_url is not None: - return resolve_tapestore_url(self._workspace_dir()) - env = env_with_workspace_dotenv(self._workspace_dir()) if env_with_workspace_dotenv else self._marimo_env() - url = (env.get("BUB_TAPESTORE_SQLALCHEMY_URL") or "").strip() + url = resolve_tapestore_url(self._workspace_dir()) + else: + env = env_with_workspace_dotenv(self._workspace_dir()) if env_with_workspace_dotenv else self._marimo_env() + url = (env.get("BUB_TAPESTORE_SQLALCHEMY_URL") or "").strip() if url: return url - host = (env.get("OCEANBASE_HOST") or "127.0.0.1").strip() - port = int((env.get("OCEANBASE_PORT") or "2881").strip()) - user = (env.get("OCEANBASE_USER") or "root").strip() - password = env.get("OCEANBASE_PASSWORD") or "" - database = (env.get("OCEANBASE_DATABASE") or "bub").strip() - return f"mysql+oceanbase://{user}:{password}@{host}:{port}/{database}" + raise RuntimeError("BUB_TAPESTORE_SQLALCHEMY_URL is required for the marimo channel") def _ensure_seed_notebooks(self) -> None: insights_dir = self._insights_dir() diff --git a/contrib/bubseek-marimo/tests/test_marimo_e2e.py b/contrib/bubseek-marimo/tests/test_marimo_e2e.py index d9ded86..e3da55d 100644 --- a/contrib/bubseek-marimo/tests/test_marimo_e2e.py +++ b/contrib/bubseek-marimo/tests/test_marimo_e2e.py @@ -12,6 +12,7 @@ import sys import time from pathlib import Path +from types import ModuleType from urllib.parse import urlsplit import pytest @@ -39,6 +40,17 @@ async def _noop_handler(*_args, **_kwargs) -> None: return None +def _stub_bubseek_oceanbase(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem(sys.modules, "bubseek.oceanbase", ModuleType("bubseek.oceanbase")) + + +def _require_tapestore_url() -> str: + url = (os.environ.get("BUB_TAPESTORE_SQLALCHEMY_URL") or "").strip() + if not url: + pytest.skip("BUB_TAPESTORE_SQLALCHEMY_URL is required for marimo gateway tests") + return url + + def _port_ready(host: str, port: int, timeout: float = 2.0) -> bool: try: with socket.create_connection((host, port), timeout=timeout): @@ -91,10 +103,12 @@ def _assert_notebook_loads(filename: str) -> tuple[int, str]: def test_workspace_resolution_priority(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)) @@ -105,8 +119,10 @@ def test_workspace_resolution_priority(monkeypatch, tmp_path) -> None: def test_workspace_resolution_falls_back_to_cwd(monkeypatch, tmp_path) -> None: + _stub_bubseek_oceanbase(monkeypatch) from bubseek_marimo.channel import MarimoChannel + 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) @@ -120,6 +136,21 @@ def test_workspace_resolution_falls_back_to_cwd(monkeypatch, tmp_path) -> None: assert channel._insights_dir() == tmp_path.resolve() / "insights" +def test_marimo_channel_requires_explicit_tapestore_url(monkeypatch, tmp_path) -> None: + _stub_bubseek_oceanbase(monkeypatch) + from bubseek_marimo.channel import MarimoChannel + + 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) + + @pytest.fixture(scope="module") def gateway_process(): """Start gateway with marimo channel, yield process, cleanup on teardown.""" @@ -127,6 +158,7 @@ def gateway_process(): workspace = REPO_ROOT env = os.environ.copy() + env["BUB_TAPESTORE_SQLALCHEMY_URL"] = _require_tapestore_url() PORT = _pick_free_port() MARIMO_PORT = _pick_free_port() while MARIMO_PORT == PORT: @@ -142,7 +174,7 @@ def gateway_process(): pytest.fail("uv executable is required for marimo gateway tests") proc = subprocess.Popen( # noqa: S603 - [uv_executable, "run", "bubseek", "gateway", "--enable-channel", "marimo"], + [uv_executable, "run", "bub", "gateway", "--enable-channel", "marimo"], cwd=str(REPO_ROOT), env=env, stdout=subprocess.DEVNULL, diff --git a/contrib/bubseek-schedule/README.md b/contrib/bubseek-schedule/README.md index e69967e..cffd191 100644 --- a/contrib/bubseek-schedule/README.md +++ b/contrib/bubseek-schedule/README.md @@ -37,7 +37,7 @@ dependencies = [ - **`load_state` starts the scheduler** on the first inbound message. That way `bub chat` (CLI-only: only the `cli` channel is enabled) still persists jobs to SeekDB. Previously, `AsyncIOScheduler` was only started by the `schedule` channel, so CLI chat left jobs in memory-only `_pending_jobs` and **nothing was written to `apscheduler_jobs`**. - The channel name is `schedule`. Enabling it in `bub gateway` is optional for persistence; it still starts/stops the scheduler cleanly when you use gateway with that channel. - Jobs are persisted to: - - **OceanBase/SeekDB**: Same URL as the tape store (`BUB_TAPESTORE_SQLALCHEMY_URL` / `OCEANBASE_*`), table `apscheduler_jobs`. + - **OceanBase/SeekDB**: Same URL as the tape store (`BUB_TAPESTORE_SQLALCHEMY_URL`), table `apscheduler_jobs`. ## Provided Tools @@ -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` or workspace `.env` (`OCEANBASE_*`). Marimo must use the **same** URL. If `insights/schedule_kanban.py` pointed at the default `127.0.0.1:2881/bub` while your `.env` uses another host/db, the table will look empty. +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. From the bubseek repo root: diff --git a/contrib/bubseek-schedule/src/tests/test_bubseek_schedule.py b/contrib/bubseek-schedule/src/tests/test_bubseek_schedule.py index 3bc3604..bdcf479 100644 --- a/contrib/bubseek-schedule/src/tests/test_bubseek_schedule.py +++ b/contrib/bubseek-schedule/src/tests/test_bubseek_schedule.py @@ -18,9 +18,11 @@ def _seekdb_url() -> str: def test_jobstore_roundtrip(): """Test jobstore roundtrip via APScheduler on SeekDB/OceanBase.""" from apscheduler.schedulers.background import BackgroundScheduler + + url = _seekdb_url() from bubseek_schedule.jobstore import OceanBaseJobStore - store = OceanBaseJobStore(url=_seekdb_url(), tablename="apscheduler_jobs_test_roundtrip") + store = OceanBaseJobStore(url=url, tablename="apscheduler_jobs_test_roundtrip") scheduler = BackgroundScheduler(jobstores={"default": store}) scheduler.start() @@ -38,9 +40,11 @@ def test_jobstore_roundtrip(): def test_jobstore_get_due_jobs(): """Test get_due_jobs and get_next_run_time.""" from apscheduler.schedulers.background import BackgroundScheduler + + url = _seekdb_url() from bubseek_schedule.jobstore import OceanBaseJobStore - store = OceanBaseJobStore(url=_seekdb_url(), tablename="apscheduler_jobs_test_due") + store = OceanBaseJobStore(url=url, tablename="apscheduler_jobs_test_due") scheduler = BackgroundScheduler(jobstores={"default": store}) scheduler.start() diff --git a/docs/api-reference.md b/docs/api-reference.md index 8f0d8d7..f4d2468 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -2,14 +2,14 @@ The public Python surface is intentionally small. -## bubseek +## bubseek.config -Package root. Re-exports the CLI entry function. +Configuration helpers for resolving tapestore settings. -::: bubseek +::: bubseek.config -## bubseek.__main__ +## bubseek.database -CLI entry point. `main()` forwards CLI arguments and `.env` values to the `bub` subprocess. +Database bootstrap helpers used by maintenance scripts. -::: bubseek.__main__ +::: bubseek.database diff --git a/docs/architecture.md b/docs/architecture.md index 458df82..5730e5c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,10 +4,9 @@ This page explains what bubseek is responsible for, and what it deliberately lea ## What bubseek does -- provides the `bubseek` executable as a single bootstrap entry point over `bub` -- forwards `.env` values to the Bub subprocess - standardizes tape storage on SeekDB/OceanBase - ships a small set of builtin skills with the package +- bundles a practical set of contrib channels and tools by default - pins a practical default Bub runtime version ## What bubseek does not do @@ -25,7 +24,7 @@ Bub remains the runtime, command surface, and extension host. ### bubseek -bubseek is the distribution layer: packaging, bootstrap behavior, runtime defaults, and builtin skills. +bubseek is the distribution layer: packaging, runtime defaults, plugin wiring, and builtin skills. ### Python packaging @@ -35,7 +34,7 @@ Python packaging handles dependency resolution, lockfiles, and installation. Con From a user perspective, the benefit is simple: there is less to learn. -- run `bubseek` the same way you would run `bub` +- run `bub` - add contrib the same way you add any Python dependency - use builtin skills without an extra sync step - treat generated marimo notebooks as runtime artifacts under `insights/`, not committed templates diff --git a/docs/configuration.md b/docs/configuration.md index 51b14ad..368e625 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -30,22 +30,25 @@ dependencies = [ ] ``` -If you do not want them installed by default, put them under `optional-dependencies` instead: +For bubseek itself, the official distribution keeps its built-in channels and marimo support in the default dependency set: ```toml -[project.optional-dependencies] -feishu = ["bub-feishu"] -dingtalk = ["bub-dingtalk"] -wechat = ["bub-wechat"] -discord = ["bub-discord"] -marimo = ["bubseek-marimo"] +[project] +dependencies = [ + "bub", + "bub-feishu", + "bub-dingtalk", + "bub-wechat", + "bub-discord", + "bubseek-marimo", +] ``` -Install with: `uv sync --extra feishu` / `pip install bubseek[feishu]` (Feishu); `uv sync --extra dingtalk` / `pip install bubseek[dingtalk]` (DingTalk); `uv sync --extra wechat` / `pip install bubseek[wechat]` ([WeChat](https://github.com/bubbuild/bub-contrib/tree/main/packages/bub-wechat)); `uv sync --extra discord` / `pip install bubseek[discord]` ([Discord](https://github.com/bubbuild/bub-contrib/tree/main/packages/bub-discord)); `uv sync --extra marimo` / `pip install bubseek[marimo]` (Marimo channel with bundled notebook skills). +Install with the normal project sync or package install: `uv sync` / `pip install .`. ## Runtime credentials -bubseek forwards `.env` values to the Bub subprocess. Bub reads `BUB_*` variables (see [Bub deployment](https://github.com/bubbuild/bub/blob/main/docs/deployment.md)). +Bub reads `BUB_*` variables directly (see [Bub deployment](https://github.com/bubbuild/bub/blob/main/docs/deployment.md)). **Minimal OpenRouter setup:** @@ -68,13 +71,13 @@ BUB_API_BASE=https://openrouter.ai/api/v1 | `BUB_TELEGRAM_ALLOW_CHATS` | Comma-separated chat allowlist | | `BUB_SEARCH_OLLAMA_API_KEY` | Required for web.search tool (bundled) | | `BUB_SEARCH_OLLAMA_API_BASE` | Ollama API base (default: `https://ollama.com/api`) | -| `BUB_FEISHU_APP_ID` | Required for Feishu channel (optional extra: `bubseek[feishu]`) | +| `BUB_FEISHU_APP_ID` | Required for Feishu channel | | `BUB_FEISHU_APP_SECRET` | Required for Feishu channel | -| `BUB_DINGTALK_CLIENT_ID` | AppKey for DingTalk channel (optional extra: `bubseek[dingtalk]`) | +| `BUB_DINGTALK_CLIENT_ID` | AppKey for DingTalk channel | | `BUB_DINGTALK_CLIENT_SECRET` | AppSecret for DingTalk channel | | `BUB_DINGTALK_ALLOW_USERS` | Comma-separated staff_ids, or `*` for all | -| WeChat token file | After `bub login wechat`, credentials live under `~/.bub/wechat_token.json` (optional extra: `bubseek[wechat]`); see [bub-wechat](https://github.com/bubbuild/bub-contrib/tree/main/packages/bub-wechat) | -| `BUB_DISCORD_TOKEN` | Discord bot token (optional extra: `bubseek[discord]`); see [bub-discord](https://github.com/bubbuild/bub-contrib/tree/main/packages/bub-discord) | +| WeChat token file | After `bub login wechat`, credentials live under `~/.bub/wechat_token.json`; see [bub-wechat](https://github.com/bubbuild/bub-contrib/tree/main/packages/bub-wechat) | +| `BUB_DISCORD_TOKEN` | Discord bot token; see [bub-discord](https://github.com/bubbuild/bub-contrib/tree/main/packages/bub-discord) | | `BUB_DISCORD_ALLOW_USERS` | Optional comma-separated allowlist (user id / username / global name) | | `BUB_DISCORD_ALLOW_CHANNELS` | Optional comma-separated channel id allowlist | | `BUB_MARIMO_HOST` | Marimo channel bind host (default: `127.0.0.1`) | @@ -82,7 +85,7 @@ BUB_API_BASE=https://openrouter.ai/api/v1 | `BUB_MARIMO_WORKSPACE` | Workspace for insights (default: `BUB_WORKSPACE_PATH` or `.`) | | `BUB_TAPESTORE_SQLALCHEMY_URL` | SQLAlchemy tape store URL (bundled) | -When `BUB_TAPESTORE_SQLALCHEMY_URL` is unset, bubseek builds a SeekDB/OceanBase URL from the `OCEANBASE_*` variables. Set either the full `mysql+oceanbase://...` URL or the `OCEANBASE_*` fields before running. +Set `BUB_TAPESTORE_SQLALCHEMY_URL` to the full `mysql+oceanbase://...` URL before running any tapestore-backed features. ## Builtin skills @@ -93,14 +96,14 @@ bubseek also vendors skills at build time via `pdm-build-skills`; these are merg - `friendly-python` and `piglet` from [PsiACE/skills](https://github.com/PsiACE/skills) - `plugin-creator` from [bub-contrib/.agents/skills/plugin-creator](https://github.com/bubbuild/bub-contrib/tree/main/.agents/skills/plugin-creator) -The optional `bubseek[marimo]` extra provides: +The bundled marimo support provides: - **MarimoChannel** — inbound WebSocket for gateway; chat dashboard at `http://0.0.0.0:2718/` - **marimo skill** — output data insights as marimo `.py` notebooks; index of charts in `{workspace}/insights/` - References [marimo-team/skills](https://github.com/marimo-team/skills) marimo-notebook conventions The dashboard and index are generated into `{workspace}/insights/` at runtime from one canonical template source. They should not be hand-edited inside the repository. -Run `bubseek gateway --enable-channel marimo` to enable the marimo dashboard. +Run `bub gateway --enable-channel marimo` to enable the marimo dashboard. ## Advanced: downstream skill packaging diff --git a/docs/getting-started.md b/docs/getting-started.md index bda3762..0549040 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,6 +1,6 @@ # Getting started -This guide is for the normal user flow: install bubseek, run Bub through the wrapper, and add contrib with standard Python dependencies. +This guide is for the normal user flow: install bubseek, run `bub`, and add contrib with standard Python dependencies. ## Prerequisites @@ -22,37 +22,27 @@ This installs `bubseek` together with `bub==0.3.0a1`. ## Run Bub -Use `bubseek` exactly as you would use `bub`: +Use the bundled `bub` command directly: ```bash -uv run bubseek --help -uv run bubseek chat -uv run bubseek run ",help" +uv run bub --help +uv run bub chat +uv run bub run ",help" ``` -## Use `.env` - -If `.env` contains runtime credentials, bubseek forwards them to the Bub subprocess as-is: - -```dotenv -BUB_MODEL=openrouter:qwen/qwen3-coder-next -BUB_API_KEY=sk-or-v1-... -BUB_API_BASE=https://openrouter.ai/api/v1 -``` - -Configure SeekDB or OceanBase before running `bubseek`, for example with `BUB_TAPESTORE_SQLALCHEMY_URL=mysql+oceanbase://...` or the `OCEANBASE_*` variables. +Configure SeekDB or OceanBase before running `bubseek`, for example with `BUB_TAPESTORE_SQLALCHEMY_URL=mysql+oceanbase://...`. ## Add contrib -Contrib packages are standard Python packages. Add them with normal dependency management. bubseek ships `bub-web-search`, `bub-tapestore-sqlalchemy`, and `bubseek-schedule` by default, and resolves channel extras from GitHub-hosted `bub-contrib` packages. +Contrib packages are standard Python packages. Add them with normal dependency management. bubseek ships `bub-web-search`, `bub-tapestore-sqlalchemy`, `bubseek-schedule`, Feishu, DingTalk, WeChat, Discord, and Marimo support by default. -**Optional extras:** +**Bundled channels and tools:** -- **Feishu channel**: `uv sync --extra feishu` or `pip install bubseek[feishu]` -- **DingTalk channel**: `uv sync --extra dingtalk` or `pip install bubseek[dingtalk]` -- **WeChat channel**: `uv sync --extra wechat` or `pip install bubseek[wechat]` ([bub-wechat](https://github.com/bubbuild/bub-contrib/tree/main/packages/bub-wechat)); then `uv run bubseek login wechat` (or `bub login wechat`), then `uv run bubseek gateway --enable-channel wechat` -- **Discord channel**: `uv sync --extra discord` or `pip install bubseek[discord]` ([bub-discord](https://github.com/bubbuild/bub-contrib/tree/main/packages/bub-discord)); set `BUB_DISCORD_TOKEN` (see package README), then `uv run bubseek gateway --enable-channel discord` -- **Marimo channel** (notebook skills): `uv sync --extra marimo` or `pip install bubseek[marimo]` +- **Feishu channel**: set `BUB_FEISHU_APP_ID` / `BUB_FEISHU_APP_SECRET`, then enable it in gateway if needed. +- **DingTalk channel**: set `BUB_DINGTALK_CLIENT_ID` / `BUB_DINGTALK_CLIENT_SECRET`, then enable it in gateway if needed. +- **WeChat channel**: run `uv run bub login wechat`, then `uv run bub gateway --enable-channel wechat`. +- **Discord channel**: set `BUB_DISCORD_TOKEN`, then `uv run bub gateway --enable-channel discord`. +- **Marimo channel**: run `uv run bub gateway --enable-channel marimo`. **Add other contrib from Git:** diff --git a/docs/index.md b/docs/index.md index a5bc557..c6d317d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,7 +5,7 @@ bubseek is a Bub distribution aimed at users who want a practical default packag The main user path is simple: 1. Install `bubseek` -2. Run Bub through `bubseek` +2. Run `bub` 3. Add contrib through normal Python dependencies 4. Keep using Bub as usual diff --git a/pyproject.toml b/pyproject.toml index bdf3b13..6704143 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,29 +17,26 @@ classifiers = [ ] dependencies = [ "bub", + # pyobvector imports Expression from sqlglot; sqlglot 30+ removed that export. + "sqlglot>=26.0.0,<30.0.0", "republic>=0.5.4", "typer>=0.12.0", "pydantic-settings>=2.0.0", "python-dotenv>=1.0.0", + "bub-feishu", + "bub-dingtalk", + "bub-wechat", + "bub-discord", "bub-web-search", "bub-tapestore-sqlalchemy", "pyobvector>=0.2.22", "bubseek-schedule", + "bubseek-marimo", ] -[project.optional-dependencies] -feishu = ["bub-feishu"] -dingtalk = ["bub-dingtalk"] -wechat = ["bub-wechat"] -discord = ["bub-discord"] -marimo = ["bubseek-marimo"] - [project.urls] Repository = "https://github.com/ob-labs/bubseek" -[project.scripts] -bubseek = "bubseek.__main__:main" - [project.entry-points."bub"] oceanbase-dialect = "bubseek.oceanbase:register" @@ -51,13 +48,7 @@ dev = [ "ty>=0.0.14", - "bub-dingtalk", - "bub-wechat", - "bub-discord", - "bubseek-marimo", - "pandas", - "python-dotenv", "ruff>=0.14.14", "mkdocs>=1.6.1", @@ -85,7 +76,7 @@ skills = [ index-url = "https://pypi.org/simple" [tool.uv.sources] -# bub-contrib: branch=main so optional packages (e.g. bub-wechat, bub-discord) exist at resolve time; uv.lock pins the exact commit. +# bub-contrib: branch=main so bundled runtime packages (e.g. bub-wechat, bub-discord) exist at resolve time; uv.lock pins the exact commit. bub = { git = "https://github.com/bubbuild/bub.git" } bub-dingtalk = { git = "https://github.com/bubbuild/bub-contrib.git", branch = "main", subdirectory = "packages/bub-dingtalk" } bub-feishu = { git = "https://github.com/bubbuild/bub-contrib.git", branch = "main", subdirectory = "packages/bub-feishu" } diff --git a/scripts/create-bub-db.py b/scripts/create-bub-db.py index e4adbef..8a89cd7 100644 --- a/scripts/create-bub-db.py +++ b/scripts/create-bub-db.py @@ -4,6 +4,6 @@ from __future__ import annotations if __name__ == "__main__": - from bubseek.cli import ensure_database + from bubseek.database import ensure_database ensure_database() diff --git a/scripts/query_apscheduler_jobs.py b/scripts/query_apscheduler_jobs.py index 8ced579..66a0465 100644 --- a/scripts/query_apscheduler_jobs.py +++ b/scripts/query_apscheduler_jobs.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Query `apscheduler_jobs` in the same DB the gateway uses (tapestore / OCEANBASE_*). +"""Query `apscheduler_jobs` in the same DB the gateway uses. Use when Marimo schedule kanban shows no rows but the assistant reported a job id. diff --git a/src/bubseek/__init__.py b/src/bubseek/__init__.py index a180479..e6b76f3 100644 --- a/src/bubseek/__init__.py +++ b/src/bubseek/__init__.py @@ -1,5 +1,3 @@ """bubseek package.""" -from bubseek.bootstrap import main - -__all__ = ["main"] +__all__: list[str] = [] diff --git a/src/bubseek/__main__.py b/src/bubseek/__main__.py deleted file mode 100644 index 5d748d5..0000000 --- a/src/bubseek/__main__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Console script entry point for Bubseek.""" - -from __future__ import annotations - -from bubseek.bootstrap import main - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/src/bubseek/bootstrap.py b/src/bubseek/bootstrap.py deleted file mode 100644 index 00532fd..0000000 --- a/src/bubseek/bootstrap.py +++ /dev/null @@ -1,178 +0,0 @@ -"""Single entry bootstrap for forwarding bubseek invocations to Bub.""" - -from __future__ import annotations - -import errno -import os -import shutil -import sys -from dataclasses import dataclass -from pathlib import Path - -import typer - -from bubseek.config import BubSeekSettings, env_with_workspace_dotenv - -CREATE_DB_HINT = """ -Please create the database manually, for example: - mysql -h{host} -P{port} -u{user} -p -e "CREATE DATABASE `{database}` DEFAULT CHARACTER SET utf8mb4" - -Or run: uv run python scripts/create-bub-db.py -""" - - -def database_exists(host: str, port: int, user: str, password: str, database: str) -> bool: - """Return whether the configured OceanBase or SeekDB database already exists.""" - import pymysql - - try: - conn = pymysql.connect( - host=host, - port=port, - user=user, - password=password, - database=database, - charset="utf8mb4", - ) - conn.close() - except pymysql.err.OperationalError as exc: - if exc.args[0] == 1049: - return False - raise - else: - return True - - -def create_database(host: str, port: int, user: str, password: str, database: str) -> bool: - """Create the configured OceanBase or SeekDB database when credentials permit.""" - import pymysql - - try: - conn = pymysql.connect( - host=host, - port=port, - user=user, - password=password, - charset="utf8mb4", - ) - with conn.cursor() as cursor: - cursor.execute(f"CREATE DATABASE IF NOT EXISTS `{database}` DEFAULT CHARACTER SET utf8mb4") - conn.close() - except Exception: - return False - else: - return True - - -def _resolve_workspace(args: list[str], default_workspace: Path) -> Path: - for index, arg in enumerate(args): - if arg in {"--workspace", "-w"} and index + 1 < len(args): - return Path(args[index + 1]).expanduser().resolve() - if arg.startswith("--workspace="): - return Path(arg.split("=", 1)[1]).expanduser().resolve() - return default_workspace.resolve() - - -def _should_ensure_database(args: list[str]) -> bool: - """Skip DB preflight for pure help/version flows.""" - if not args: - return False - return not any(arg in {"--help", "-h", "help", "--version"} for arg in args) - - -def _resolve_bub_command() -> list[str] | None: - executable = shutil.which("bub") - if executable is not None: - return [executable] - - candidates = [ - Path(sys.argv[0]).with_name("bub"), - Path(sys.executable).with_name("bub"), - Path(sys.prefix) / "bin" / "bub", - Path(sys.base_prefix) / "bin" / "bub", - ] - for candidate in candidates: - if candidate.is_file(): - return [str(candidate)] - - return [sys.executable, "-m", "bub.builtin.cli"] - - -@dataclass(slots=True) -class BubSeekBootstrap: - """Bootstrap runtime state for a single bubseek invocation.""" - - workspace: Path - settings: BubSeekSettings - - @classmethod - def from_workspace(cls, workspace: Path | None = None) -> BubSeekBootstrap: - resolved_workspace = (workspace or Path.cwd()).resolve() - return cls( - workspace=resolved_workspace, - settings=BubSeekSettings.from_workspace(resolved_workspace), - ) - - def ensure_database(self) -> None: - """Pre-flight database creation for MySQL-compatible backends only.""" - params = self.settings.db.mysql_connection_params() - if params is None: - return - - host, port, user, password, database = params - try: - if database_exists(host, port, user, password, database): - return - except Exception as exc: - typer.echo(f"Cannot connect to {host}:{port}: {exc}", err=True) - typer.echo("Ensure OceanBase/SeekDB is running.", err=True) - raise typer.Exit(1) from exc - - hint = CREATE_DB_HINT.format(host=host, port=port, user=user, database=database).strip() - if sys.stdin.isatty() and not typer.confirm( - f"Database {database!r} does not exist. Create it?", - default=False, - ): - typer.echo(hint, err=True) - raise typer.Exit(1) - - if create_database(host, port, user, password, database): - typer.echo(f"Database {database!r} created at {host}:{port}", err=True) - return - - typer.echo(f"Cannot create database {database!r}.", err=True) - typer.echo(hint, err=True) - raise typer.Exit(1) - - def forwarded_environment(self, args: list[str]) -> dict[str, str]: - """Merge workspace .env and defaults into the Bub subprocess environment.""" - env = env_with_workspace_dotenv(self.workspace) - settings = BubSeekSettings.from_workspace(self.workspace) - env.setdefault("BUB_TAPESTORE_SQLALCHEMY_URL", settings.db.resolved_tapestore_url) - env.setdefault("BUB_WORKSPACE_PATH", str(_resolve_workspace(args, self.workspace))) - return env - - def run(self, args: list[str]) -> None: - """Replace the current process with Bub after preparing runtime defaults.""" - if _should_ensure_database(args): - self.ensure_database() - - command_prefix = _resolve_bub_command() - if command_prefix is None: - raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), "bub") - - env = self.forwarded_environment(args) - executable = command_prefix[0] - command = [*command_prefix, *(args or ["--help"])] - try: - os.execve(executable, command, env) # noqa: S606 - except OSError as exc: - sys.exit(exc.errno if exc.errno else 1) - - -def main(argv: list[str] | None = None) -> int: - """Entry point for the `bubseek` console script.""" - args = list(sys.argv[1:] if argv is None else argv) - workspace = _resolve_workspace(args, Path.cwd()) - BubSeekBootstrap.from_workspace(workspace).run(args) - return 0 diff --git a/src/bubseek/cli.py b/src/bubseek/cli.py deleted file mode 100644 index aa30d82..0000000 --- a/src/bubseek/cli.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Compatibility wrappers for bubseek bootstrap helpers.""" - -from __future__ import annotations - -from bubseek.bootstrap import ( - BubSeekBootstrap, - create_database, - database_exists, -) - -__all__ = [ - "BubSeekBootstrap", - "create_database", - "database_exists", - "ensure_database", - "forward_environment", -] - - -def ensure_database() -> None: - """Backward-compatible database bootstrap wrapper.""" - BubSeekBootstrap.from_workspace().ensure_database() - - -def forward_environment(args: list[str] | None = None) -> dict[str, str]: - """Backward-compatible environment forwarding wrapper.""" - return BubSeekBootstrap.from_workspace().forwarded_environment(args or []) diff --git a/src/bubseek/config.py b/src/bubseek/config.py index c2402bb..2f00bc0 100644 --- a/src/bubseek/config.py +++ b/src/bubseek/config.py @@ -8,7 +8,6 @@ from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict -from sqlalchemy import URL _SETTINGS_CONFIG = SettingsConfigDict( env_file=".env", @@ -59,27 +58,11 @@ class DatabaseSettings(BaseSettings): bub_home: Path = Field(default=Path.home() / ".bub", validation_alias="BUB_HOME") tapestore_sqlalchemy_url: str = Field(default="", validation_alias="BUB_TAPESTORE_SQLALCHEMY_URL") - oceanbase_host: str = Field(default="127.0.0.1", validation_alias="OCEANBASE_HOST") - oceanbase_port: int = Field(default=2881, validation_alias="OCEANBASE_PORT") - oceanbase_user: str = Field(default="root", validation_alias="OCEANBASE_USER") - oceanbase_password: str = Field(default="", validation_alias="OCEANBASE_PASSWORD") - oceanbase_database: str = Field(default="bub", validation_alias="OCEANBASE_DATABASE") @property def resolved_tapestore_url(self) -> str: - """Return the explicit tape store URL or build one from OCEANBASE_* settings.""" - if self.tapestore_sqlalchemy_url.strip(): - return self.tapestore_sqlalchemy_url.strip() - return str( - URL.create( - "mysql+oceanbase", - username=self.oceanbase_user, - password=self.oceanbase_password, - host=self.oceanbase_host, - port=self.oceanbase_port, - database=self.oceanbase_database, - ).render_as_string(hide_password=False) - ) + """Return the explicit tape store URL.""" + return self.tapestore_sqlalchemy_url.strip() @property def backend_name(self) -> str: @@ -91,25 +74,17 @@ def mysql_connection_params(self) -> tuple[str, int, str, str, str] | None: """Return connection params when using a MySQL-compatible backend.""" if self.backend_name != "mysql": return None - host = self.oceanbase_host - port = self.oceanbase_port - user = self.oceanbase_user - password = self.oceanbase_password - database = self.oceanbase_database try: parsed = urlparse(self.resolved_tapestore_url) - if parsed.hostname: - host = parsed.hostname - if parsed.port: - port = parsed.port - if parsed.username: - user = parsed.username - if parsed.password is not None: - password = parsed.password - if parsed.path and parsed.path.strip("/"): - database = parsed.path.strip("/") - except Exception: # noqa: S110 - pass + host = parsed.hostname or "" + port = parsed.port or 3306 + user = parsed.username or "" + password = parsed.password or "" + database = parsed.path.strip("/") + except Exception: + return None + if not host or not database: + return None return host, port, user, password, database @@ -147,7 +122,7 @@ def resolve_tapestore_url( - If workspace is given: use workspace/.env (BubSeekSettings). - Else if BUB_WORKSPACE_PATH is set: use that workspace. - Else walk discover_from (or cwd) and parents for first .env, use that directory as workspace. - - Else use default (BubSeekSettings.from_workspace(None) → env or OCEANBASE_*). + - Else use process environment only. """ if workspace is not None: return BubSeekSettings.from_workspace(workspace).db.resolved_tapestore_url diff --git a/src/bubseek/database.py b/src/bubseek/database.py new file mode 100644 index 0000000..cb725c9 --- /dev/null +++ b/src/bubseek/database.py @@ -0,0 +1,93 @@ +"""Database bootstrap helpers for SeekDB/OceanBase-backed runtimes.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import typer + +from bubseek.config import BubSeekSettings + +CREATE_DB_HINT = """ +Please create the database manually, for example: + mysql -h{host} -P{port} -u{user} -p -e "CREATE DATABASE `{database}` DEFAULT CHARACTER SET utf8mb4" + +Or run: uv run python scripts/create-bub-db.py +""" + + +def database_exists(host: str, port: int, user: str, password: str, database: str) -> bool: + """Return whether the configured OceanBase or SeekDB database already exists.""" + import pymysql + + try: + conn = pymysql.connect( + host=host, + port=port, + user=user, + password=password, + database=database, + charset="utf8mb4", + ) + conn.close() + except pymysql.err.OperationalError as exc: + if exc.args[0] == 1049: + return False + raise + else: + return True + + +def create_database(host: str, port: int, user: str, password: str, database: str) -> bool: + """Create the configured OceanBase or SeekDB database when credentials permit.""" + import pymysql + + try: + conn = pymysql.connect( + host=host, + port=port, + user=user, + password=password, + charset="utf8mb4", + ) + with conn.cursor() as cursor: + cursor.execute(f"CREATE DATABASE IF NOT EXISTS `{database}` DEFAULT CHARACTER SET utf8mb4") + conn.close() + except Exception: + return False + else: + return True + + +def ensure_database(workspace: Path | None = None) -> None: + """Pre-flight database creation for MySQL-compatible backends only.""" + settings = BubSeekSettings.from_workspace((workspace or Path.cwd()).resolve()) + params = settings.db.mysql_connection_params() + if params is None: + return + + host, port, user, password, database = params + try: + if database_exists(host, port, user, password, database): + return + except Exception as exc: + typer.echo(f"Cannot connect to {host}:{port}: {exc}", err=True) + typer.echo("Ensure OceanBase/SeekDB is running.", err=True) + raise typer.Exit(1) from exc + + hint = CREATE_DB_HINT.format(host=host, port=port, user=user, database=database).strip() + if sys.stdin.isatty() and not typer.confirm( + f"Database {database!r} does not exist. Create it?", + default=False, + ): + typer.echo(hint, err=True) + raise typer.Exit(1) + + if create_database(host, port, user, password, database): + typer.echo(f"Database {database!r} created at {host}:{port}", err=True) + return + + typer.echo(f"Cannot create database {database!r}.", err=True) + typer.echo(hint, err=True) + raise typer.Exit(1) diff --git a/src/bubseek/oceanbase.py b/src/bubseek/oceanbase.py index afd2490..5647e04 100644 --- a/src/bubseek/oceanbase.py +++ b/src/bubseek/oceanbase.py @@ -28,6 +28,9 @@ class OceanBaseDialect(_OceanBaseDialect): that to avoid masking the original error. """ + # SQLAlchemy only reads this on the concrete dialect class (__dict__), not via MRO. + supports_statement_cache = True + def do_release_savepoint(self, connection, name: str) -> None: try: super().do_release_savepoint(connection, name) @@ -49,9 +52,8 @@ def do_rollback_to_savepoint(self, connection, name: str) -> None: def _patch_tape_store_validate_schema() -> None: """Tolerate duplicate index (MySQL 1061) in bub_tapestore_sqlalchemy. - OceanBase/SeekDB may already have indexes (e.g. idx_tape_entries_anchor_name_key). - When the store calls index.create(..., checkfirst=True), some drivers still raise - 1061. We catch and ignore so startup/shutdown does not fail. + SeekDB/OceanBase introspection may not match SQLAlchemy's checkfirst, so + CREATE INDEX is attempted even when the index already exists on the table. """ try: from bub_tapestore_sqlalchemy import store as _store @@ -74,7 +76,6 @@ def _validate_schema_tolerant(self: _Store) -> None: _store.SQLAlchemyTapeStore._validate_schema = _validate_schema_tolerant # type: ignore[method-assign] -# Run patch at import so it is in place before any plugin creates the store. _patch_tape_store_validate_schema() diff --git a/tests/test_bubseek.py b/tests/test_bubseek.py index c64bda3..63ace71 100644 --- a/tests/test_bubseek.py +++ b/tests/test_bubseek.py @@ -6,7 +6,8 @@ from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path -from types import ModuleType +from types import ModuleType, SimpleNamespace +from typing import Any import pytest from bub.skills import _read_skill @@ -27,62 +28,34 @@ def imported_bubseek_modules(*module_names: str) -> Iterator[list[ModuleType]]: sys.modules.pop(module_name, None) -def test_pyproject_pins_bub_and_bundled_plugins() -> None: - data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) - - deps = data["project"]["dependencies"] - assert any("bub" in d for d in deps) - assert any("bub-web-search" in d for d in deps) - optional = data["project"].get("optional-dependencies", {}) - assert "feishu" in optional - assert "bub-feishu" in optional["feishu"] - assert "dingtalk" in optional - assert "bub-dingtalk" in optional["dingtalk"] - assert "wechat" in optional - assert "bub-wechat" in optional["wechat"] - assert "discord" in optional - assert "bub-discord" in optional["discord"] - assert any("bub-tapestore-sqlalchemy" in d for d in deps) - - sources = data.get("tool", {}).get("uv", {}).get("sources", {}) - assert "bub" in sources - assert sources["bub"].get("git") == "https://github.com/bubbuild/bub.git" - assert "bub-dingtalk" in sources - assert sources["bub-dingtalk"].get("git") == "https://github.com/bubbuild/bub-contrib.git" - assert sources["bub-dingtalk"].get("subdirectory") == "packages/bub-dingtalk" - assert "bub-feishu" in sources - assert sources["bub-feishu"].get("git") == "https://github.com/bubbuild/bub-contrib.git" - assert "bub-wechat" in sources - assert sources["bub-wechat"].get("git") == "https://github.com/bubbuild/bub-contrib.git" - assert sources["bub-wechat"].get("subdirectory") == "packages/bub-wechat" - assert "bub-discord" in sources - assert sources["bub-discord"].get("git") == "https://github.com/bubbuild/bub-contrib.git" - assert sources["bub-discord"].get("subdirectory") == "packages/bub-discord" - requires = data["build-system"]["requires"] - assert "pdm-backend" in requires - assert any("pdm-build-skills" in r for r in requires) - - -def test_pyproject_includes_builtin_skills_in_wheel() -> None: - data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) - - assert data["tool"]["pdm"]["build"]["includes"] == [ +def _load_pyproject() -> dict[str, Any]: + return tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + + +def _settings_with_db_params(params: tuple[str, int, str, str, str] | None) -> SimpleNamespace: + db = SimpleNamespace(mysql_connection_params=lambda: params) + return SimpleNamespace(db=db) + + +def test_distribution_metadata_exposes_bub_plugin_without_console_script() -> None: + data = _load_pyproject() + + project = data["project"] + assert "scripts" not in project + assert project["entry-points"]["bub"] == { + "oceanbase-dialect": "bubseek.oceanbase:register", + } + + +def test_pyproject_includes_package_and_builtin_skills_in_wheel() -> None: + data = _load_pyproject() + + build = data["tool"]["pdm"]["build"] + assert build["includes"] == [ "src/bubseek", "src/skills", ] - skills = data["tool"]["pdm"]["build"]["skills"] - assert skills == [ - { - "git": "https://github.com/PsiACE/skills.git", - "subpath": "skills", - "include": ["friendly-python", "piglet"], - }, - { - "git": "https://github.com/bubbuild/bub-contrib.git", - "subpath": ".agents/skills", - "include": ["plugin-creator"], - }, - ] + assert build["skills"] def test_bundled_skills_have_valid_frontmatter() -> None: @@ -97,138 +70,179 @@ def test_bundled_skills_have_valid_frontmatter() -> None: assert "github-repo-cards" in skill_names -def test_main_forwards_explicit_args(monkeypatch) -> None: - with imported_bubseek_modules("bubseek.__main__", "bubseek.bootstrap") as [main_mod, bootstrap_mod]: - observed_command: list[str] | None = None - observed_env: dict[str, str] | None = None +def test_resolve_tapestore_url_requires_explicit_url(monkeypatch, tmp_path: Path) -> None: + with imported_bubseek_modules("bubseek.config") as [config_mod]: + monkeypatch.setenv("BUB_HOME", str(tmp_path / "runtime-home")) + monkeypatch.delenv("BUB_WORKSPACE_PATH", raising=False) + monkeypatch.setenv("BUB_TAPESTORE_SQLALCHEMY_URL", "") - monkeypatch.setattr(bootstrap_mod.BubSeekBootstrap, "ensure_database", lambda self: None) - monkeypatch.setattr(bootstrap_mod.shutil, "which", lambda _name: "/usr/bin/bub") + settings = config_mod.DatabaseSettings() - def _capture_execve(path: str, argv: list[str], env: dict[str, str]) -> None: - nonlocal observed_command, observed_env - observed_command = argv - observed_env = env - raise SystemExit(0) + assert settings.resolved_tapestore_url == "" + assert settings.backend_name == "" + assert settings.mysql_connection_params() is None - monkeypatch.setattr(bootstrap_mod.os, "execve", _capture_execve) - with pytest.raises(SystemExit) as exc_info: - main_mod.main(["chat", "--help"]) - assert exc_info.value.code == 0 - assert observed_command == ["/usr/bin/bub", "chat", "--help"] - assert observed_env is not None +def test_database_settings_extract_mysql_params(monkeypatch) -> None: + with imported_bubseek_modules("bubseek.config") as [config_mod]: + monkeypatch.setenv( + "BUB_TAPESTORE_SQLALCHEMY_URL", + "mysql+oceanbase://seek:secret@seekdb.example:2881/analytics", + ) + settings = config_mod.DatabaseSettings() -def test_main_defaults_to_help(monkeypatch) -> None: - with imported_bubseek_modules("bubseek.__main__", "bubseek.bootstrap") as [main_mod, bootstrap_mod]: - observed_command: list[str] | None = None + assert settings.backend_name == "mysql" + assert settings.mysql_connection_params() == ( + "seekdb.example", + 2881, + "seek", + "secret", + "analytics", + ) - monkeypatch.setattr(bootstrap_mod.BubSeekBootstrap, "ensure_database", lambda self: None) - monkeypatch.setattr(bootstrap_mod.shutil, "which", lambda _name: "/usr/bin/bub") - def _capture_execve(path: str, argv: list[str], env: dict[str, str]) -> None: - nonlocal observed_command - observed_command = argv - raise SystemExit(0) +def test_resolve_tapestore_url_reads_workspace_env_file(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + (workspace / ".env").write_text( + "BUB_TAPESTORE_SQLALCHEMY_URL=mysql+oceanbase://workspace:secret@seekdb.example:2881/workspace_db\n", + encoding="utf-8", + ) - monkeypatch.setattr(bootstrap_mod.os, "execve", _capture_execve) - with pytest.raises(SystemExit) as exc_info: - main_mod.main([]) - assert exc_info.value.code == 0 + with imported_bubseek_modules("bubseek.config") as [config_mod]: + url = config_mod.resolve_tapestore_url(workspace=workspace) - assert observed_command == ["/usr/bin/bub", "--help"] + assert url == "mysql+oceanbase://workspace:secret@seekdb.example:2881/workspace_db" -def test_wrapper_forwards_dotenv_values(monkeypatch, tmp_path: Path) -> None: - env_file = tmp_path / ".env" - env_file.write_text( - "\n".join([ - "BUB_API_KEY=demo-key", - "BUB_API_BASE=https://openrouter.ai/api/v1", - ]), +def test_resolve_tapestore_url_prefers_bub_workspace_path(monkeypatch, tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + (workspace / ".env").write_text( + "BUB_TAPESTORE_SQLALCHEMY_URL=mysql+oceanbase://workspace:secret@seekdb.example:2881/workspace_db\n", encoding="utf-8", ) - with imported_bubseek_modules("bubseek.__main__", "bubseek.bootstrap") as [main_mod, bootstrap_mod]: - observed_command: list[str] | None = None - observed_env: dict[str, str] | None = None + other_root = tmp_path / "other" + nested = other_root / "nested" + nested.mkdir(parents=True) + (other_root / ".env").write_text( + "BUB_TAPESTORE_SQLALCHEMY_URL=mysql+oceanbase://discovered:secret@seekdb.example:2881/discovered_db\n", + encoding="utf-8", + ) - monkeypatch.chdir(tmp_path) - monkeypatch.setattr(bootstrap_mod.BubSeekBootstrap, "ensure_database", lambda self: None) - monkeypatch.setattr(bootstrap_mod.shutil, "which", lambda _name: "/usr/bin/bub") + with imported_bubseek_modules("bubseek.config") as [config_mod]: + monkeypatch.setenv("BUB_WORKSPACE_PATH", str(workspace)) + url = config_mod.resolve_tapestore_url(discover_from=nested) - def _capture_execve(path: str, argv: list[str], env: dict[str, str]) -> None: - nonlocal observed_command, observed_env - observed_command = argv - observed_env = env - raise SystemExit(0) + assert url == "mysql+oceanbase://workspace:secret@seekdb.example:2881/workspace_db" - monkeypatch.setattr(bootstrap_mod.os, "execve", _capture_execve) - with pytest.raises(SystemExit) as exc_info: - main_mod.main(["chat"]) - assert exc_info.value.code == 0 - assert observed_command == ["/usr/bin/bub", "chat"] - assert observed_env is not None - assert observed_env["BUB_API_KEY"] == "demo-key" - assert observed_env["BUB_API_BASE"] == "https://openrouter.ai/api/v1" - assert observed_env["BUB_TAPESTORE_SQLALCHEMY_URL"].startswith("mysql+oceanbase://") +def test_resolve_tapestore_url_discovers_parent_env(monkeypatch, tmp_path: Path) -> None: + root = tmp_path / "project" + nested = root / "src" / "pkg" + nested.mkdir(parents=True) + (root / ".env").write_text( + "BUB_TAPESTORE_SQLALCHEMY_URL=mysql+oceanbase://discovered:secret@seekdb.example:2881/discovered_db\n", + encoding="utf-8", + ) + with imported_bubseek_modules("bubseek.config") as [config_mod]: + monkeypatch.delenv("BUB_WORKSPACE_PATH", raising=False) + url = config_mod.resolve_tapestore_url(discover_from=nested) -def test_wrapper_forwards_workspace_to_plugins(monkeypatch, tmp_path: Path) -> None: - with imported_bubseek_modules("bubseek.__main__", "bubseek.bootstrap") as [main_mod, bootstrap_mod]: - observed_env: dict[str, str] | None = None - workspace = tmp_path / "workspace" - workspace.mkdir() + assert url == "mysql+oceanbase://discovered:secret@seekdb.example:2881/discovered_db" - monkeypatch.setattr(bootstrap_mod.BubSeekBootstrap, "ensure_database", lambda self: None) - monkeypatch.setattr(bootstrap_mod.shutil, "which", lambda _name: "/usr/bin/bub") - def _capture_execve(path: str, argv: list[str], env: dict[str, str]) -> None: - nonlocal observed_env - observed_env = env - raise SystemExit(0) +def test_ensure_database_skips_non_mysql_backends(monkeypatch) -> None: + with imported_bubseek_modules("bubseek.database") as [database_mod]: + monkeypatch.setattr( + database_mod.BubSeekSettings, + "from_workspace", + lambda workspace=None: _settings_with_db_params(None), + ) + create_called = False + exists_called = False - monkeypatch.setattr(bootstrap_mod.os, "execve", _capture_execve) - with pytest.raises(SystemExit): - main_mod.main(["--workspace", str(workspace), "gateway", "--enable-channel", "marimo"]) + def _create_database(*args): + nonlocal create_called + create_called = True + return True - assert observed_env is not None - assert observed_env["BUB_WORKSPACE_PATH"] == str(workspace.resolve()) + def _database_exists(*args): + nonlocal exists_called + exists_called = True + return True + monkeypatch.setattr(database_mod, "create_database", _create_database) + monkeypatch.setattr(database_mod, "database_exists", _database_exists) -def test_database_settings_default_to_oceanbase(monkeypatch, tmp_path: Path) -> None: - with imported_bubseek_modules("bubseek.config") as [config_mod]: - monkeypatch.setenv("BUB_HOME", str(tmp_path / "runtime-home")) - monkeypatch.setenv("BUB_TAPESTORE_SQLALCHEMY_URL", "") # override .env so default is used + database_mod.ensure_database() - settings = config_mod.DatabaseSettings() + assert not exists_called + assert not create_called - assert settings.backend_name == "mysql" - assert settings.mysql_connection_params() == ( - "127.0.0.1", - 2881, - "root", - "", - "bub", - ) +def test_ensure_database_returns_when_database_exists(monkeypatch) -> None: + with imported_bubseek_modules("bubseek.database") as [database_mod]: + monkeypatch.setattr( + database_mod.BubSeekSettings, + "from_workspace", + lambda workspace=None: _settings_with_db_params(("seekdb.example", 2881, "seek", "secret", "analytics")), + ) + create_called = False -def test_database_settings_extract_mysql_params(monkeypatch) -> None: - with imported_bubseek_modules("bubseek.config") as [config_mod]: - monkeypatch.setenv( - "BUB_TAPESTORE_SQLALCHEMY_URL", - "mysql+oceanbase://seek:secret@seekdb.example:2881/analytics", + monkeypatch.setattr(database_mod, "database_exists", lambda *args: True) + + def _create_database(*args): + nonlocal create_called + create_called = True + return True + + monkeypatch.setattr(database_mod, "create_database", _create_database) + + database_mod.ensure_database() + + assert not create_called + + +def test_ensure_database_creates_missing_database_without_prompt(monkeypatch) -> None: + with imported_bubseek_modules("bubseek.database") as [database_mod]: + monkeypatch.setattr( + database_mod.BubSeekSettings, + "from_workspace", + lambda workspace=None: _settings_with_db_params(("seekdb.example", 2881, "seek", "secret", "analytics")), ) + monkeypatch.setattr(database_mod, "database_exists", lambda *args: False) + monkeypatch.setattr(database_mod.sys.stdin, "isatty", lambda: False) - settings = config_mod.DatabaseSettings() + created = False - assert settings.backend_name == "mysql" - assert settings.mysql_connection_params() == ( - "seekdb.example", - 2881, - "seek", - "secret", - "analytics", - ) + def _create_database(*args): + nonlocal created + created = True + return True + + monkeypatch.setattr(database_mod, "create_database", _create_database) + + database_mod.ensure_database() + + assert created + + +def test_ensure_database_respects_tty_decline(monkeypatch) -> None: + with imported_bubseek_modules("bubseek.database") as [database_mod]: + monkeypatch.setattr( + database_mod.BubSeekSettings, + "from_workspace", + lambda workspace=None: _settings_with_db_params(("seekdb.example", 2881, "seek", "secret", "analytics")), + ) + monkeypatch.setattr(database_mod, "database_exists", lambda *args: False) + monkeypatch.setattr(database_mod.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(database_mod.typer, "confirm", lambda *args, **kwargs: False) + + with pytest.raises(database_mod.typer.Exit) as exc_info: + database_mod.ensure_database() + + assert exc_info.value.exit_code == 1 diff --git a/uv.lock b/uv.lock index 3c8a593..5821b84 100644 --- a/uv.lock +++ b/uv.lock @@ -393,46 +393,30 @@ version = "0.0.1" source = { editable = "." } dependencies = [ { name = "bub" }, + { name = "bub-dingtalk" }, + { name = "bub-discord" }, + { name = "bub-feishu" }, { name = "bub-tapestore-sqlalchemy" }, { name = "bub-web-search" }, + { name = "bub-wechat" }, + { name = "bubseek-marimo" }, { name = "bubseek-schedule" }, { name = "pydantic-settings" }, { name = "pyobvector" }, { name = "python-dotenv" }, { name = "republic" }, + { name = "sqlglot" }, { name = "typer" }, ] -[package.optional-dependencies] -dingtalk = [ - { name = "bub-dingtalk" }, -] -discord = [ - { name = "bub-discord" }, -] -feishu = [ - { name = "bub-feishu" }, -] -marimo = [ - { name = "bubseek-marimo" }, -] -wechat = [ - { name = "bub-wechat" }, -] - [package.dev-dependencies] dev = [ - { name = "bub-dingtalk" }, - { name = "bub-discord" }, - { name = "bub-wechat" }, - { name = "bubseek-marimo" }, { name = "mkdocs" }, { name = "mkdocs-material" }, { name = "mkdocstrings", extra = ["python"] }, { name = "pandas" }, { name = "pre-commit" }, { name = "pytest" }, - { name = "python-dotenv" }, { name = "ruff" }, { name = "tox-uv" }, { name = "ty" }, @@ -441,35 +425,30 @@ dev = [ [package.metadata] requires-dist = [ { name = "bub", git = "https://github.com/bubbuild/bub.git" }, - { name = "bub-dingtalk", marker = "extra == 'dingtalk'", git = "https://github.com/bubbuild/bub-contrib.git?subdirectory=packages%2Fbub-dingtalk&branch=main" }, - { name = "bub-discord", marker = "extra == 'discord'", git = "https://github.com/bubbuild/bub-contrib.git?subdirectory=packages%2Fbub-discord&branch=main" }, - { name = "bub-feishu", marker = "extra == 'feishu'", git = "https://github.com/bubbuild/bub-contrib.git?subdirectory=packages%2Fbub-feishu&branch=main" }, + { name = "bub-dingtalk", git = "https://github.com/bubbuild/bub-contrib.git?subdirectory=packages%2Fbub-dingtalk&branch=main" }, + { name = "bub-discord", git = "https://github.com/bubbuild/bub-contrib.git?subdirectory=packages%2Fbub-discord&branch=main" }, + { name = "bub-feishu", git = "https://github.com/bubbuild/bub-contrib.git?subdirectory=packages%2Fbub-feishu&branch=main" }, { name = "bub-tapestore-sqlalchemy", git = "https://github.com/bubbuild/bub-contrib.git?subdirectory=packages%2Fbub-tapestore-sqlalchemy&branch=main" }, { name = "bub-web-search", git = "https://github.com/bubbuild/bub-contrib.git?subdirectory=packages%2Fbub-web-search&branch=main" }, - { name = "bub-wechat", marker = "extra == 'wechat'", git = "https://github.com/bubbuild/bub-contrib.git?subdirectory=packages%2Fbub-wechat&branch=main" }, - { name = "bubseek-marimo", marker = "extra == 'marimo'", editable = "contrib/bubseek-marimo" }, + { name = "bub-wechat", git = "https://github.com/bubbuild/bub-contrib.git?subdirectory=packages%2Fbub-wechat&branch=main" }, + { name = "bubseek-marimo", editable = "contrib/bubseek-marimo" }, { name = "bubseek-schedule", editable = "contrib/bubseek-schedule" }, { name = "pydantic-settings", specifier = ">=2.0.0" }, { name = "pyobvector", specifier = ">=0.2.22" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "republic", specifier = ">=0.5.4" }, + { name = "sqlglot", specifier = ">=26.0.0,<30.0.0" }, { name = "typer", specifier = ">=0.12.0" }, ] -provides-extras = ["feishu", "dingtalk", "wechat", "discord", "marimo"] [package.metadata.requires-dev] dev = [ - { name = "bub-dingtalk", git = "https://github.com/bubbuild/bub-contrib.git?subdirectory=packages%2Fbub-dingtalk&branch=main" }, - { name = "bub-discord", git = "https://github.com/bubbuild/bub-contrib.git?subdirectory=packages%2Fbub-discord&branch=main" }, - { name = "bub-wechat", git = "https://github.com/bubbuild/bub-contrib.git?subdirectory=packages%2Fbub-wechat&branch=main" }, - { name = "bubseek-marimo", editable = "contrib/bubseek-marimo" }, { name = "mkdocs", specifier = ">=1.6.1" }, { name = "mkdocs-material", specifier = ">=9.7.1" }, { name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.2" }, { name = "pandas" }, { name = "pre-commit", specifier = ">=4.5.1" }, { name = "pytest", specifier = ">=9.0.2" }, - { name = "python-dotenv" }, { name = "ruff", specifier = ">=0.14.14" }, { name = "tox-uv", specifier = ">=1.29.0" }, { name = "ty", specifier = ">=0.0.14" }, @@ -965,6 +944,7 @@ wheels = [ name = "griffelib" version = "2.0.0" source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/06/eccbd311c9e2b3ca45dbc063b93134c57a1ccc7607c5e545264ad092c4a9/griffelib-2.0.0.tar.gz", hash = "sha256:e504d637a089f5cab9b5daf18f7645970509bf4f53eda8d79ed71cce8bd97934", size = 166312, upload-time = "2026-03-23T21:06:55.954Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" }, ] @@ -2510,11 +2490,11 @@ wheels = [ [[package]] name = "sqlglot" -version = "30.0.3" +version = "29.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5b/7a/3b6a8853fc2fa166f785a8ea4fecde46f70588e35471bc7811373da31a49/sqlglot-30.0.3.tar.gz", hash = "sha256:35ba7514c132b54f87fd1732a65a73615efa9fd83f6e1eed0a315bc9ee3e1027", size = 5802632, upload-time = "2026-03-19T16:51:39.166Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/12/c3f7533fde302fcd59bebcd4c2e46d5bf0eef21f183c67995bbb010fb578/sqlglot-29.0.1.tar.gz", hash = "sha256:0010b4f77fb996c8d25dd4b16f3654e6da163ff1866ceabc70b24e791c203048", size = 5760786, upload-time = "2026-02-23T21:41:20.178Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/13/b57ab75b0f60b5ee8cb8924bc01a5c419ed3221e00f8f11f8c059a707eb7/sqlglot-30.0.3-py3-none-any.whl", hash = "sha256:5489cc98b5666f1fafc21e0304ca286e513e142aa054ee5760806a2139d07a05", size = 651853, upload-time = "2026-03-19T16:51:36.241Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c9/f58c3a17beb650700f9d2eccd410726b6d96df8953663700764ca48636c7/sqlglot-29.0.1-py3-none-any.whl", hash = "sha256:06a473ea6c2b3632ac67bd38e687a6860265bf4156e66b54adeda15d07f00c65", size = 611448, upload-time = "2026-02-23T21:41:18.008Z" }, ] [[package]]