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
56 changes: 54 additions & 2 deletions src/bub/builtin/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@
from __future__ import annotations

import asyncio
import json
import os
import subprocess
import sys
from functools import lru_cache
from importlib import metadata
from pathlib import Path
from urllib.parse import unquote, urlsplit
from urllib.request import url2pathname

import typer

Expand Down Expand Up @@ -156,10 +160,59 @@ def _build_requirement(spec: str) -> str:
return name


def _build_local_requirement_path(url: str, subdirectory: str | None = None) -> str | None:
parsed = urlsplit(url)
if parsed.scheme != "file":
return None

path = parsed.path
if parsed.netloc and parsed.netloc != "localhost":
path = f"//{parsed.netloc}{path}"
local_path = Path(url2pathname(unquote(path)))
if subdirectory:
local_path /= subdirectory
return os.fspath(local_path)


def _build_bub_requirement() -> list[str]:
dist = metadata.distribution("bub")
dist_name = dist.name
direct_url_text = dist.read_text("direct_url.json")
if not direct_url_text:
return [dist_name]

direct_url = json.loads(direct_url_text)
requirement_url = str(direct_url["url"])
subdirectory = direct_url.get("subdirectory")
normalized_subdirectory = subdirectory if isinstance(subdirectory, str) and subdirectory else None

local_path = _build_local_requirement_path(requirement_url, normalized_subdirectory)
if local_path is not None:
dir_info = direct_url.get("dir_info")
editable = isinstance(dir_info, dict) and bool(dir_info.get("editable"))
return ["--editable", local_path] if editable else [local_path]

vcs_info = direct_url.get("vcs_info")
if isinstance(vcs_info, dict):
vcs = vcs_info.get("vcs")
requested_revision = vcs_info.get("requested_revision")
if isinstance(vcs, str) and vcs:
requirement_url = f"{vcs}+{requirement_url}"
if isinstance(requested_revision, str) and requested_revision:
requirement_url = f"{requirement_url}@{requested_revision}"

if normalized_subdirectory:
requirement_url = f"{requirement_url}#subdirectory={normalized_subdirectory}"

return [requirement_url]


def _ensure_project(project: Path) -> None:
if (project / "pyproject.toml").is_file():
return
_uv("init", "--bare", "--name", "bub-project", "--app", cwd=project)
bub_requirement = _build_bub_requirement()
_uv("add", "--active", "--no-sync", *bub_requirement, cwd=project)


def install(
Expand All @@ -183,8 +236,7 @@ def uninstall(
) -> None:
"""Uninstall a plugin from Bub's environment."""
_ensure_project(project)
_uv("remove", "--active", "--no-sync", *packages, cwd=project)
_uv("sync", "--active", "--frozen", "--inexact", cwd=project)
_uv("remove", "--active", *packages, cwd=project)


def update(
Expand Down
3 changes: 1 addition & 2 deletions src/bub/builtin/hook_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,7 @@ def register_cli_commands(self, app: typer.Typer) -> None:
app.command("hooks", hidden=True)(cli.list_hooks)
app.command("gateway")(cli.gateway)
app.command("install")(cli.install)
# TODO: uninstall command can't work properly
# app.command("uninstall")(cli.uninstall)
app.command("uninstall")(cli.uninstall)
app.command("update")(cli.update)

def _read_agents_file(self, state: State) -> str:
Expand Down
79 changes: 79 additions & 0 deletions tests/test_builtin_cli.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from __future__ import annotations

import json
from pathlib import Path

from typer.testing import CliRunner

import bub.builtin.auth as auth
import bub.builtin.cli as cli
from bub.framework import BubFramework


Expand Down Expand Up @@ -69,3 +71,80 @@ def test_login_rejects_unsupported_provider() -> None:

assert result.exit_code == 2
assert "No such command 'anthropic'" in result.stderr


def test_build_bub_requirement_uses_direct_url_json(monkeypatch) -> None:
class FakeDistribution:
version = "0.3.4"
name = "bub"

def read_text(self, filename: str) -> str:
assert filename == "direct_url.json"
return json.dumps({
"url": "https://github.com/bubbuild/bub.git",
"vcs_info": {"vcs": "git", "requested_revision": "main"},
"subdirectory": "python",
})

monkeypatch.setattr(cli.metadata, "distribution", lambda name: FakeDistribution())

assert cli._build_bub_requirement() == ["git+https://github.com/bubbuild/bub.git@main#subdirectory=python"]


def test_build_bub_requirement_falls_back_to_installed_version(monkeypatch) -> None:
class FakeDistribution:
version = "0.3.4"
name = "bub"

def read_text(self, filename: str) -> None:
assert filename == "direct_url.json"
return None

monkeypatch.setattr(cli.metadata, "distribution", lambda name: FakeDistribution())

assert cli._build_bub_requirement() == ["bub"]


def test_build_bub_requirement_uses_local_path_for_file_dist(monkeypatch) -> None:
class FakeDistribution:
name = "bub"

def read_text(self, filename: str) -> str:
assert filename == "direct_url.json"
return json.dumps({"url": "file:///tmp/worktrees/bub"})

monkeypatch.setattr(cli.metadata, "distribution", lambda name: FakeDistribution())

assert cli._build_bub_requirement() == ["/tmp/worktrees/bub"] # noqa: S108


def test_build_bub_requirement_marks_editable_local_dist(monkeypatch) -> None:
class FakeDistribution:
name = "bub"

def read_text(self, filename: str) -> str:
assert filename == "direct_url.json"
return json.dumps({
"url": "file:///tmp/worktrees/bub",
"dir_info": {"editable": True},
})

monkeypatch.setattr(cli.metadata, "distribution", lambda name: FakeDistribution())

assert cli._build_bub_requirement() == ["--editable", "/tmp/worktrees/bub"] # noqa: S108


def test_ensure_project_initializes_project_and_adds_bub_dependency(tmp_path: Path, monkeypatch) -> None:
project = tmp_path / "managed-project"
project.mkdir()
captured: list[tuple[tuple[str, ...], Path]] = []

monkeypatch.setattr(cli, "_build_bub_requirement", lambda: ["--editable", "/tmp/bub"]) # noqa: S108
monkeypatch.setattr(cli, "_uv", lambda *args, cwd: captured.append((args, cwd)))

cli._ensure_project(project)

assert captured == [
(("init", "--bare", "--name", "bub-project", "--app"), project),
(("add", "--active", "--no-sync", "--editable", "/tmp/bub"), project), # noqa: S108
]
Loading
Loading