From 7e7616f5eb5ec5afc3241f4c69e4f630bcafebca Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Thu, 23 Apr 2026 18:32:24 +0800 Subject: [PATCH 1/2] feat: extensible config object Signed-off-by: Frost Ming --- src/bub/__init__.py | 22 +++++++++- src/bub/builtin/agent.py | 4 +- src/bub/builtin/cli.py | 5 +-- src/bub/builtin/hook_impl.py | 18 +++++--- src/bub/builtin/settings.py | 42 ++++++++----------- src/bub/channels/cli/__init__.py | 3 +- src/bub/channels/manager.py | 9 ++-- src/bub/channels/telegram.py | 9 ++-- src/bub/configure.py | 72 ++++++++++++++++++++++++++++++++ src/bub/framework.py | 26 +++++++++--- tests/conftest.py | 36 ++++++++++++++++ tests/test_builtin_agent.py | 4 +- tests/test_builtin_hook_impl.py | 9 ++-- tests/test_channels.py | 53 +++++++++++++++-------- tests/test_framework.py | 63 ++++++++++++++++++++++++---- tests/test_image_message.py | 16 ++++--- tests/test_settings.py | 59 ++++++++++++-------------- 17 files changed, 334 insertions(+), 116 deletions(-) create mode 100644 src/bub/configure.py create mode 100644 tests/conftest.py diff --git a/src/bub/__init__.py b/src/bub/__init__.py index 7f3838de..27305efc 100644 --- a/src/bub/__init__.py +++ b/src/bub/__init__.py @@ -1,14 +1,20 @@ """Bub framework package.""" +from __future__ import annotations + +import os from importlib import import_module from importlib.metadata import PackageNotFoundError from importlib.metadata import version as metadata_version +from pathlib import Path +from typing import TYPE_CHECKING -from bub.framework import BubFramework +from bub.configure import Settings, config, ensure_config +from bub.framework import DEFAULT_HOME, BubFramework from bub.hookspecs import hookimpl from bub.tools import tool -__all__ = ["BubFramework", "hookimpl", "tool"] +__all__ = ["BubFramework", "Settings", "config", "ensure_config", "home", "hookimpl", "tool"] try: __version__ = import_module("bub._version").version @@ -17,3 +23,15 @@ __version__ = metadata_version("bub") except PackageNotFoundError: __version__ = "0.0.0" + + +if TYPE_CHECKING: + home: Path + + +def __getattr__(name: str): + if name == "home": + if "BUB_HOME" in os.environ: + return Path(os.environ["BUB_HOME"]) + return DEFAULT_HOME + raise AttributeError(f"module {__name__} has no attribute {name}") diff --git a/src/bub/builtin/agent.py b/src/bub/builtin/agent.py index 46367b17..cb8173ae 100644 --- a/src/bub/builtin/agent.py +++ b/src/bub/builtin/agent.py @@ -56,12 +56,14 @@ def __init__(self, framework: BubFramework) -> None: @cached_property def tapes(self) -> TapeService: + import bub + tape_store = self.framework.get_tape_store() if tape_store is None: tape_store = InMemoryTapeStore() tape_store = ForkTapeStore(tape_store) llm = _build_llm(self.settings, tape_store, self.framework.build_tape_context()) - return TapeService(llm, self.settings.home / "tapes", tape_store) + return TapeService(llm, bub.home / "tapes", tape_store) @staticmethod def _events_from_iterable(iterable: Iterable) -> AsyncStreamEvents: diff --git a/src/bub/builtin/cli.py b/src/bub/builtin/cli.py index 94af751a..df6c405d 100644 --- a/src/bub/builtin/cli.py +++ b/src/bub/builtin/cli.py @@ -106,10 +106,9 @@ def _find_uv() -> str: @lru_cache(maxsize=1) def _default_project() -> Path: - from .settings import load_settings + import bub - settings = load_settings() - project = settings.home / "bub-project" + project = bub.home / "bub-project" project.mkdir(exist_ok=True, parents=True) return project diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py index 591ecab6..6c717d6d 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -48,7 +48,12 @@ def __init__(self, framework: BubFramework) -> None: from bub.builtin import tools # noqa: F401 self.framework = framework - self.agent = Agent(framework) + self._agent: Agent | None = None + + def _get_agent(self) -> Agent: + if self._agent is None: + self._agent = Agent(self.framework) + return self._agent @hookimpl def resolve_session(self, message: ChannelMessage) -> str: @@ -64,7 +69,7 @@ async def load_state(self, message: ChannelMessage, session_id: str) -> State: lifespan = field_of(message, "lifespan") if lifespan is not None: await lifespan.__aenter__() - state = {"session_id": session_id, "_runtime_agent": self.agent} + state = {"session_id": session_id, "_runtime_agent": self._get_agent()} if context := field_of(message, "context_str"): state["context"] = context return state @@ -107,11 +112,11 @@ async def build_prompt(self, message: ChannelMessage, session_id: str, state: St @hookimpl async def run_model(self, prompt: str | list[dict], session_id: str, state: State) -> str: - return await self.agent.run(session_id=session_id, prompt=prompt, state=state) + return await self._get_agent().run(session_id=session_id, prompt=prompt, state=state) @hookimpl async def run_model_stream(self, prompt: str | list[dict], session_id: str, state: State) -> AsyncStreamEvents: - return await self.agent.run_stream(session_id=session_id, prompt=prompt, state=state) + return await self._get_agent().run_stream(session_id=session_id, prompt=prompt, state=state) @hookimpl def register_cli_commands(self, app: typer.Typer) -> None: @@ -148,7 +153,7 @@ def provide_channels(self, message_handler: MessageHandler) -> list[Channel]: return [ TelegramChannel(on_receive=message_handler), - CliChannel(on_receive=message_handler, agent=self.agent), + CliChannel(on_receive=message_handler, agent=self._get_agent()), ] @hookimpl @@ -191,9 +196,10 @@ def render_outbound( @hookimpl def provide_tape_store(self) -> TapeStore: + import bub from bub.builtin.store import FileTapeStore - return FileTapeStore(directory=self.agent.settings.home / "tapes") + return FileTapeStore(directory=bub.home / "tapes") @hookimpl def build_tape_context(self) -> TapeContext: diff --git a/src/bub/builtin/settings.py b/src/bub/builtin/settings.py index e8213fd6..878b4bac 100644 --- a/src/bub/builtin/settings.py +++ b/src/bub/builtin/settings.py @@ -4,16 +4,15 @@ import pathlib import re from collections.abc import Callable -from functools import lru_cache from typing import Any, Literal from pydantic import Field -from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, YamlConfigSettingsSource +from pydantic_settings import SettingsConfigDict + +from bub import Settings, config, ensure_config DEFAULT_MODEL = "openrouter:qwen/qwen3-coder-next" DEFAULT_MAX_TOKENS = 1024 -DEFAULT_HOME = pathlib.Path.home() / ".bub" -DEFAULT_CONFIG_FILE = DEFAULT_HOME / "config.yml" def provider_specific(setting_name: str) -> Callable[[], dict[str, str] | None]: @@ -32,11 +31,11 @@ def default_factory() -> dict[str, str] | None: return default_factory -class AgentSettings(BaseSettings): +@config() +class AgentSettings(Settings): """Configuration settings for the Agent.""" model_config = SettingsConfigDict(env_prefix="BUB_", env_parse_none_str="null", extra="ignore") - home: pathlib.Path = Field(default=DEFAULT_HOME) model: str = DEFAULT_MODEL fallback_models: list[str] | None = None api_key: str | dict[str, str] | None = Field(default_factory=provider_specific("api_key")) @@ -48,25 +47,20 @@ class AgentSettings(BaseSettings): client_args: dict[str, Any] | None = None verbose: int = Field(default=0, description="Verbosity level for logging. Higher means more verbose.", ge=0, le=2) - @classmethod - def settings_customise_sources( - cls, - settings_cls: type[BaseSettings], - init_settings: PydanticBaseSettingsSource, - env_settings: PydanticBaseSettingsSource, - dotenv_settings: PydanticBaseSettingsSource, - file_secret_settings: PydanticBaseSettingsSource, - ) -> tuple[PydanticBaseSettingsSource, ...]: - home = os.getenv("BUB_HOME", str(DEFAULT_HOME)) - return ( - init_settings, - env_settings, - dotenv_settings, - YamlConfigSettingsSource(settings_cls, yaml_file=pathlib.Path(home) / "config.yml"), - file_secret_settings, + @property + def home(self) -> pathlib.Path: + import warnings + + import bub + + warnings.warn( + "Using the 'home' property from AgentSettings is deprecated. Please use 'bub.home' instead.", + DeprecationWarning, + stacklevel=2, ) + return bub.home + -@lru_cache(maxsize=1) def load_settings() -> AgentSettings: - return AgentSettings() + return ensure_config(AgentSettings) diff --git a/src/bub/channels/cli/__init__.py b/src/bub/channels/cli/__init__.py index e551e85e..6959ad37 100644 --- a/src/bub/channels/cli/__init__.py +++ b/src/bub/channels/cli/__init__.py @@ -15,6 +15,7 @@ from rich import get_console from rich.live import Live +import bub from bub.builtin.agent import Agent from bub.builtin.tape import TapeInfo from bub.channels.base import Channel @@ -160,7 +161,7 @@ def _tool_sort_key(tool_name: str) -> tuple[str, str]: section, _, name = tool_name.rpartition(".") return (section, name) - history_file = self._history_file(self._agent.settings.home, workspace) + history_file = self._history_file(bub.home, workspace) history_file.parent.mkdir(parents=True, exist_ok=True) history = FileHistory(str(history_file)) tool_names = sorted((f",{name}" for name in REGISTRY), key=_tool_sort_key) diff --git a/src/bub/channels/manager.py b/src/bub/channels/manager.py index c2abab25..83e4a602 100644 --- a/src/bub/channels/manager.py +++ b/src/bub/channels/manager.py @@ -5,19 +5,22 @@ from loguru import logger from pydantic import Field -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import SettingsConfigDict from republic import StreamEvent +from bub import config from bub.channels.base import Channel from bub.channels.handler import BufferedMessageHandler from bub.channels.message import ChannelMessage +from bub.configure import Settings, ensure_config from bub.envelope import content_of, field_of from bub.framework import BubFramework from bub.types import Envelope, MessageHandler from bub.utils import wait_until_stopped -class ChannelSettings(BaseSettings): +@config() +class ChannelSettings(Settings): model_config = SettingsConfigDict(env_prefix="BUB_", extra="ignore", env_file=".env") enabled_channels: str = Field( @@ -47,7 +50,7 @@ def __init__( ) -> None: self.framework = framework self._channels: dict[str, Channel] = self.framework.get_channels(self.on_receive) - self._settings = ChannelSettings() + self._settings = ensure_config(ChannelSettings) self._stream_output = stream_output if stream_output is not None else self._settings.stream_output if enabled_channels is not None: self._enabled_channels = list(enabled_channels) diff --git a/src/bub/channels/telegram.py b/src/bub/channels/telegram.py index a7bd62cb..e412d142 100644 --- a/src/bub/channels/telegram.py +++ b/src/bub/channels/telegram.py @@ -8,19 +8,22 @@ from loguru import logger from pydantic import Field -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import SettingsConfigDict from telegram import Bot, Message, Update from telegram.ext import Application, CommandHandler, ContextTypes, filters from telegram.ext import MessageHandler as TelegramMessageHandler from telegram.request import HTTPXRequest +from bub import config from bub.channels.base import Channel from bub.channels.message import ChannelMessage, MediaItem, MediaType +from bub.configure import Settings, ensure_config from bub.types import MessageHandler from bub.utils import exclude_none -class TelegramSettings(BaseSettings): +@config(name="telegram") +class TelegramSettings(Settings): model_config = SettingsConfigDict(env_prefix="BUB_TELEGRAM_", extra="ignore", env_file=".env") token: str = Field(default="", description="Telegram bot token.") @@ -148,7 +151,7 @@ class TelegramChannel(Channel): def __init__(self, on_receive: MessageHandler) -> None: self._on_receive = on_receive - self._settings = TelegramSettings() + self._settings = ensure_config(TelegramSettings) self._allow_users = {uid.strip() for uid in (self._settings.allow_users or "").split(",") if uid.strip()} self._allow_chats = {cid.strip() for cid in (self._settings.allow_chats or "").split(",") if cid.strip()} self._parser = TelegramMessageParser(bot_getter=lambda: self._app.bot) diff --git a/src/bub/configure.py b/src/bub/configure.py new file mode 100644 index 00000000..17fe6503 --- /dev/null +++ b/src/bub/configure.py @@ -0,0 +1,72 @@ +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from pydantic_settings import BaseSettings, PydanticBaseSettingsSource + +CONFIG_MAP: dict[str, list[type[BaseSettings]]] = {} +ROOT = "" + +_global_config: dict[str, list[BaseSettings]] | None = None + + +class Settings(BaseSettings): + @classmethod + def settings_customise_sources( + cls, + settings_cls: type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + env_settings: PydanticBaseSettingsSource, + dotenv_settings: PydanticBaseSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> tuple[PydanticBaseSettingsSource, ...]: + del settings_cls # unused + return (env_settings, dotenv_settings, init_settings, file_secret_settings) + + +def config[C: type[BaseSettings]](name: str = ROOT) -> Callable[[C], C]: + """Decorator to register a config class for a plugin.""" + + def decorator(cls: C) -> C: + if name not in CONFIG_MAP: + CONFIG_MAP[name] = [] + CONFIG_MAP[name].append(cls) + return cls + + return decorator + + +def load(config_file: Path) -> dict[str, list[BaseSettings]]: + """Load config from a file.""" + import yaml + + global _global_config + if _global_config is not None: + return _global_config + + this_data: dict[str, list[BaseSettings]] = {} + + config_data: dict[str, Any] = {} + if config_file.exists(): + with config_file.open() as f: + config_data = yaml.safe_load(f) or {} + + for name, config_classes in CONFIG_MAP.items(): + section_data = config_data if name == ROOT else config_data.get(name, {}) + for config_cls in config_classes: + config_instance = config_cls.model_validate(section_data) + this_data.setdefault(name, []).append(config_instance) + + _global_config = this_data + return _global_config + + +def ensure_config[C: BaseSettings](config_cls: type[C]) -> C: + """No-op function to ensure a config class is registered and can be imported.""" + if _global_config is None: + raise RuntimeError("Config not loaded yet") + for config_list in _global_config.values(): + for config in config_list: + if isinstance(config, config_cls): + return config + raise ValueError(f"Config class {config_cls} not found in loaded config") diff --git a/src/bub/framework.py b/src/bub/framework.py index 603eccf2..be5337b8 100644 --- a/src/bub/framework.py +++ b/src/bub/framework.py @@ -14,6 +14,7 @@ from republic.core.errors import ErrorKind from republic.tape import TapeStore +from bub import configure from bub.envelope import content_of, field_of, unpack_batch from bub.hook_runtime import HookRuntime from bub.hookspecs import BUB_HOOK_NAMESPACE, BubHookSpecs @@ -24,6 +25,8 @@ load_dotenv() +DEFAULT_HOME = Path.home() / ".bub" +DEFAULT_CONFIG_FILE = (DEFAULT_HOME / "config.yml").resolve() @dataclass(frozen=True) @@ -35,13 +38,14 @@ class PluginStatus: class BubFramework: """Minimal framework core. Everything grows from hook skills.""" - def __init__(self) -> None: + def __init__(self, config_file: Path = DEFAULT_CONFIG_FILE) -> None: self.workspace = Path.cwd().resolve() self._plugin_manager = pluggy.PluginManager(BUB_HOOK_NAMESPACE) self._plugin_manager.add_hookspecs(BubHookSpecs) self._hook_runtime = HookRuntime(self._plugin_manager) self._plugin_status: dict[str, PluginStatus] = {} self._outbound_router: OutboundChannelRouter | None = None + self._config_file = config_file def _load_builtin_hooks(self) -> None: from bub.builtin.hook_impl import BuiltinImpl @@ -58,18 +62,30 @@ def _load_builtin_hooks(self) -> None: def load_hooks(self) -> None: import importlib.metadata + pending_plugins: list[tuple[str, Any]] = [] + self._load_builtin_hooks() for entry_point in importlib.metadata.entry_points(group="bub"): try: plugin = entry_point.load() - if callable(plugin): # Support entry points that are classes - plugin = plugin(self) - self._plugin_manager.register(plugin, name=entry_point.name) except Exception as exc: logger.warning(f"Failed to load plugin '{entry_point.name}': {exc}") self._plugin_status[entry_point.name] = PluginStatus(is_success=False, detail=str(exc)) else: - self._plugin_status[entry_point.name] = PluginStatus(is_success=True) + pending_plugins.append((entry_point.name, plugin)) + + configure.load(self._config_file) + + for plugin_name, plugin in pending_plugins: + try: + if callable(plugin): # Support entry points that are classes + plugin = plugin(self) + self._plugin_manager.register(plugin, name=plugin_name) + except Exception as exc: + logger.warning(f"Failed to initialize plugin '{plugin_name}': {exc}") + self._plugin_status[plugin_name] = PluginStatus(is_success=False, detail=str(exc)) + else: + self._plugin_status[plugin_name] = PluginStatus(is_success=True) def create_cli_app(self) -> typer.Typer: """Create CLI app by collecting commands from hooks. Can be used for custom CLI entry point.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..b4c98fbe --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from collections.abc import Callable, Generator +from pathlib import Path + +import pytest + +import bub.configure as configure + + +@pytest.fixture(autouse=True) +def reset_loaded_config() -> Generator[None, None, None]: + configure._global_config = None + yield + configure._global_config = None + + +@pytest.fixture +def write_config(tmp_path: Path) -> Callable[[str], Path]: + def _write(content: str = "") -> Path: + config_file = tmp_path / "config.yml" + config_file.write_text(content, encoding="utf-8") + return config_file + + return _write + + +@pytest.fixture +def load_config(write_config: Callable[[str], Path], monkeypatch: pytest.MonkeyPatch) -> Callable[[str], Path]: + def _load(content: str = "") -> Path: + config_file = write_config(content) + monkeypatch.chdir(config_file.parent) + configure.load(config_file) + return config_file + + return _load diff --git a/tests/test_builtin_agent.py b/tests/test_builtin_agent.py index be359b70..4adeb169 100644 --- a/tests/test_builtin_agent.py +++ b/tests/test_builtin_agent.py @@ -26,7 +26,7 @@ def __init__(self, *args: object, **kwargs: object) -> None: monkeypatch.setattr(agent_module, "LLM", FakeLLM) monkeypatch.setattr(openai_codex, "openai_codex_oauth_resolver", lambda: resolver) - settings = AgentSettings( + settings = AgentSettings.model_construct( model="openai:gpt-5-codex", api_key=None, api_base=None, @@ -61,7 +61,7 @@ def _make_agent() -> Agent: with patch.object(Agent, "__init__", lambda self, fw: None): agent = Agent.__new__(Agent) - agent.settings = AgentSettings(model="test:model", api_key="k", api_base="b") + agent.settings = AgentSettings.model_construct(model="test:model", api_key="k", api_base="b") agent.framework = framework return agent diff --git a/tests/test_builtin_hook_impl.py b/tests/test_builtin_hook_impl.py index 2c2397ff..e04472cc 100644 --- a/tests/test_builtin_hook_impl.py +++ b/tests/test_builtin_hook_impl.py @@ -51,7 +51,7 @@ def _build_impl(tmp_path: Path) -> tuple[BubFramework, BuiltinImpl, FakeAgent]: framework = BubFramework() impl = BuiltinImpl(framework) agent = FakeAgent(tmp_path) - impl.agent = agent # type: ignore[assignment] + impl._agent = agent return framework, impl, agent @@ -73,7 +73,7 @@ def test_resolve_session_falls_back_to_channel_and_chat_id(tmp_path: Path) -> No @pytest.mark.asyncio async def test_load_state_and_save_state_manage_lifespan_and_context(tmp_path: Path) -> None: - _, impl, _ = _build_impl(tmp_path) + _, impl, agent = _build_impl(tmp_path) lifespan = RecordingLifespan() message = ChannelMessage( session_id="session", @@ -87,7 +87,7 @@ async def test_load_state_and_save_state_manage_lifespan_and_context(tmp_path: P assert lifespan.entered is True assert state["session_id"] == "resolved-session" - assert state["_runtime_agent"] is impl.agent + assert state["_runtime_agent"] is agent assert state["context"] == message.context_str try: @@ -262,8 +262,9 @@ def test_render_outbound_preserves_message_metadata(tmp_path: Path) -> None: assert outbound.content == "result" -def test_provide_tape_store_uses_agent_home_directory(tmp_path: Path) -> None: +def test_provide_tape_store_uses_bub_home_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: _, impl, _ = _build_impl(tmp_path) + monkeypatch.setenv("BUB_HOME", str(tmp_path)) store = impl.provide_tape_store() diff --git a/tests/test_channels.py b/tests/test_channels.py index 90b73035..41d2c7e8 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -16,6 +16,22 @@ from bub.channels.telegram import BubMessageFilter, TelegramChannel, TelegramMessageParser +def _load_channel_config( + load_config, + *, + enabled_channels: str = "all", + stream_output: bool = False, + telegram_value: str = "", +) -> None: + content = f""" +enabled_channels: {enabled_channels} +stream_output: {str(stream_output).lower()} +telegram: + token: {telegram_value} +""".strip() + load_config(content) + + class FakeChannel: def __init__(self, name: str, *, needs_debounce: bool = False) -> None: self.name = name @@ -103,7 +119,8 @@ async def receive(message: ChannelMessage) -> None: @pytest.mark.asyncio -async def test_channel_manager_dispatch_uses_output_channel_and_preserves_metadata() -> None: +async def test_channel_manager_dispatch_uses_output_channel_and_preserves_metadata(load_config) -> None: + _load_channel_config(load_config, enabled_channels="cli") cli_channel = FakeChannel("cli") manager = ChannelManager(FakeFramework({"cli": cli_channel}), enabled_channels=["cli"]) @@ -127,7 +144,8 @@ async def test_channel_manager_dispatch_uses_output_channel_and_preserves_metada assert outbound.context["source"] == "test" -def test_channel_manager_enabled_channels_excludes_cli_from_all() -> None: +def test_channel_manager_enabled_channels_excludes_cli_from_all(load_config) -> None: + _load_channel_config(load_config) channels = {"cli": FakeChannel("cli"), "telegram": FakeChannel("telegram"), "discord": FakeChannel("discord")} manager = ChannelManager(FakeFramework(channels), enabled_channels=["all"]) @@ -135,7 +153,10 @@ def test_channel_manager_enabled_channels_excludes_cli_from_all() -> None: @pytest.mark.asyncio -async def test_channel_manager_on_receive_uses_buffer_for_debounced_channel(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_channel_manager_on_receive_uses_buffer_for_debounced_channel( + monkeypatch: pytest.MonkeyPatch, load_config +) -> None: + _load_channel_config(load_config, enabled_channels="telegram") telegram = FakeChannel("telegram", needs_debounce=True) manager = ChannelManager(FakeFramework({"telegram": telegram}), enabled_channels=["telegram"]) calls: list[ChannelMessage] = [] @@ -164,7 +185,8 @@ async def __call__(self, message: ChannelMessage) -> None: @pytest.mark.asyncio -async def test_channel_manager_shutdown_cancels_tasks_and_stops_enabled_channels() -> None: +async def test_channel_manager_shutdown_cancels_tasks_and_stops_enabled_channels(load_config) -> None: + _load_channel_config(load_config) telegram = FakeChannel("telegram") cli = FakeChannel("cli") manager = ChannelManager(FakeFramework({"telegram": telegram, "cli": cli}), enabled_channels=["all"]) @@ -184,20 +206,13 @@ async def never_finish() -> None: @pytest.mark.asyncio async def test_channel_manager_listen_and_run_passes_stream_output_setting( - monkeypatch: pytest.MonkeyPatch, + monkeypatch: pytest.MonkeyPatch, load_config ) -> None: + _load_channel_config(load_config, enabled_channels="telegram", stream_output=True) framework = FakeFramework({"telegram": FakeChannel("telegram")}) - class StubChannelSettings: - enabled_channels = "telegram" - debounce_seconds = 1.0 - max_wait_seconds = 10.0 - active_time_window = 60.0 - stream_output = True - import bub.channels.manager as manager_module - monkeypatch.setattr(manager_module, "ChannelSettings", StubChannelSettings) manager = ChannelManager(framework) calls = 0 spawned_coroutines = [] @@ -248,7 +263,8 @@ async def shutdown() -> None: @pytest.mark.asyncio -async def test_channel_manager_quit_cancels_only_matching_session_tasks() -> None: +async def test_channel_manager_quit_cancels_only_matching_session_tasks(load_config) -> None: + _load_channel_config(load_config, enabled_channels="telegram") manager = ChannelManager(FakeFramework({"telegram": FakeChannel("telegram")}), enabled_channels=["telegram"]) async def never_finish() -> None: @@ -358,7 +374,8 @@ def test_bub_message_filter_accepts_group_mention() -> None: @pytest.mark.asyncio -async def test_telegram_channel_send_extracts_json_message_and_skips_blank() -> None: +async def test_telegram_channel_send_extracts_json_message_and_skips_blank(load_config) -> None: + _load_channel_config(load_config, telegram_value="test-token") channel = TelegramChannel(lambda message: None) sent: list[tuple[str, str]] = [] @@ -374,7 +391,8 @@ async def send_message(chat_id: str, text: str) -> None: @pytest.mark.asyncio -async def test_telegram_channel_build_message_returns_command_directly() -> None: +async def test_telegram_channel_build_message_returns_command_directly(load_config) -> None: + _load_channel_config(load_config, telegram_value="test-token") channel = TelegramChannel(lambda message: None) channel._parser = SimpleNamespace(parse=_async_return((",help", {"type": "text"})), get_reply=_async_return(None)) @@ -390,8 +408,9 @@ async def test_telegram_channel_build_message_returns_command_directly() -> None @pytest.mark.asyncio async def test_telegram_channel_build_message_wraps_payload_and_disables_outbound( - monkeypatch: pytest.MonkeyPatch, + monkeypatch: pytest.MonkeyPatch, load_config ) -> None: + _load_channel_config(load_config, telegram_value="test-token") channel = TelegramChannel(lambda message: None) parser = SimpleNamespace( parse=_async_return(("hello", {"type": "text", "sender_id": "7"})), diff --git a/tests/test_framework.py b/tests/test_framework.py index 5749d0f0..f295787d 100644 --- a/tests/test_framework.py +++ b/tests/test_framework.py @@ -1,15 +1,21 @@ from __future__ import annotations +import importlib.metadata +import os from pathlib import Path from types import SimpleNamespace +from unittest.mock import patch import pytest import typer from republic import AsyncStreamEvents, StreamEvent from typer.testing import CliRunner +from bub.builtin.settings import load_settings from bub.channels.base import Channel from bub.channels.message import ChannelMessage +from bub.channels.telegram import TelegramSettings +from bub.configure import ensure_config from bub.framework import BubFramework from bub.hookspecs import hookimpl @@ -98,14 +104,15 @@ def system_prompt(self, prompt: str, state: dict[str, str]) -> str | None: assert prompt == "low\n\nhigh" -def test_builtin_cli_exposes_login_and_gateway_command() -> None: - framework = BubFramework() - framework.load_hooks() - app = framework.create_cli_app() - runner = CliRunner() +def test_builtin_cli_exposes_login_and_gateway_command(write_config) -> None: + with patch.dict(os.environ, {}, clear=True): + framework = BubFramework(config_file=write_config()) + framework.load_hooks() + app = framework.create_cli_app() + runner = CliRunner() - help_result = runner.invoke(app, ["--help"]) - gateway_result = runner.invoke(app, ["gateway", "--help"]) + help_result = runner.invoke(app, ["--help"]) + gateway_result = runner.invoke(app, ["gateway", "--help"]) assert help_result.exit_code == 0 assert "login" in help_result.stdout @@ -116,6 +123,48 @@ def test_builtin_cli_exposes_login_and_gateway_command() -> None: assert "Start message listeners" in gateway_result.stdout +def test_load_hooks_loads_root_and_named_config_sections(monkeypatch: pytest.MonkeyPatch, write_config) -> None: + expected = "test-token" + config_file = write_config( + f""" +model: openai:gpt-5 +telegram: + token: {expected} +""".strip() + ) + + with patch.dict(os.environ, {}, clear=True): + monkeypatch.chdir(config_file.parent) + framework = BubFramework(config_file=config_file) + + framework.load_hooks() + + assert load_settings().model == "openai:gpt-5" + assert ensure_config(TelegramSettings).token == expected + + +def test_load_hooks_initializes_callable_plugins_after_config_load( + monkeypatch: pytest.MonkeyPatch, write_config +) -> None: + with patch.dict(os.environ, {}, clear=True): + framework = BubFramework(config_file=write_config("model: openai:gpt-5")) + + class SettingsAwarePlugin: + def __init__(self, _framework: BubFramework) -> None: + self.model = load_settings().model + + @hookimpl + def register_cli_commands(self, app: typer.Typer) -> None: + return None + + entry_point = SimpleNamespace(name="config-plugin", load=lambda: SettingsAwarePlugin) + monkeypatch.setattr(importlib.metadata, "entry_points", lambda group: [entry_point]) + + framework.load_hooks() + + assert framework._plugin_status["config-plugin"].is_success is True + + @pytest.mark.asyncio async def test_process_inbound_defaults_to_non_streaming_run_model() -> None: framework = BubFramework() diff --git a/tests/test_image_message.py b/tests/test_image_message.py index 7d17e9d0..132e15b6 100644 --- a/tests/test_image_message.py +++ b/tests/test_image_message.py @@ -179,9 +179,14 @@ async def runner(*args, **kwargs): return runner +async def _receive_message(_message) -> None: + return None + + @pytest.mark.asyncio -async def test_telegram_build_message_extracts_media_items(monkeypatch: pytest.MonkeyPatch) -> None: - channel = TelegramChannel(lambda message: None) # type: ignore[arg-type] +async def test_telegram_build_message_extracts_media_items(monkeypatch: pytest.MonkeyPatch, load_config) -> None: + load_config("telegram:\n token: test-token") + channel = TelegramChannel(_receive_message) photo_metadata = { "type": "photo", "sender_id": "7", @@ -206,8 +211,9 @@ async def test_telegram_build_message_extracts_media_items(monkeypatch: pytest.M @pytest.mark.asyncio -async def test_telegram_build_message_no_media_for_text(monkeypatch: pytest.MonkeyPatch) -> None: - channel = TelegramChannel(lambda message: None) # type: ignore[arg-type] +async def test_telegram_build_message_no_media_for_text(monkeypatch: pytest.MonkeyPatch, load_config) -> None: + load_config("telegram:\n token: test-token") + channel = TelegramChannel(_receive_message) channel._parser = SimpleNamespace( # type: ignore[assignment] parse=_async_return(("hello", {"type": "text", "sender_id": "7"})), get_reply=_async_return(None), @@ -233,7 +239,7 @@ def __init__(self, home: Path) -> None: def _build_impl(tmp_path: Path) -> tuple[BubFramework, BuiltinImpl]: framework = BubFramework() impl = BuiltinImpl(framework) - impl.agent = FakeAgent(tmp_path) # type: ignore[assignment] + impl._agent = FakeAgent(tmp_path) return framework, impl diff --git a/tests/test_settings.py b/tests/test_settings.py index 8369e3e2..bfd1b65b 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,8 +1,10 @@ from __future__ import annotations -from pathlib import Path +import os from unittest.mock import patch +import pytest + from bub.builtin.settings import AgentSettings, load_settings @@ -11,11 +13,6 @@ def _settings_with_env(env: dict[str, str]) -> AgentSettings: return AgentSettings() -def _write_config(home: Path, content: str) -> None: - home.mkdir(parents=True, exist_ok=True) - (home / "config.yml").write_text(content, encoding="utf-8") - - def test_settings_single_api_key_and_base() -> None: settings = _settings_with_env({"BUB_API_KEY": "sk-test", "BUB_API_BASE": "https://api.example.com"}) @@ -63,10 +60,10 @@ def test_settings_mixed_single_key_with_per_provider_base() -> None: assert settings.api_base["openai"] == "https://api.openai.com" -def test_settings_load_values_from_yaml(tmp_path: Path) -> None: - _write_config( - tmp_path, - """ +def test_settings_load_values_from_yaml(load_config) -> None: + with patch.dict(os.environ, {}, clear=True): + load_config( + """ model: openai:gpt-5 fallback_models: - openai:gpt-4o-mini @@ -80,10 +77,9 @@ def test_settings_load_values_from_yaml(tmp_path: Path) -> None: HTTP-Referer: https://openclaw.ai X-Title: OpenClaw """.strip(), - ) + ) - with patch.dict("os.environ", {"BUB_HOME": str(tmp_path)}, clear=True): - settings = AgentSettings() + settings = load_settings() assert settings.model == "openai:gpt-5" assert settings.fallback_models == ["openai:gpt-4o-mini"] @@ -95,10 +91,8 @@ def test_settings_load_values_from_yaml(tmp_path: Path) -> None: } -def test_env_settings_override_yaml(tmp_path: Path) -> None: - _write_config( - tmp_path, - """ +def test_env_settings_override_yaml(load_config) -> None: + config = """ model: openai:gpt-5 api_key: sk-yaml max_steps: 77 @@ -106,13 +100,11 @@ def test_env_settings_override_yaml(tmp_path: Path) -> None: extra_headers: HTTP-Referer: https://yaml.example X-Title: YAML App -""".strip(), - ) +""".strip() with patch.dict( "os.environ", { - "BUB_HOME": str(tmp_path), "BUB_MODEL": "anthropic:claude-3-7-sonnet", "BUB_API_KEY": "sk-env", "BUB_CLIENT_ARGS": '{"extra_headers":{"HTTP-Referer":"https://env.example","X-Title":"Env App"}}', @@ -120,7 +112,8 @@ def test_env_settings_override_yaml(tmp_path: Path) -> None: }, clear=True, ): - settings = AgentSettings() + load_config(config) + settings = load_settings() assert settings.model == "anthropic:claude-3-7-sonnet" assert settings.api_key == "sk-env" @@ -136,21 +129,21 @@ def test_settings_client_args_can_be_disabled() -> None: assert settings.client_args is None -def test_load_settings_reads_yaml_from_bub_home(tmp_path: Path) -> None: - _write_config( - tmp_path, - """ +def test_load_settings_requires_loaded_config() -> None: + with pytest.raises(RuntimeError, match="Config not loaded yet"): + load_settings() + + +def test_load_settings_returns_loaded_config(load_config) -> None: + with patch.dict(os.environ, {}, clear=True): + load_config( + """ model: openrouter:qwen/qwen3-coder-next api_format: responses """.strip(), - ) - - load_settings.cache_clear() - try: - with patch.dict("os.environ", {"BUB_HOME": str(tmp_path)}, clear=True): - settings = load_settings() - finally: - load_settings.cache_clear() + ) + + settings = load_settings() assert settings.model == "openrouter:qwen/qwen3-coder-next" assert settings.api_format == "responses" From 7c156fecc80a621b7599e4949748f370843e483d Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Thu, 23 Apr 2026 18:38:33 +0800 Subject: [PATCH 2/2] fix: correct formatting of telegram token in channel config Signed-off-by: Frost Ming --- tests/test_channels.py | 2 +- website/wrangler.jsonc | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_channels.py b/tests/test_channels.py index 41d2c7e8..b272ae3b 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -27,7 +27,7 @@ def _load_channel_config( enabled_channels: {enabled_channels} stream_output: {str(stream_output).lower()} telegram: - token: {telegram_value} + token: {telegram_value!r} """.strip() load_config(content) diff --git a/website/wrangler.jsonc b/website/wrangler.jsonc index 8b17fc7b..cbd77f6e 100644 --- a/website/wrangler.jsonc +++ b/website/wrangler.jsonc @@ -1,6 +1,7 @@ { "$schema": "./node_modules/wrangler/config-schema.json", "name": "bub", + "compatibility_date": "2026-04-22", "observability": { "enabled": false, "head_sampling_rate": 1,