Skip to content

Commit 13860a1

Browse files
authored
feat: extensible config object (#182)
* feat: extensible config object Signed-off-by: Frost Ming <me@frostming.com> * fix: correct formatting of telegram token in channel config Signed-off-by: Frost Ming <me@frostming.com>
1 parent ec337f4 commit 13860a1

18 files changed

Lines changed: 335 additions & 116 deletions

src/bub/__init__.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
"""Bub framework package."""
22

3+
from __future__ import annotations
4+
5+
import os
36
from importlib import import_module
47
from importlib.metadata import PackageNotFoundError
58
from importlib.metadata import version as metadata_version
9+
from pathlib import Path
10+
from typing import TYPE_CHECKING
611

7-
from bub.framework import BubFramework
12+
from bub.configure import Settings, config, ensure_config
13+
from bub.framework import DEFAULT_HOME, BubFramework
814
from bub.hookspecs import hookimpl
915
from bub.tools import tool
1016

11-
__all__ = ["BubFramework", "hookimpl", "tool"]
17+
__all__ = ["BubFramework", "Settings", "config", "ensure_config", "home", "hookimpl", "tool"]
1218

1319
try:
1420
__version__ = import_module("bub._version").version
@@ -17,3 +23,15 @@
1723
__version__ = metadata_version("bub")
1824
except PackageNotFoundError:
1925
__version__ = "0.0.0"
26+
27+
28+
if TYPE_CHECKING:
29+
home: Path
30+
31+
32+
def __getattr__(name: str):
33+
if name == "home":
34+
if "BUB_HOME" in os.environ:
35+
return Path(os.environ["BUB_HOME"])
36+
return DEFAULT_HOME
37+
raise AttributeError(f"module {__name__} has no attribute {name}")

src/bub/builtin/agent.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,14 @@ def __init__(self, framework: BubFramework) -> None:
5656

5757
@cached_property
5858
def tapes(self) -> TapeService:
59+
import bub
60+
5961
tape_store = self.framework.get_tape_store()
6062
if tape_store is None:
6163
tape_store = InMemoryTapeStore()
6264
tape_store = ForkTapeStore(tape_store)
6365
llm = _build_llm(self.settings, tape_store, self.framework.build_tape_context())
64-
return TapeService(llm, self.settings.home / "tapes", tape_store)
66+
return TapeService(llm, bub.home / "tapes", tape_store)
6567

6668
@staticmethod
6769
def _events_from_iterable(iterable: Iterable) -> AsyncStreamEvents:

src/bub/builtin/cli.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,9 @@ def _find_uv() -> str:
106106

107107
@lru_cache(maxsize=1)
108108
def _default_project() -> Path:
109-
from .settings import load_settings
109+
import bub
110110

111-
settings = load_settings()
112-
project = settings.home / "bub-project"
111+
project = bub.home / "bub-project"
113112
project.mkdir(exist_ok=True, parents=True)
114113
return project
115114

src/bub/builtin/hook_impl.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,12 @@ def __init__(self, framework: BubFramework) -> None:
4848
from bub.builtin import tools # noqa: F401
4949

5050
self.framework = framework
51-
self.agent = Agent(framework)
51+
self._agent: Agent | None = None
52+
53+
def _get_agent(self) -> Agent:
54+
if self._agent is None:
55+
self._agent = Agent(self.framework)
56+
return self._agent
5257

5358
@hookimpl
5459
def resolve_session(self, message: ChannelMessage) -> str:
@@ -64,7 +69,7 @@ async def load_state(self, message: ChannelMessage, session_id: str) -> State:
6469
lifespan = field_of(message, "lifespan")
6570
if lifespan is not None:
6671
await lifespan.__aenter__()
67-
state = {"session_id": session_id, "_runtime_agent": self.agent}
72+
state = {"session_id": session_id, "_runtime_agent": self._get_agent()}
6873
if context := field_of(message, "context_str"):
6974
state["context"] = context
7075
return state
@@ -107,11 +112,11 @@ async def build_prompt(self, message: ChannelMessage, session_id: str, state: St
107112

108113
@hookimpl
109114
async def run_model(self, prompt: str | list[dict], session_id: str, state: State) -> str:
110-
return await self.agent.run(session_id=session_id, prompt=prompt, state=state)
115+
return await self._get_agent().run(session_id=session_id, prompt=prompt, state=state)
111116

112117
@hookimpl
113118
async def run_model_stream(self, prompt: str | list[dict], session_id: str, state: State) -> AsyncStreamEvents:
114-
return await self.agent.run_stream(session_id=session_id, prompt=prompt, state=state)
119+
return await self._get_agent().run_stream(session_id=session_id, prompt=prompt, state=state)
115120

116121
@hookimpl
117122
def register_cli_commands(self, app: typer.Typer) -> None:
@@ -148,7 +153,7 @@ def provide_channels(self, message_handler: MessageHandler) -> list[Channel]:
148153

149154
return [
150155
TelegramChannel(on_receive=message_handler),
151-
CliChannel(on_receive=message_handler, agent=self.agent),
156+
CliChannel(on_receive=message_handler, agent=self._get_agent()),
152157
]
153158

154159
@hookimpl
@@ -191,9 +196,10 @@ def render_outbound(
191196

192197
@hookimpl
193198
def provide_tape_store(self) -> TapeStore:
199+
import bub
194200
from bub.builtin.store import FileTapeStore
195201

196-
return FileTapeStore(directory=self.agent.settings.home / "tapes")
202+
return FileTapeStore(directory=bub.home / "tapes")
197203

198204
@hookimpl
199205
def build_tape_context(self) -> TapeContext:

src/bub/builtin/settings.py

Lines changed: 18 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,15 @@
44
import pathlib
55
import re
66
from collections.abc import Callable
7-
from functools import lru_cache
87
from typing import Any, Literal
98

109
from pydantic import Field
11-
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, YamlConfigSettingsSource
10+
from pydantic_settings import SettingsConfigDict
11+
12+
from bub import Settings, config, ensure_config
1213

1314
DEFAULT_MODEL = "openrouter:qwen/qwen3-coder-next"
1415
DEFAULT_MAX_TOKENS = 1024
15-
DEFAULT_HOME = pathlib.Path.home() / ".bub"
16-
DEFAULT_CONFIG_FILE = DEFAULT_HOME / "config.yml"
1716

1817

1918
def provider_specific(setting_name: str) -> Callable[[], dict[str, str] | None]:
@@ -32,11 +31,11 @@ def default_factory() -> dict[str, str] | None:
3231
return default_factory
3332

3433

35-
class AgentSettings(BaseSettings):
34+
@config()
35+
class AgentSettings(Settings):
3636
"""Configuration settings for the Agent."""
3737

3838
model_config = SettingsConfigDict(env_prefix="BUB_", env_parse_none_str="null", extra="ignore")
39-
home: pathlib.Path = Field(default=DEFAULT_HOME)
4039
model: str = DEFAULT_MODEL
4140
fallback_models: list[str] | None = None
4241
api_key: str | dict[str, str] | None = Field(default_factory=provider_specific("api_key"))
@@ -48,25 +47,20 @@ class AgentSettings(BaseSettings):
4847
client_args: dict[str, Any] | None = None
4948
verbose: int = Field(default=0, description="Verbosity level for logging. Higher means more verbose.", ge=0, le=2)
5049

51-
@classmethod
52-
def settings_customise_sources(
53-
cls,
54-
settings_cls: type[BaseSettings],
55-
init_settings: PydanticBaseSettingsSource,
56-
env_settings: PydanticBaseSettingsSource,
57-
dotenv_settings: PydanticBaseSettingsSource,
58-
file_secret_settings: PydanticBaseSettingsSource,
59-
) -> tuple[PydanticBaseSettingsSource, ...]:
60-
home = os.getenv("BUB_HOME", str(DEFAULT_HOME))
61-
return (
62-
init_settings,
63-
env_settings,
64-
dotenv_settings,
65-
YamlConfigSettingsSource(settings_cls, yaml_file=pathlib.Path(home) / "config.yml"),
66-
file_secret_settings,
50+
@property
51+
def home(self) -> pathlib.Path:
52+
import warnings
53+
54+
import bub
55+
56+
warnings.warn(
57+
"Using the 'home' property from AgentSettings is deprecated. Please use 'bub.home' instead.",
58+
DeprecationWarning,
59+
stacklevel=2,
6760
)
6861

62+
return bub.home
63+
6964

70-
@lru_cache(maxsize=1)
7165
def load_settings() -> AgentSettings:
72-
return AgentSettings()
66+
return ensure_config(AgentSettings)

src/bub/channels/cli/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from rich import get_console
1616
from rich.live import Live
1717

18+
import bub
1819
from bub.builtin.agent import Agent
1920
from bub.builtin.tape import TapeInfo
2021
from bub.channels.base import Channel
@@ -160,7 +161,7 @@ def _tool_sort_key(tool_name: str) -> tuple[str, str]:
160161
section, _, name = tool_name.rpartition(".")
161162
return (section, name)
162163

163-
history_file = self._history_file(self._agent.settings.home, workspace)
164+
history_file = self._history_file(bub.home, workspace)
164165
history_file.parent.mkdir(parents=True, exist_ok=True)
165166
history = FileHistory(str(history_file))
166167
tool_names = sorted((f",{name}" for name in REGISTRY), key=_tool_sort_key)

src/bub/channels/manager.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,22 @@
55

66
from loguru import logger
77
from pydantic import Field
8-
from pydantic_settings import BaseSettings, SettingsConfigDict
8+
from pydantic_settings import SettingsConfigDict
99
from republic import StreamEvent
1010

11+
from bub import config
1112
from bub.channels.base import Channel
1213
from bub.channels.handler import BufferedMessageHandler
1314
from bub.channels.message import ChannelMessage
15+
from bub.configure import Settings, ensure_config
1416
from bub.envelope import content_of, field_of
1517
from bub.framework import BubFramework
1618
from bub.types import Envelope, MessageHandler
1719
from bub.utils import wait_until_stopped
1820

1921

20-
class ChannelSettings(BaseSettings):
22+
@config()
23+
class ChannelSettings(Settings):
2124
model_config = SettingsConfigDict(env_prefix="BUB_", extra="ignore", env_file=".env")
2225

2326
enabled_channels: str = Field(
@@ -47,7 +50,7 @@ def __init__(
4750
) -> None:
4851
self.framework = framework
4952
self._channels: dict[str, Channel] = self.framework.get_channels(self.on_receive)
50-
self._settings = ChannelSettings()
53+
self._settings = ensure_config(ChannelSettings)
5154
self._stream_output = stream_output if stream_output is not None else self._settings.stream_output
5255
if enabled_channels is not None:
5356
self._enabled_channels = list(enabled_channels)

src/bub/channels/telegram.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,22 @@
88

99
from loguru import logger
1010
from pydantic import Field
11-
from pydantic_settings import BaseSettings, SettingsConfigDict
11+
from pydantic_settings import SettingsConfigDict
1212
from telegram import Bot, Message, Update
1313
from telegram.ext import Application, CommandHandler, ContextTypes, filters
1414
from telegram.ext import MessageHandler as TelegramMessageHandler
1515
from telegram.request import HTTPXRequest
1616

17+
from bub import config
1718
from bub.channels.base import Channel
1819
from bub.channels.message import ChannelMessage, MediaItem, MediaType
20+
from bub.configure import Settings, ensure_config
1921
from bub.types import MessageHandler
2022
from bub.utils import exclude_none
2123

2224

23-
class TelegramSettings(BaseSettings):
25+
@config(name="telegram")
26+
class TelegramSettings(Settings):
2427
model_config = SettingsConfigDict(env_prefix="BUB_TELEGRAM_", extra="ignore", env_file=".env")
2528

2629
token: str = Field(default="", description="Telegram bot token.")
@@ -148,7 +151,7 @@ class TelegramChannel(Channel):
148151

149152
def __init__(self, on_receive: MessageHandler) -> None:
150153
self._on_receive = on_receive
151-
self._settings = TelegramSettings()
154+
self._settings = ensure_config(TelegramSettings)
152155
self._allow_users = {uid.strip() for uid in (self._settings.allow_users or "").split(",") if uid.strip()}
153156
self._allow_chats = {cid.strip() for cid in (self._settings.allow_chats or "").split(",") if cid.strip()}
154157
self._parser = TelegramMessageParser(bot_getter=lambda: self._app.bot)

src/bub/configure.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from collections.abc import Callable
2+
from pathlib import Path
3+
from typing import Any
4+
5+
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource
6+
7+
CONFIG_MAP: dict[str, list[type[BaseSettings]]] = {}
8+
ROOT = ""
9+
10+
_global_config: dict[str, list[BaseSettings]] | None = None
11+
12+
13+
class Settings(BaseSettings):
14+
@classmethod
15+
def settings_customise_sources(
16+
cls,
17+
settings_cls: type[BaseSettings],
18+
init_settings: PydanticBaseSettingsSource,
19+
env_settings: PydanticBaseSettingsSource,
20+
dotenv_settings: PydanticBaseSettingsSource,
21+
file_secret_settings: PydanticBaseSettingsSource,
22+
) -> tuple[PydanticBaseSettingsSource, ...]:
23+
del settings_cls # unused
24+
return (env_settings, dotenv_settings, init_settings, file_secret_settings)
25+
26+
27+
def config[C: type[BaseSettings]](name: str = ROOT) -> Callable[[C], C]:
28+
"""Decorator to register a config class for a plugin."""
29+
30+
def decorator(cls: C) -> C:
31+
if name not in CONFIG_MAP:
32+
CONFIG_MAP[name] = []
33+
CONFIG_MAP[name].append(cls)
34+
return cls
35+
36+
return decorator
37+
38+
39+
def load(config_file: Path) -> dict[str, list[BaseSettings]]:
40+
"""Load config from a file."""
41+
import yaml
42+
43+
global _global_config
44+
if _global_config is not None:
45+
return _global_config
46+
47+
this_data: dict[str, list[BaseSettings]] = {}
48+
49+
config_data: dict[str, Any] = {}
50+
if config_file.exists():
51+
with config_file.open() as f:
52+
config_data = yaml.safe_load(f) or {}
53+
54+
for name, config_classes in CONFIG_MAP.items():
55+
section_data = config_data if name == ROOT else config_data.get(name, {})
56+
for config_cls in config_classes:
57+
config_instance = config_cls.model_validate(section_data)
58+
this_data.setdefault(name, []).append(config_instance)
59+
60+
_global_config = this_data
61+
return _global_config
62+
63+
64+
def ensure_config[C: BaseSettings](config_cls: type[C]) -> C:
65+
"""No-op function to ensure a config class is registered and can be imported."""
66+
if _global_config is None:
67+
raise RuntimeError("Config not loaded yet")
68+
for config_list in _global_config.values():
69+
for config in config_list:
70+
if isinstance(config, config_cls):
71+
return config
72+
raise ValueError(f"Config class {config_cls} not found in loaded config")

0 commit comments

Comments
 (0)