From 801d04cfdf8a19ed983a45177f607c45ce1e5385 Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Sat, 10 Jan 2026 02:41:05 +0400 Subject: [PATCH] feat(config): add hot-reload via watchfiles (#78) --- pyproject.toml | 1 + readme.md | 3 + src/takopi/config_watch.py | 128 ++++++++++++++++++++ src/takopi/runtime_loader.py | 203 ++++++++++++++++++++++++++++++++ src/takopi/settings.py | 1 + src/takopi/telegram/backend.py | 32 ++++- src/takopi/telegram/bridge.py | 113 +++++++++++++++--- src/takopi/telegram/client.py | 9 +- src/takopi/transport_runtime.py | 15 +++ tests/test_config_watch.py | 107 +++++++++++++++++ tests/test_runtime_loader.py | 36 ++++++ uv.lock | 36 ++++++ 12 files changed, 659 insertions(+), 25 deletions(-) create mode 100644 src/takopi/config_watch.py create mode 100644 src/takopi/runtime_loader.py create mode 100644 tests/test_config_watch.py create mode 100644 tests/test_runtime_loader.py diff --git a/pyproject.toml b/pyproject.toml index 2b86f15..740e830 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/readme.md b/readme.md index 957fd2c..5bae650 100644 --- a/readme.md +++ b/readme.md @@ -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 diff --git a/src/takopi/config_watch.py b/src/takopi/config_watch.py new file mode 100644 index 0000000..b992bc2 --- /dev/null +++ b/src/takopi/config_watch.py @@ -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 diff --git a/src/takopi/runtime_loader.py b/src/takopi/runtime_loader.py new file mode 100644 index 0000000..35f556c --- /dev/null +++ b/src/takopi/runtime_loader.py @@ -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, + ) diff --git a/src/takopi/settings.py b/src/takopi/settings.py index 74a4641..bfb2c17 100644 --- a/src/takopi/settings.py +++ b/src/takopi/settings.py @@ -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) diff --git a/src/takopi/telegram/backend.py b/src/takopi/telegram/backend.py index fa1a002..8688c45 100644 --- a/src/takopi/telegram/backend.py +++ b/src/takopi/telegram/backend.py @@ -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() diff --git a/src/takopi/telegram/bridge.py b/src/takopi/telegram/bridge.py index 7157f5e..b70e904 100644 --- a/src/takopi/telegram/bridge.py +++ b/src/takopi/telegram/bridge.py @@ -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, diff --git a/src/takopi/telegram/client.py b/src/takopi/telegram/client.py index 37e0a28..0dde566 100644 --- a/src/takopi/telegram/client.py +++ b/src/takopi/telegram/client.py @@ -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) diff --git a/src/takopi/transport_runtime.py b/src/takopi/transport_runtime.py index ac94e54..4b074b8 100644 --- a/src/takopi/transport_runtime.py +++ b/src/takopi/transport_runtime.py @@ -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 diff --git a/tests/test_config_watch.py b/tests/test_config_watch.py new file mode 100644 index 0000000..259a0fa --- /dev/null +++ b/tests/test_config_watch.py @@ -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" diff --git a/tests/test_runtime_loader.py b/tests/test_runtime_loader.py new file mode 100644 index 0000000..8722c03 --- /dev/null +++ b/tests/test_runtime_loader.py @@ -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"], + ) diff --git a/uv.lock b/uv.lock index edbf47c..727ef95 100644 --- a/uv.lock +++ b/uv.lock @@ -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"