feat(config): add hot-reload via watchfiles (#78)

This commit is contained in:
banteg
2026-01-10 02:41:05 +04:00
committed by GitHub
parent 910e7a6d98
commit 801d04cfdf
12 changed files with 659 additions and 25 deletions
+1
View File
@@ -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",
+3
View File
@@ -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
+128
View File
@@ -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
+203
View File
@@ -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,
)
+1
View File
@@ -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)
+27 -5
View File
@@ -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()
+97 -16
View File
@@ -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,
+5 -4
View File
@@ -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)
+15
View File
@@ -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
+107
View File
@@ -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"
+36
View File
@@ -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"],
)
Generated
+36
View File
@@ -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"