feat(config): add hot-reload via watchfiles (#78)
This commit is contained in:
@@ -18,6 +18,7 @@ dependencies = [
|
||||
"structlog>=25.5.0",
|
||||
"sulguk>=0.11.1",
|
||||
"typer>=0.21.0",
|
||||
"watchfiles>=0.21.0",
|
||||
]
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
|
||||
@@ -56,6 +56,8 @@ global config `~/.takopi/takopi.toml`
|
||||
|
||||
```toml
|
||||
default_engine = "codex"
|
||||
# optional: reload config changes without restarting
|
||||
watch_config = true
|
||||
|
||||
# optional, defaults to "telegram"
|
||||
transport = "telegram"
|
||||
@@ -90,6 +92,7 @@ extra_args = ["--no-color"]
|
||||
```
|
||||
|
||||
note: configs with top-level `bot_token` / `chat_id` are migrated to `[transports.telegram]` on startup.
|
||||
note: `watch_config` reloads runtime settings (projects, engines, plugins). transport changes still require a restart.
|
||||
|
||||
## projects
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Awaitable, Callable, Iterable
|
||||
|
||||
from watchfiles import awatch
|
||||
|
||||
from .config import ConfigError
|
||||
from .logging import get_logger
|
||||
from .runtime_loader import RuntimeSpec, build_runtime_spec
|
||||
from .settings import TakopiSettings, load_settings
|
||||
from .transport_runtime import TransportRuntime
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigReload:
|
||||
settings: TakopiSettings
|
||||
runtime_spec: RuntimeSpec
|
||||
config_path: Path
|
||||
|
||||
|
||||
def _config_status(path: Path) -> tuple[str, tuple[int, int] | None]:
|
||||
try:
|
||||
stat = path.stat()
|
||||
except FileNotFoundError:
|
||||
return "missing", None
|
||||
except OSError:
|
||||
return "missing", None
|
||||
if not path.is_file():
|
||||
return "invalid", None
|
||||
return "ok", (stat.st_mtime_ns, stat.st_size)
|
||||
|
||||
|
||||
def _reload_config(
|
||||
config_path: Path,
|
||||
default_engine_override: str | None,
|
||||
reserved: tuple[str, ...],
|
||||
) -> ConfigReload:
|
||||
settings, resolved_path = load_settings(config_path)
|
||||
spec = build_runtime_spec(
|
||||
settings=settings,
|
||||
config_path=resolved_path,
|
||||
default_engine_override=default_engine_override,
|
||||
reserved=reserved,
|
||||
)
|
||||
return ConfigReload(
|
||||
settings=settings,
|
||||
runtime_spec=spec,
|
||||
config_path=resolved_path,
|
||||
)
|
||||
|
||||
|
||||
async def watch_config(
|
||||
*,
|
||||
config_path: Path,
|
||||
runtime: TransportRuntime,
|
||||
default_engine_override: str | None = None,
|
||||
reserved: Iterable[str] = ("cancel",),
|
||||
on_reload: Callable[[ConfigReload], Awaitable[None]] | None = None,
|
||||
) -> None:
|
||||
reserved_tuple = tuple(reserved)
|
||||
config_path = config_path.expanduser().resolve()
|
||||
watch_root = config_path.parent
|
||||
status, signature = _config_status(config_path)
|
||||
last_status = status
|
||||
if status != "ok":
|
||||
logger.warning("config.watch.unavailable", path=str(config_path), status=status)
|
||||
|
||||
async for changes in awatch(watch_root):
|
||||
if not any(Path(path) == config_path for _, path in changes):
|
||||
continue
|
||||
|
||||
status, current = _config_status(config_path)
|
||||
if status != "ok":
|
||||
if status != last_status:
|
||||
logger.warning(
|
||||
"config.watch.unavailable",
|
||||
path=str(config_path),
|
||||
status=status,
|
||||
)
|
||||
last_status = status
|
||||
signature = None
|
||||
continue
|
||||
|
||||
if last_status != "ok":
|
||||
logger.info("config.watch.available", path=str(config_path))
|
||||
last_status = status
|
||||
|
||||
if current == signature:
|
||||
continue
|
||||
|
||||
try:
|
||||
reload = _reload_config(
|
||||
config_path,
|
||||
default_engine_override,
|
||||
reserved_tuple,
|
||||
)
|
||||
except ConfigError as exc:
|
||||
logger.warning("config.reload.failed", error=str(exc))
|
||||
signature = current
|
||||
continue
|
||||
except Exception as exc: # pragma: no cover - safety net
|
||||
logger.exception(
|
||||
"config.reload.crashed",
|
||||
error=str(exc),
|
||||
error_type=exc.__class__.__name__,
|
||||
)
|
||||
signature = current
|
||||
continue
|
||||
|
||||
reload.runtime_spec.apply(runtime, config_path=reload.config_path)
|
||||
logger.info("config.reload.applied", path=str(reload.config_path))
|
||||
if on_reload is not None:
|
||||
try:
|
||||
await on_reload(reload)
|
||||
except Exception as exc: # pragma: no cover - safety net
|
||||
logger.exception(
|
||||
"config.reload.callback_failed",
|
||||
error=str(exc),
|
||||
error_type=exc.__class__.__name__,
|
||||
)
|
||||
|
||||
_, signature = _config_status(config_path)
|
||||
if signature is None:
|
||||
signature = current
|
||||
@@ -0,0 +1,203 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
from .backends import EngineBackend
|
||||
from .config import ConfigError, ProjectsConfig
|
||||
from .engines import get_backend, list_backend_ids
|
||||
from .logging import get_logger
|
||||
from .router import AutoRouter, RunnerEntry
|
||||
from .settings import TakopiSettings
|
||||
from .transport_runtime import TransportRuntime
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RuntimeSpec:
|
||||
router: AutoRouter
|
||||
projects: ProjectsConfig
|
||||
allowlist: list[str] | None
|
||||
plugin_configs: Mapping[str, Any] | None
|
||||
|
||||
def to_runtime(self, *, config_path: Path | None) -> TransportRuntime:
|
||||
return TransportRuntime(
|
||||
router=self.router,
|
||||
projects=self.projects,
|
||||
allowlist=self.allowlist,
|
||||
config_path=config_path,
|
||||
plugin_configs=self.plugin_configs,
|
||||
)
|
||||
|
||||
def apply(self, runtime: TransportRuntime, *, config_path: Path | None) -> None:
|
||||
runtime.update(
|
||||
router=self.router,
|
||||
projects=self.projects,
|
||||
allowlist=self.allowlist,
|
||||
config_path=config_path,
|
||||
plugin_configs=self.plugin_configs,
|
||||
)
|
||||
|
||||
|
||||
def resolve_plugins_allowlist(
|
||||
settings: TakopiSettings | None,
|
||||
) -> list[str] | None:
|
||||
if settings is None:
|
||||
return None
|
||||
enabled = [
|
||||
value.strip()
|
||||
for value in settings.plugins.enabled
|
||||
if isinstance(value, str) and value.strip()
|
||||
]
|
||||
return enabled or None
|
||||
|
||||
|
||||
def resolve_default_engine(
|
||||
*,
|
||||
override: str | None,
|
||||
settings: TakopiSettings,
|
||||
config_path: Path,
|
||||
engine_ids: list[str],
|
||||
) -> str:
|
||||
default_engine = override or settings.default_engine or "codex"
|
||||
if not isinstance(default_engine, str) or not default_engine.strip():
|
||||
raise ConfigError(
|
||||
f"Invalid `default_engine` in {config_path}; expected a non-empty string."
|
||||
)
|
||||
default_engine = default_engine.strip()
|
||||
if default_engine not in engine_ids:
|
||||
available = ", ".join(sorted(engine_ids))
|
||||
raise ConfigError(
|
||||
f"Unknown default engine {default_engine!r}. Available: {available}."
|
||||
)
|
||||
return default_engine
|
||||
|
||||
|
||||
def build_router(
|
||||
*,
|
||||
settings: TakopiSettings,
|
||||
config_path: Path,
|
||||
backends: list[EngineBackend],
|
||||
default_engine: str,
|
||||
) -> AutoRouter:
|
||||
entries: list[RunnerEntry] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
for backend in backends:
|
||||
engine_id = backend.id
|
||||
issue: str | None = None
|
||||
engine_cfg: dict
|
||||
try:
|
||||
engine_cfg = settings.engine_config(engine_id, config_path=config_path)
|
||||
except ConfigError as exc:
|
||||
if engine_id == default_engine:
|
||||
raise
|
||||
issue = str(exc)
|
||||
engine_cfg = {}
|
||||
|
||||
try:
|
||||
runner = backend.build_runner(engine_cfg, config_path)
|
||||
except Exception as exc:
|
||||
if engine_id == default_engine:
|
||||
raise
|
||||
issue = issue or str(exc)
|
||||
if engine_cfg:
|
||||
try:
|
||||
runner = backend.build_runner({}, config_path)
|
||||
except Exception as fallback_exc:
|
||||
warnings.append(f"{engine_id}: {issue or str(fallback_exc)}")
|
||||
continue
|
||||
else:
|
||||
warnings.append(f"{engine_id}: {issue}")
|
||||
continue
|
||||
|
||||
cmd = backend.cli_cmd or backend.id
|
||||
if shutil.which(cmd) is None:
|
||||
issue = issue or f"{cmd} not found on PATH"
|
||||
|
||||
if issue and engine_id == default_engine:
|
||||
raise ConfigError(f"Default engine {engine_id!r} unavailable: {issue}")
|
||||
|
||||
available = issue is None
|
||||
if issue and engine_id != default_engine:
|
||||
warnings.append(f"{engine_id}: {issue}")
|
||||
|
||||
entries.append(
|
||||
RunnerEntry(
|
||||
engine=engine_id,
|
||||
runner=runner,
|
||||
available=available,
|
||||
issue=issue,
|
||||
)
|
||||
)
|
||||
|
||||
for warning in warnings:
|
||||
logger.warning("setup.warning", issue=warning)
|
||||
|
||||
return AutoRouter(entries=entries, default_engine=default_engine)
|
||||
|
||||
|
||||
def load_backends(
|
||||
*,
|
||||
engine_ids: list[str],
|
||||
allowlist: list[str] | None,
|
||||
default_engine: str,
|
||||
) -> list[EngineBackend]:
|
||||
backends: list[EngineBackend] = []
|
||||
load_issues: list[str] = []
|
||||
for engine_id in engine_ids:
|
||||
try:
|
||||
backend = get_backend(engine_id, allowlist=allowlist)
|
||||
except ConfigError as exc:
|
||||
if engine_id == default_engine:
|
||||
raise
|
||||
load_issues.append(f"{engine_id}: {exc}")
|
||||
continue
|
||||
backends.append(backend)
|
||||
if not backends:
|
||||
raise ConfigError("No engine backends are available.")
|
||||
for issue in load_issues:
|
||||
logger.warning("setup.warning", issue=issue)
|
||||
return backends
|
||||
|
||||
|
||||
def build_runtime_spec(
|
||||
*,
|
||||
settings: TakopiSettings,
|
||||
config_path: Path,
|
||||
default_engine_override: str | None = None,
|
||||
reserved: Iterable[str] = ("cancel",),
|
||||
) -> RuntimeSpec:
|
||||
allowlist = resolve_plugins_allowlist(settings)
|
||||
engine_ids = list_backend_ids(allowlist=allowlist)
|
||||
projects = settings.to_projects_config(
|
||||
config_path=config_path,
|
||||
engine_ids=engine_ids,
|
||||
reserved=reserved,
|
||||
)
|
||||
default_engine = resolve_default_engine(
|
||||
override=default_engine_override,
|
||||
settings=settings,
|
||||
config_path=config_path,
|
||||
engine_ids=engine_ids,
|
||||
)
|
||||
backends = load_backends(
|
||||
engine_ids=engine_ids,
|
||||
allowlist=allowlist,
|
||||
default_engine=default_engine,
|
||||
)
|
||||
router = build_router(
|
||||
settings=settings,
|
||||
config_path=config_path,
|
||||
backends=backends,
|
||||
default_engine=default_engine,
|
||||
)
|
||||
return RuntimeSpec(
|
||||
router=router,
|
||||
projects=projects,
|
||||
allowlist=allowlist,
|
||||
plugin_configs=settings.plugins.model_extra,
|
||||
)
|
||||
@@ -109,6 +109,7 @@ class TakopiSettings(BaseSettings):
|
||||
env_nested_delimiter="__",
|
||||
)
|
||||
|
||||
watch_config: bool = False
|
||||
default_engine: str = "codex"
|
||||
default_project: str | None = None
|
||||
projects: dict[str, ProjectSettings] = Field(default_factory=dict)
|
||||
|
||||
@@ -7,7 +7,9 @@ import anyio
|
||||
|
||||
from ..backends import EngineBackend
|
||||
from ..runner_bridge import ExecBridgeConfig
|
||||
from ..settings import require_telegram_config
|
||||
from ..config import ConfigError
|
||||
from ..logging import get_logger
|
||||
from ..settings import load_settings, require_telegram_config
|
||||
from ..transports import SetupResult, TransportBackend
|
||||
from ..transport_runtime import TransportRuntime
|
||||
from .bridge import (
|
||||
@@ -20,6 +22,8 @@ from .bridge import (
|
||||
from .client import TelegramClient
|
||||
from .onboarding import check_setup, interactive_setup
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _build_startup_message(
|
||||
runtime: TransportRuntime,
|
||||
@@ -82,7 +86,17 @@ class TelegramBackend(TransportBackend):
|
||||
final_notify: bool,
|
||||
default_engine_override: str | None,
|
||||
) -> None:
|
||||
_ = default_engine_override
|
||||
watch_enabled = False
|
||||
try:
|
||||
settings, _ = load_settings(config_path)
|
||||
except ConfigError as exc:
|
||||
logger.warning(
|
||||
"config.watch.disabled",
|
||||
error=str(exc),
|
||||
)
|
||||
else:
|
||||
watch_enabled = settings.watch_config
|
||||
|
||||
token, chat_id = require_telegram_config(transport_config, config_path)
|
||||
startup_msg = _build_startup_message(
|
||||
runtime,
|
||||
@@ -97,17 +111,25 @@ class TelegramBackend(TransportBackend):
|
||||
final_notify=final_notify,
|
||||
)
|
||||
voice_transcription = _build_voice_transcription_config(transport_config)
|
||||
chat_ids = (chat_id, *runtime.project_chat_ids())
|
||||
cfg = TelegramBridgeConfig(
|
||||
bot=bot,
|
||||
runtime=runtime,
|
||||
chat_id=chat_id,
|
||||
chat_ids=chat_ids,
|
||||
startup_msg=startup_msg,
|
||||
exec_cfg=exec_cfg,
|
||||
voice_transcription=voice_transcription,
|
||||
)
|
||||
anyio.run(run_main_loop, cfg)
|
||||
|
||||
async def run_loop() -> None:
|
||||
await run_main_loop(
|
||||
cfg,
|
||||
watch_config=watch_enabled,
|
||||
default_engine_override=default_engine_override,
|
||||
transport_id=self.id,
|
||||
transport_config=transport_config,
|
||||
)
|
||||
|
||||
anyio.run(run_loop)
|
||||
|
||||
|
||||
telegram_backend = TelegramBackend()
|
||||
|
||||
@@ -19,6 +19,7 @@ from ..commands import (
|
||||
)
|
||||
from ..context import RunContext
|
||||
from ..config import ConfigError
|
||||
from ..config_watch import ConfigReload, watch_config as watch_config_changes
|
||||
from ..directives import DirectiveError
|
||||
from ..ids import RESERVED_COMMAND_IDS, is_valid_id
|
||||
from ..runner_bridge import (
|
||||
@@ -143,6 +144,43 @@ def _build_bot_commands(runtime: TransportRuntime) -> list[dict[str, str]]:
|
||||
return commands
|
||||
|
||||
|
||||
def _reserved_commands(runtime: TransportRuntime) -> set[str]:
|
||||
return {
|
||||
*{engine.lower() for engine in runtime.engine_ids},
|
||||
*{alias.lower() for alias in runtime.project_aliases()},
|
||||
*RESERVED_COMMAND_IDS,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RuntimeCommandCache:
|
||||
command_ids: set[str]
|
||||
reserved_commands: set[str]
|
||||
|
||||
@classmethod
|
||||
def from_runtime(cls, runtime: TransportRuntime) -> "RuntimeCommandCache":
|
||||
allowlist = runtime.allowlist
|
||||
return cls(
|
||||
command_ids={
|
||||
command_id.lower()
|
||||
for command_id in list_command_ids(allowlist=allowlist)
|
||||
},
|
||||
reserved_commands=_reserved_commands(runtime),
|
||||
)
|
||||
|
||||
def refresh(self, runtime: TransportRuntime) -> None:
|
||||
allowlist = runtime.allowlist
|
||||
self.command_ids = {
|
||||
command_id.lower() for command_id in list_command_ids(allowlist=allowlist)
|
||||
}
|
||||
self.reserved_commands = _reserved_commands(runtime)
|
||||
|
||||
|
||||
def _diff_keys(old: dict[str, object], new: dict[str, object]) -> list[str]:
|
||||
keys = set(old) | set(new)
|
||||
return sorted(key for key in keys if old.get(key) != new.get(key))
|
||||
|
||||
|
||||
async def _set_command_menu(cfg: TelegramBridgeConfig) -> None:
|
||||
commands = _build_bot_commands(cfg.runtime)
|
||||
if not commands:
|
||||
@@ -303,6 +341,7 @@ class TelegramBridgeConfig:
|
||||
def _allowed_chat_ids(cfg: TelegramBridgeConfig) -> set[int]:
|
||||
allowed = set(cfg.chat_ids or ())
|
||||
allowed.add(cfg.chat_id)
|
||||
allowed.update(cfg.runtime.project_chat_ids())
|
||||
return allowed
|
||||
|
||||
|
||||
@@ -362,7 +401,7 @@ async def poll_updates(
|
||||
|
||||
async for msg in poll_incoming(
|
||||
cfg.bot,
|
||||
chat_ids=_allowed_chat_ids(cfg),
|
||||
chat_ids=lambda: _allowed_chat_ids(cfg),
|
||||
offset=offset,
|
||||
):
|
||||
yield msg
|
||||
@@ -927,21 +966,62 @@ async def run_main_loop(
|
||||
poller: Callable[[TelegramBridgeConfig], AsyncIterator[TelegramIncomingMessage]] = (
|
||||
poll_updates
|
||||
),
|
||||
*,
|
||||
watch_config: bool | None = None,
|
||||
default_engine_override: str | None = None,
|
||||
transport_id: str | None = None,
|
||||
transport_config: dict[str, object] | None = None,
|
||||
) -> None:
|
||||
running_tasks: RunningTasks = {}
|
||||
command_cache = RuntimeCommandCache.from_runtime(cfg.runtime)
|
||||
transport_snapshot = (
|
||||
dict(transport_config) if transport_config is not None else None
|
||||
)
|
||||
|
||||
try:
|
||||
await _set_command_menu(cfg)
|
||||
allowlist = cfg.runtime.allowlist
|
||||
command_ids = {
|
||||
command_id.lower() for command_id in list_command_ids(allowlist=allowlist)
|
||||
}
|
||||
reserved_commands = {
|
||||
*{engine.lower() for engine in cfg.runtime.engine_ids},
|
||||
*{alias.lower() for alias in cfg.runtime.project_aliases()},
|
||||
*RESERVED_COMMAND_IDS,
|
||||
}
|
||||
async with anyio.create_task_group() as tg:
|
||||
config_path = cfg.runtime.config_path
|
||||
watch_enabled = bool(watch_config) and config_path is not None
|
||||
|
||||
async def handle_reload(reload: ConfigReload) -> None:
|
||||
nonlocal transport_snapshot, transport_id
|
||||
command_cache.refresh(cfg.runtime)
|
||||
await _set_command_menu(cfg)
|
||||
if transport_snapshot is not None:
|
||||
new_snapshot = reload.settings.transports.telegram.model_dump()
|
||||
changed = _diff_keys(transport_snapshot, new_snapshot)
|
||||
if changed:
|
||||
logger.warning(
|
||||
"config.reload.transport_config_changed",
|
||||
transport="telegram",
|
||||
keys=changed,
|
||||
restart_required=True,
|
||||
)
|
||||
transport_snapshot = new_snapshot
|
||||
if (
|
||||
transport_id is not None
|
||||
and reload.settings.transport != transport_id
|
||||
):
|
||||
logger.warning(
|
||||
"config.reload.transport_changed",
|
||||
old=transport_id,
|
||||
new=reload.settings.transport,
|
||||
restart_required=True,
|
||||
)
|
||||
transport_id = reload.settings.transport
|
||||
|
||||
if watch_enabled and config_path is not None:
|
||||
|
||||
async def run_config_watch() -> None:
|
||||
await watch_config_changes(
|
||||
config_path=config_path,
|
||||
runtime=cfg.runtime,
|
||||
default_engine_override=default_engine_override,
|
||||
on_reload=handle_reload,
|
||||
)
|
||||
|
||||
tg.start_soon(run_config_watch)
|
||||
|
||||
async def run_job(
|
||||
chat_id: int,
|
||||
@@ -1000,12 +1080,13 @@ async def run_main_loop(
|
||||
continue
|
||||
|
||||
command_id, args_text = _parse_slash_command(text)
|
||||
if command_id is not None and command_id not in reserved_commands:
|
||||
if command_id not in command_ids:
|
||||
command_ids = {
|
||||
cid.lower() for cid in list_command_ids(allowlist=allowlist)
|
||||
}
|
||||
if command_id in command_ids:
|
||||
if (
|
||||
command_id is not None
|
||||
and command_id not in command_cache.reserved_commands
|
||||
):
|
||||
if command_id not in command_cache.command_ids:
|
||||
command_cache.refresh(cfg.runtime)
|
||||
if command_id in command_cache.command_ids:
|
||||
tg.start_soon(
|
||||
_dispatch_command,
|
||||
cfg,
|
||||
|
||||
@@ -127,12 +127,9 @@ async def poll_incoming(
|
||||
bot: BotClient,
|
||||
*,
|
||||
chat_id: int | None = None,
|
||||
chat_ids: Iterable[int] | None = None,
|
||||
chat_ids: Iterable[int] | Callable[[], Iterable[int]] | None = None,
|
||||
offset: int | None = None,
|
||||
) -> AsyncIterator[TelegramIncomingMessage]:
|
||||
allowed = set(chat_ids) if chat_ids is not None else None
|
||||
if allowed is None and chat_id is not None:
|
||||
allowed = {chat_id}
|
||||
while True:
|
||||
updates = await bot.get_updates(
|
||||
offset=offset, timeout_s=50, allowed_updates=["message"]
|
||||
@@ -142,6 +139,10 @@ async def poll_incoming(
|
||||
await anyio.sleep(2)
|
||||
continue
|
||||
logger.debug("loop.updates", updates=updates)
|
||||
resolved_chat_ids = chat_ids() if callable(chat_ids) else chat_ids
|
||||
allowed = set(resolved_chat_ids) if resolved_chat_ids is not None else None
|
||||
if allowed is None and chat_id is not None:
|
||||
allowed = {chat_id}
|
||||
for upd in updates:
|
||||
offset = upd["update_id"] + 1
|
||||
msg = parse_incoming_update(upd, chat_ids=allowed)
|
||||
|
||||
@@ -55,6 +55,21 @@ class TransportRuntime:
|
||||
self._config_path = config_path
|
||||
self._plugin_configs = dict(plugin_configs or {})
|
||||
|
||||
def update(
|
||||
self,
|
||||
*,
|
||||
router: AutoRouter,
|
||||
projects: ProjectsConfig,
|
||||
allowlist: Iterable[str] | None = None,
|
||||
config_path: Path | None = None,
|
||||
plugin_configs: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
self._router = router
|
||||
self._projects = projects
|
||||
self._allowlist = normalize_allowlist(allowlist)
|
||||
self._config_path = config_path
|
||||
self._plugin_configs = dict(plugin_configs or {})
|
||||
|
||||
@property
|
||||
def default_engine(self) -> EngineId:
|
||||
return self._router.default_engine
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
from pathlib import Path
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
|
||||
import takopi.config_watch as config_watch
|
||||
from takopi.config_watch import ConfigReload, _config_status, watch_config
|
||||
from takopi.config import empty_projects_config
|
||||
from takopi.router import AutoRouter, RunnerEntry
|
||||
from takopi.runtime_loader import RuntimeSpec
|
||||
from takopi.runners.mock import Return, ScriptRunner
|
||||
from takopi.settings import TakopiSettings
|
||||
from takopi.transport_runtime import TransportRuntime
|
||||
|
||||
|
||||
def test_config_status_variants(tmp_path: Path) -> None:
|
||||
missing = tmp_path / "missing.toml"
|
||||
status, signature = _config_status(missing)
|
||||
assert status == "missing"
|
||||
assert signature is None
|
||||
|
||||
directory = tmp_path / "config.d"
|
||||
directory.mkdir()
|
||||
status, signature = _config_status(directory)
|
||||
assert status == "invalid"
|
||||
assert signature is None
|
||||
|
||||
config_file = tmp_path / "takopi.toml"
|
||||
config_file.write_text('transport = "telegram"\n', encoding="utf-8")
|
||||
status, signature = _config_status(config_file)
|
||||
assert status == "ok"
|
||||
assert signature is not None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_watch_config_applies_runtime(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
config_path = tmp_path / "takopi.toml"
|
||||
config_path.write_text('default_engine = "codex"\n', encoding="utf-8")
|
||||
resolved_path = config_path.resolve()
|
||||
|
||||
codex_runner = ScriptRunner([Return(answer="ok")], engine="codex")
|
||||
router = AutoRouter(
|
||||
entries=[RunnerEntry(engine=codex_runner.engine, runner=codex_runner)],
|
||||
default_engine=codex_runner.engine,
|
||||
)
|
||||
runtime = TransportRuntime(
|
||||
router=router,
|
||||
projects=empty_projects_config(),
|
||||
config_path=resolved_path,
|
||||
)
|
||||
|
||||
pi_runner = ScriptRunner([Return(answer="ok")], engine="pi")
|
||||
new_router = AutoRouter(
|
||||
entries=[RunnerEntry(engine=pi_runner.engine, runner=pi_runner)],
|
||||
default_engine=pi_runner.engine,
|
||||
)
|
||||
new_spec = RuntimeSpec(
|
||||
router=new_router,
|
||||
projects=empty_projects_config(),
|
||||
allowlist=None,
|
||||
plugin_configs=None,
|
||||
)
|
||||
reload = ConfigReload(
|
||||
settings=TakopiSettings.model_validate({"transport": "telegram"}),
|
||||
runtime_spec=new_spec,
|
||||
config_path=resolved_path,
|
||||
)
|
||||
|
||||
ready = anyio.Event()
|
||||
watching = anyio.Event()
|
||||
|
||||
async def fake_awatch(_path: Path):
|
||||
watching.set()
|
||||
await ready.wait()
|
||||
yield {(None, str(resolved_path))}
|
||||
|
||||
monkeypatch.setattr(config_watch, "awatch", fake_awatch)
|
||||
monkeypatch.setattr(
|
||||
config_watch, "_reload_config", lambda *_args, **_kwargs: reload
|
||||
)
|
||||
|
||||
reloaded = anyio.Event()
|
||||
|
||||
async def on_reload(_payload: ConfigReload) -> None:
|
||||
reloaded.set()
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
|
||||
async def run_watch() -> None:
|
||||
await watch_config(
|
||||
config_path=resolved_path,
|
||||
runtime=runtime,
|
||||
on_reload=on_reload,
|
||||
)
|
||||
|
||||
tg.start_soon(run_watch)
|
||||
with anyio.fail_after(2):
|
||||
await watching.wait()
|
||||
config_path.write_text('default_engine = "pi"\n', encoding="utf-8")
|
||||
ready.set()
|
||||
with anyio.fail_after(2):
|
||||
await reloaded.wait()
|
||||
tg.cancel_scope.cancel()
|
||||
|
||||
assert runtime.default_engine == "pi"
|
||||
@@ -0,0 +1,36 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import takopi.runtime_loader as runtime_loader
|
||||
from takopi.config import ConfigError
|
||||
from takopi.settings import TakopiSettings
|
||||
|
||||
|
||||
def test_build_runtime_spec_minimal(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.setattr(runtime_loader.shutil, "which", lambda _cmd: "/bin/echo")
|
||||
settings = TakopiSettings.model_validate({"transport": "telegram"})
|
||||
config_path = tmp_path / "takopi.toml"
|
||||
config_path.write_text('transport = "telegram"\n', encoding="utf-8")
|
||||
|
||||
spec = runtime_loader.build_runtime_spec(
|
||||
settings=settings,
|
||||
config_path=config_path,
|
||||
)
|
||||
|
||||
assert spec.router.default_engine == settings.default_engine
|
||||
runtime = spec.to_runtime(config_path=config_path)
|
||||
assert runtime.default_engine == settings.default_engine
|
||||
|
||||
|
||||
def test_resolve_default_engine_unknown(tmp_path: Path) -> None:
|
||||
settings = TakopiSettings.model_validate({"transport": "telegram"})
|
||||
with pytest.raises(ConfigError, match="Unknown default engine"):
|
||||
runtime_loader.resolve_default_engine(
|
||||
override="unknown",
|
||||
settings=settings,
|
||||
config_path=tmp_path / "takopi.toml",
|
||||
engine_ids=["codex"],
|
||||
)
|
||||
@@ -511,6 +511,7 @@ dependencies = [
|
||||
{ name = "structlog" },
|
||||
{ name = "sulguk" },
|
||||
{ name = "typer" },
|
||||
{ name = "watchfiles" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
@@ -535,6 +536,7 @@ requires-dist = [
|
||||
{ name = "structlog", specifier = ">=25.5.0" },
|
||||
{ name = "sulguk", specifier = ">=0.11.1" },
|
||||
{ name = "typer", specifier = ">=0.21.0" },
|
||||
{ name = "watchfiles", specifier = ">=0.21.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
@@ -607,6 +609,40 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchfiles"
|
||||
version = "1.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wcwidth"
|
||||
version = "0.2.14"
|
||||
|
||||
Reference in New Issue
Block a user