From d606833603310615a26adfa5e84f6cc4fc9d6423 Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Thu, 8 Jan 2026 11:20:10 +0400 Subject: [PATCH] feat: migrate config to pydantic-settings (#65) --- docs/architecture.md | 3 +- docs/developing.md | 13 +- docs/projects.md | 3 + pyproject.toml | 2 + readme.md | 5 + src/takopi/cli.py | 77 +++--- src/takopi/config.py | 2 + src/takopi/config_store.py | 27 ++ src/takopi/settings.py | 358 +++++++++++++++++++++++++++ src/takopi/telegram/config.py | 31 --- src/takopi/telegram/onboarding.py | 68 +++-- tests/test_config_store.py | 41 +++ tests/test_exec_bridge.py | 36 ++- tests/test_onboarding.py | 27 +- tests/test_onboarding_interactive.py | 52 +++- tests/test_projects_config.py | 33 ++- tests/test_settings.py | 188 ++++++++++++++ uv.lock | 102 ++++++++ 18 files changed, 937 insertions(+), 131 deletions(-) create mode 100644 src/takopi/config_store.py create mode 100644 src/takopi/settings.py delete mode 100644 src/takopi/telegram/config.py create mode 100644 tests/test_config_store.py create mode 100644 tests/test_settings.py diff --git a/docs/architecture.md b/docs/architecture.md index 9f6fca8..bb88862 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -274,7 +274,8 @@ flowchart LR subgraph toml_contents["takopi.toml"] direction TB - global["bot_token
chat_id
default_engine"] + global["transport
default_engine"] + telegram_cfg["[transports.telegram]
bot_token = ...
chat_id = ..."] claude_cfg["[claude]
model = ..."] codex_cfg["[codex]
model = ..."] projects_cfg["[projects.alias]
path = ...
worktrees_dir = ...
default_engine = ..."] diff --git a/docs/developing.md b/docs/developing.md index 84a7c14..ce4cd93 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -229,11 +229,18 @@ Self-documenting msgspec schemas for decoding engine JSONL streams. class ConfigError(RuntimeError): ... ``` -### `telegram/config.py` - Configuration loading +### `settings.py` - Settings loading ```python -def load_telegram_config() -> tuple[dict, Path]: - # Loads ~/.takopi/takopi.toml +def load_settings(path: str | Path | None = None) -> tuple[TakopiSettings, Path]: + # Loads ~/.takopi/takopi.toml (TOML + env), validates via pydantic-settings +``` + +### `config_store.py` - Raw TOML read/write + +```python +def read_raw_toml(path: Path) -> dict: + # Loads TOML for merge/update without clobbering extra sections ``` ### `logging.py` - Secure logging setup diff --git a/docs/projects.md b/docs/projects.md index bfe2473..6517ff8 100644 --- a/docs/projects.md +++ b/docs/projects.md @@ -20,6 +20,9 @@ All config lives in `~/.takopi/takopi.toml`. ```toml default_engine = "codex" # optional default_project = "z80" # optional +transport = "telegram" # required + +[transports.telegram] bot_token = "..." # required chat_id = 123 # required diff --git a/pyproject.toml b/pyproject.toml index ffff740..40b1049 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,8 @@ dependencies = [ "httpx>=0.28.1", "markdown-it-py", "msgspec>=0.20.0", + "pydantic>=2.12.5", + "pydantic-settings>=2.12.0", "questionary>=2.1.1", "rich>=14.2.0", "structlog>=25.5.0", diff --git a/readme.md b/readme.md index 2ae466f..cd76a2b 100644 --- a/readme.md +++ b/readme.md @@ -53,6 +53,9 @@ global config `~/.takopi/takopi.toml` ```toml default_engine = "codex" +transport = "telegram" + +[transports.telegram] bot_token = "123456789:ABCdefGHIjklMNOpqrsTUVwxyz" chat_id = 123456789 @@ -78,6 +81,8 @@ provider = "openai" extra_args = ["--no-color"] ``` +note: configs with top-level `bot_token` / `chat_id` must be migrated to `[transports.telegram]`. + ## projects register the current repo as a project alias: diff --git a/src/takopi/cli.py b/src/takopi/cli.py index 127d349..b0f13f0 100644 --- a/src/takopi/cli.py +++ b/src/takopi/cli.py @@ -11,17 +11,19 @@ import typer from . import __version__ from .backends import EngineBackend -from .config import ( - ConfigError, - load_or_init_config, - parse_projects_config, - write_config, -) -from .engines import get_backend, get_engine_config, list_backends +from .config import ConfigError, load_or_init_config, write_config +from .engines import get_backend, list_backends from .lockfile import LockError, LockHandle, acquire_lock, token_fingerprint from .logging import get_logger, setup_logging from .router import AutoRouter, RunnerEntry from .runner_bridge import ExecBridgeConfig +from .settings import ( + TakopiSettings, + load_settings, + load_settings_if_exists, + require_telegram, + validate_settings_data, +) from .telegram.bridge import ( TelegramBridgeConfig, TelegramPresenter, @@ -29,7 +31,6 @@ from .telegram.bridge import ( run_main_loop, ) from .telegram.client import TelegramClient -from .telegram.config import load_telegram_config from .telegram.onboarding import SetupResult, check_setup, interactive_setup from .utils.git import resolve_default_base, resolve_main_worktree_root @@ -48,25 +49,10 @@ def _version_callback(value: bool) -> None: def load_and_validate_config( path: str | Path | None = None, -) -> tuple[dict, Path, str, int]: - config, config_path = load_telegram_config(path) - try: - token = config["bot_token"] - except KeyError: - raise ConfigError(f"Missing key `bot_token` in {config_path}.") from None - if not isinstance(token, str) or not token.strip(): - raise ConfigError( - f"Invalid `bot_token` in {config_path}; expected a non-empty string." - ) from None - try: - chat_id_value = config["chat_id"] - except KeyError: - raise ConfigError(f"Missing key `chat_id` in {config_path}.") from None - if isinstance(chat_id_value, bool) or not isinstance(chat_id_value, int): - raise ConfigError( - f"Invalid `chat_id` in {config_path}; expected an integer." - ) from None - return config, config_path, token.strip(), chat_id_value +) -> tuple[TakopiSettings, Path, str, int]: + settings, config_path = load_settings(path) + token, chat_id = require_telegram(settings, config_path) + return settings, config_path, token, chat_id def acquire_config_lock(config_path: Path, token: str) -> LockHandle: @@ -89,13 +75,11 @@ def acquire_config_lock(config_path: Path, token: str) -> LockHandle: def _default_engine_for_setup(override: str | None) -> str: if override: return override - try: - config, config_path = load_telegram_config() - except ConfigError: - return "codex" - value = config.get("default_engine") - if value is None: + loaded = load_settings_if_exists() + if loaded is None: return "codex" + settings, config_path = loaded + value = settings.default_engine if not isinstance(value, str) or not value.strip(): raise ConfigError( f"Invalid `default_engine` in {config_path}; expected a non-empty string." @@ -106,11 +90,11 @@ def _default_engine_for_setup(override: str | None) -> str: def _resolve_default_engine( *, override: str | None, - config: dict, + settings: TakopiSettings, config_path: Path, backends: list[EngineBackend], ) -> str: - default_engine = override or config.get("default_engine") or "codex" + 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." @@ -127,7 +111,7 @@ def _resolve_default_engine( def _build_router( *, - config: dict, + settings: TakopiSettings, config_path: Path, backends: list[EngineBackend], default_engine: str, @@ -140,7 +124,7 @@ def _build_router( issue: str | None = None engine_cfg: dict try: - engine_cfg = get_engine_config(config, engine_id, config_path) + engine_cfg = settings.engine_config(engine_id, config_path=config_path) except ConfigError as exc: if engine_id == default_engine: raise @@ -193,7 +177,7 @@ def _parse_bridge_config( *, final_notify: bool, default_engine_override: str | None, - config: dict, + settings: TakopiSettings, config_path: Path, token: str, chat_id: int, @@ -201,20 +185,19 @@ def _parse_bridge_config( startup_pwd = os.getcwd() backends = list_backends() - projects = parse_projects_config( - config, + projects = settings.to_projects_config( config_path=config_path, engine_ids=[backend.id for backend in backends], reserved=("cancel",), ) default_engine = _resolve_default_engine( override=default_engine_override, - config=config, + settings=settings, config_path=config_path, backends=backends, ) router = _build_router( - config=config, + settings=settings, config_path=config_path, backends=backends, default_engine=default_engine, @@ -317,12 +300,12 @@ def _run_auto_router( typer.echo(f"error: {first.title}", err=True) raise typer.Exit(code=1) try: - config, config_path, token, chat_id = load_and_validate_config() + settings, config_path, token, chat_id = load_and_validate_config() lock_handle = acquire_config_lock(config_path, token) cfg = _parse_bridge_config( final_notify=final_notify, default_engine_override=default_engine_override, - config=config, + settings=settings, config_path=config_path, token=token, chat_id=chat_id, @@ -391,8 +374,8 @@ def init( alias = _prompt_alias(alias, default_alias=default_alias) engine_ids = [backend.id for backend in list_backends()] - projects_cfg = parse_projects_config( - config, + settings = validate_settings_data(config, config_path=config_path) + projects_cfg = settings.to_projects_config( config_path=config_path, engine_ids=engine_ids, reserved=("cancel",), @@ -421,7 +404,7 @@ def init( if existing is not None and existing.alias in projects: projects.pop(existing.alias, None) - default_engine = _default_engine_for_setup(None) + default_engine = settings.default_engine worktree_base = resolve_default_base(project_path) entry: dict[str, object] = { diff --git a/src/takopi/config.py b/src/takopi/config.py index 5c09b83..ee36f13 100644 --- a/src/takopi/config.py +++ b/src/takopi/config.py @@ -204,6 +204,8 @@ def _format_toml_value(value: Any) -> str: return str(value) if isinstance(value, float): return repr(value) + if isinstance(value, Path): + return f'"{_toml_escape(str(value))}"' if isinstance(value, str): return f'"{_toml_escape(value)}"' if isinstance(value, (list, tuple)): diff --git a/src/takopi/config_store.py b/src/takopi/config_store.py new file mode 100644 index 0000000..4c48a7f --- /dev/null +++ b/src/takopi/config_store.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import tomllib +from pathlib import Path +from typing import Any + +from .config import ConfigError, dump_toml + + +def read_raw_toml(path: Path) -> dict[str, Any]: + if path.exists() and not path.is_file(): + raise ConfigError(f"Config path {path} exists but is not a file.") from None + try: + raw = path.read_text(encoding="utf-8") + except FileNotFoundError: + raise ConfigError(f"Missing config file {path}.") from None + except OSError as exc: + raise ConfigError(f"Failed to read config file {path}: {exc}") from exc + try: + return tomllib.loads(raw) + except tomllib.TOMLDecodeError as exc: + raise ConfigError(f"Malformed TOML in {path}: {exc}") from None + + +def write_raw_toml(config: dict[str, Any], path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(dump_toml(config), encoding="utf-8") diff --git a/src/takopi/settings.py b/src/takopi/settings.py new file mode 100644 index 0000000..e86e667 --- /dev/null +++ b/src/takopi/settings.py @@ -0,0 +1,358 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, Iterable + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + SecretStr, + ValidationError, + field_serializer, + field_validator, + model_validator, +) +from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings.sources import TomlConfigSettingsSource + +from .config import ConfigError, ProjectConfig, ProjectsConfig, HOME_CONFIG_PATH + + +class TelegramTransportSettings(BaseModel): + model_config = ConfigDict(extra="forbid") + + bot_token: SecretStr | None = None + chat_id: int | None = None + + @field_validator("bot_token", mode="before") + @classmethod + def _validate_bot_token(cls, value: Any) -> Any: + if value is None: + return None + if not isinstance(value, str): + raise ValueError("bot_token must be a string") + return value + + @field_validator("chat_id", mode="before") + @classmethod + def _validate_chat_id(cls, value: Any) -> Any: + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError("chat_id must be an integer") + return value + + @field_serializer("bot_token") + def _dump_token(self, value: SecretStr | None) -> str | None: + return value.get_secret_value() if value else None + + +class TransportsSettings(BaseModel): + telegram: TelegramTransportSettings = Field( + default_factory=TelegramTransportSettings + ) + + model_config = ConfigDict(extra="allow") + + +class PluginsSettings(BaseModel): + enabled: list[str] = Field(default_factory=list) + auto_install: bool = False + + model_config = ConfigDict(extra="allow") + + +class ProjectSettings(BaseModel): + path: str + worktrees_dir: str = ".worktrees" + default_engine: str | None = None + worktree_base: str | None = None + + model_config = ConfigDict(extra="allow") + + @field_validator( + "path", + "worktrees_dir", + "default_engine", + "worktree_base", + mode="before", + ) + @classmethod + def _validate_strings(cls, value: Any, info) -> Any: + if value is None: + return None + if not isinstance(value, str): + raise ValueError(f"{info.field_name} must be a string") + cleaned = value.strip() + if not cleaned: + raise ValueError(f"{info.field_name} must be a non-empty string") + return cleaned + + +class TakopiSettings(BaseSettings): + model_config = SettingsConfigDict( + extra="allow", + env_prefix="TAKOPI__", + env_nested_delimiter="__", + ) + + default_engine: str = "codex" + default_project: str | None = None + projects: dict[str, ProjectSettings] = Field(default_factory=dict) + + transport: str = "telegram" + transports: TransportsSettings = Field(default_factory=TransportsSettings) + + plugins: PluginsSettings = Field(default_factory=PluginsSettings) + + @model_validator(mode="before") + @classmethod + def _reject_legacy_telegram_keys(cls, data: Any) -> Any: + if isinstance(data, dict) and ("bot_token" in data or "chat_id" in data): + raise ValueError( + "Move bot_token/chat_id under [transports.telegram] " + 'and set transport = "telegram".' + ) + return data + + @field_validator("default_engine", "transport", mode="before") + @classmethod + def _validate_required_strings(cls, value: Any, info) -> Any: + if value is None: + raise ValueError(f"{info.field_name} must be a non-empty string") + if not isinstance(value, str): + raise ValueError(f"{info.field_name} must be a string") + cleaned = value.strip() + if not cleaned: + raise ValueError(f"{info.field_name} must be a non-empty string") + return cleaned + + @field_validator("default_project", mode="before") + @classmethod + def _validate_default_project(cls, value: Any) -> Any: + if value is None: + return None + if not isinstance(value, str): + raise ValueError("default_project must be a string") + cleaned = value.strip() + if not cleaned: + raise ValueError("default_project must be a non-empty string") + return cleaned + + @classmethod + def settings_customise_sources( + cls, + settings_cls, + init_settings, + env_settings, + dotenv_settings, + file_secret_settings, + ): + return ( + init_settings, + env_settings, + dotenv_settings, + TomlConfigSettingsSource(settings_cls), + file_secret_settings, + ) + + def engine_config(self, engine_id: str, *, config_path: Path) -> dict[str, Any]: + extra = self.model_extra or {} + raw = extra.get(engine_id) + if raw is None: + return {} + if not isinstance(raw, dict): + raise ConfigError( + f"Invalid `{engine_id}` config in {config_path}; expected a table." + ) + return raw + + def to_projects_config( + self, + *, + config_path: Path, + engine_ids: Iterable[str], + reserved: Iterable[str] = ("cancel",), + ) -> ProjectsConfig: + default_project = self.default_project + + reserved_lower = {value.lower() for value in reserved} + engine_map = {engine.lower(): engine for engine in engine_ids} + projects: dict[str, ProjectConfig] = {} + + for raw_alias, entry in self.projects.items(): + if not isinstance(raw_alias, str) or not raw_alias.strip(): + raise ConfigError( + f"Invalid project alias in {config_path}; expected a non-empty string." + ) + alias = raw_alias.strip() + alias_key = alias.lower() + if alias_key in engine_map or alias_key in reserved_lower: + raise ConfigError( + f"Invalid project alias {alias!r} in {config_path}; " + "aliases must not match engine ids or reserved commands." + ) + if alias_key in projects: + raise ConfigError( + f"Duplicate project alias {alias!r} in {config_path}." + ) + + path_value = entry.path + if not isinstance(path_value, str) or not path_value.strip(): + raise ConfigError( + f"Missing `path` for project {alias!r} in {config_path}." + ) + path = _normalize_project_path(path_value.strip(), config_path=config_path) + + worktrees_dir_raw = entry.worktrees_dir + if not isinstance(worktrees_dir_raw, str) or not worktrees_dir_raw.strip(): + raise ConfigError( + f"Invalid `worktrees_dir` for project {alias!r} in {config_path}." + ) + worktrees_dir = Path(worktrees_dir_raw.strip()) + + default_engine_raw = entry.default_engine + default_engine = None + if default_engine_raw is not None: + if not isinstance(default_engine_raw, str): + raise ConfigError( + f"Invalid `projects.{alias}.default_engine` in {config_path}; " + "expected a string." + ) + default_engine = _normalize_engine_id( + default_engine_raw, + engine_ids=engine_ids, + config_path=config_path, + label=f"projects.{alias}.default_engine", + ) + + worktree_base_raw = entry.worktree_base + worktree_base = None + if worktree_base_raw is not None: + if ( + not isinstance(worktree_base_raw, str) + or not worktree_base_raw.strip() + ): + raise ConfigError( + f"Invalid `projects.{alias}.worktree_base` in {config_path}; " + "expected a string." + ) + worktree_base = worktree_base_raw.strip() + + projects[alias_key] = ProjectConfig( + alias=alias, + path=path, + worktrees_dir=worktrees_dir, + default_engine=default_engine, + worktree_base=worktree_base, + ) + + if default_project is not None: + default_key = default_project.lower() + if default_key not in projects: + raise ConfigError( + f"Invalid `default_project` {default_project!r} in {config_path}; " + "no matching project alias found." + ) + default_project = default_key + + return ProjectsConfig(projects=projects, default_project=default_project) + + +def load_settings(path: str | Path | None = None) -> tuple[TakopiSettings, Path]: + cfg_path = _resolve_config_path(path) + _ensure_config_file(cfg_path) + return _load_settings_from_path(cfg_path), cfg_path + + +def load_settings_if_exists( + path: str | Path | None = None, +) -> tuple[TakopiSettings, Path] | None: + cfg_path = _resolve_config_path(path) + if cfg_path.exists(): + if not cfg_path.is_file(): + raise ConfigError( + f"Config path {cfg_path} exists but is not a file." + ) from None + return _load_settings_from_path(cfg_path), cfg_path + return None + + +def validate_settings_data( + data: dict[str, Any], *, config_path: Path +) -> TakopiSettings: + try: + return TakopiSettings.model_validate(data) + except ValidationError as exc: + raise ConfigError(f"Invalid config in {config_path}: {exc}") from exc + + +def require_telegram(settings: TakopiSettings, config_path: Path) -> tuple[str, int]: + if settings.transport != "telegram": + raise ConfigError( + f"Unsupported transport {settings.transport!r} in {config_path} " + "(telegram only for now)." + ) + tg = settings.transports.telegram + if tg.bot_token is None or not tg.bot_token.get_secret_value().strip(): + raise ConfigError(f"Missing bot token in {config_path}.") + if tg.chat_id is None: + raise ConfigError(f"Missing chat_id in {config_path}.") + if isinstance(tg.chat_id, bool) or not isinstance(tg.chat_id, int): + raise ConfigError(f"Invalid `chat_id` in {config_path}; expected an integer.") + return tg.bot_token.get_secret_value().strip(), tg.chat_id + + +def _resolve_config_path(path: str | Path | None) -> Path: + return Path(path).expanduser() if path else HOME_CONFIG_PATH + + +def _ensure_config_file(cfg_path: Path) -> None: + if cfg_path.exists() and not cfg_path.is_file(): + raise ConfigError(f"Config path {cfg_path} exists but is not a file.") from None + if not cfg_path.exists(): + raise ConfigError(f"Missing config file {cfg_path}.") from None + + +def _load_settings_from_path(cfg_path: Path) -> TakopiSettings: + cfg = dict(TakopiSettings.model_config) + cfg["toml_file"] = cfg_path + Bound = type( + "TakopiSettingsBound", + (TakopiSettings,), + {"model_config": SettingsConfigDict(**cfg)}, + ) + try: + return Bound() + except ValidationError as exc: + raise ConfigError(f"Invalid config in {cfg_path}: {exc}") from exc + except Exception as exc: # pragma: no cover - safety net + raise ConfigError(f"Failed to load config {cfg_path}: {exc}") from exc + + +def _normalize_engine_id( + value: str, + *, + engine_ids: Iterable[str], + config_path: Path, + label: str, +) -> str: + engine_map = {engine.lower(): engine for engine in engine_ids} + cleaned = value.strip() + if not cleaned: + raise ConfigError(f"Invalid `{label}` in {config_path}; expected a string.") + engine = engine_map.get(cleaned.lower()) + if engine is None: + available = ", ".join(sorted(engine_map.values())) + raise ConfigError( + f"Unknown `{label}` {cleaned!r} in {config_path}. Available: {available}." + ) + return engine + + +def _normalize_project_path(value: str, *, config_path: Path) -> Path: + path = Path(value).expanduser() + if not path.is_absolute(): + path = config_path.parent / path + return path diff --git a/src/takopi/telegram/config.py b/src/takopi/telegram/config.py deleted file mode 100644 index b395e0e..0000000 --- a/src/takopi/telegram/config.py +++ /dev/null @@ -1,31 +0,0 @@ -from __future__ import annotations - -import tomllib -from pathlib import Path - -from ..config import ConfigError - -HOME_CONFIG_PATH = Path.home() / ".takopi" / "takopi.toml" - - -def _read_config(cfg_path: Path) -> dict: - try: - raw = cfg_path.read_text(encoding="utf-8") - except FileNotFoundError: - raise ConfigError(f"Missing config file {cfg_path}.") from None - except OSError as e: - raise ConfigError(f"Failed to read config file {cfg_path}: {e}") from e - try: - return tomllib.loads(raw) - except tomllib.TOMLDecodeError as e: - raise ConfigError(f"Malformed TOML in {cfg_path}: {e}") from None - - -def load_telegram_config(path: str | Path | None = None) -> tuple[dict, Path]: - if path: - cfg_path = Path(path).expanduser() - return _read_config(cfg_path), cfg_path - cfg_path = HOME_CONFIG_PATH - if cfg_path.exists() and not cfg_path.is_file(): - raise ConfigError(f"Config path {cfg_path} exists but is not a file.") from None - return _read_config(cfg_path), cfg_path diff --git a/src/takopi/telegram/onboarding.py b/src/takopi/telegram/onboarding.py index 8de6ad8..6ed7774 100644 --- a/src/takopi/telegram/onboarding.py +++ b/src/takopi/telegram/onboarding.py @@ -23,10 +23,11 @@ from rich.table import Table from ..backends import EngineBackend, SetupIssue from ..backends_helpers import install_issue from ..config import ConfigError +from ..config_store import read_raw_toml, write_raw_toml from ..engines import list_backends from ..logging import suppress_logs +from ..settings import HOME_CONFIG_PATH, load_settings, require_telegram from .client import TelegramClient, TelegramRetryAfter -from .config import HOME_CONFIG_PATH, load_telegram_config @dataclass(slots=True) @@ -83,29 +84,23 @@ def config_issue(path: Path) -> SetupIssue: def check_setup(backend: EngineBackend) -> SetupResult: issues: list[SetupIssue] = [] config_path = HOME_CONFIG_PATH - config: dict = {} cmd = backend.cli_cmd or backend.id backend_issues: list[SetupIssue] = [] if shutil.which(cmd) is None: backend_issues.append(install_issue(cmd, backend.install_cmd)) try: - config, config_path = load_telegram_config() + settings, config_path = load_settings() + try: + require_telegram(settings, config_path) + except ConfigError: + issues.append(config_issue(config_path)) except ConfigError: issues.extend(backend_issues) issues.append(config_issue(config_path)) return SetupResult(issues=issues, config_path=config_path) - token = config.get("bot_token") - chat_id = config.get("chat_id") - - missing_or_invalid_config = not (isinstance(token, str) and token.strip()) - missing_or_invalid_config |= type(chat_id) is not int - issues.extend(backend_issues) - if missing_or_invalid_config: - issues.append(config_issue(config_path)) - return SetupResult(issues=issues, config_path=config_path) @@ -125,11 +120,32 @@ def _render_config(token: str, chat_id: int, default_engine: str | None) -> str: if default_engine: lines.append(f'default_engine = "{_toml_escape(default_engine)}"') lines.append("") + lines.append('transport = "telegram"') + lines.append("") + lines.append("[transports.telegram]") lines.append(f'bot_token = "{_toml_escape(token)}"') lines.append(f"chat_id = {chat_id}") return "\n".join(lines) + "\n" +def _ensure_table( + config: dict[str, Any], + key: str, + *, + config_path: Path, + label: str | None = None, +) -> dict[str, Any]: + value = config.get(key) + if value is None: + table: dict[str, Any] = {} + config[key] = table + return table + if not isinstance(value, dict): + name = label or key + raise ConfigError(f"Invalid `{name}` in {config_path}; expected a table.") + return value + + async def _get_bot_info(token: str) -> dict[str, Any] | None: bot = TelegramClient(token) try: @@ -326,8 +342,6 @@ def interactive_setup(*, force: bool) -> bool: console = Console() config_path = HOME_CONFIG_PATH - suppress_logs = _suppress_logging() - if config_path.exists() and not force: console.print( f"config already exists at {_display_path(config_path)}. " @@ -337,13 +351,13 @@ def interactive_setup(*, force: bool) -> bool: if config_path.exists() and force: overwrite = _confirm( - f"overwrite existing config at {_display_path(config_path)}?", + f"update existing config at {_display_path(config_path)}?", default=False, ) if not overwrite: return False - with suppress_logs: + with _suppress_logging(): panel = Panel( "let's set up your telegram bot.", title="welcome to takopi!", @@ -421,9 +435,25 @@ def interactive_setup(*, force: bool) -> bool: if not save: return False - config_path.parent.mkdir(parents=True, exist_ok=True) - config_text = _render_config(token, chat.chat_id, default_engine) - config_path.write_text(config_text, encoding="utf-8") + raw_config: dict[str, Any] = {} + if config_path.exists(): + raw_config = read_raw_toml(config_path) + merged = dict(raw_config) + if default_engine is not None: + merged["default_engine"] = default_engine + merged["transport"] = "telegram" + transports = _ensure_table(merged, "transports", config_path=config_path) + telegram = _ensure_table( + transports, + "telegram", + config_path=config_path, + label="transports.telegram", + ) + telegram["bot_token"] = token + telegram["chat_id"] = chat.chat_id + merged.pop("bot_token", None) + merged.pop("chat_id", None) + write_raw_toml(merged, config_path) console.print(f" config saved to {_display_path(config_path)}") done_panel = Panel( diff --git a/tests/test_config_store.py b/tests/test_config_store.py new file mode 100644 index 0000000..625a6b2 --- /dev/null +++ b/tests/test_config_store.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from takopi.config import ConfigError +from takopi.config_store import read_raw_toml, write_raw_toml + + +def test_read_write_raw_toml_round_trip(tmp_path: Path) -> None: + config_path = tmp_path / "takopi.toml" + payload = { + "default_engine": "codex", + "projects": {"z80": {"path": "/tmp/repo"}}, + } + + write_raw_toml(payload, config_path) + loaded = read_raw_toml(config_path) + + assert loaded == payload + + +def test_read_raw_toml_missing_file(tmp_path: Path) -> None: + config_path = tmp_path / "missing.toml" + with pytest.raises(ConfigError, match="Missing config file"): + read_raw_toml(config_path) + + +def test_read_raw_toml_invalid_toml(tmp_path: Path) -> None: + config_path = tmp_path / "takopi.toml" + config_path.write_text("nope = [", encoding="utf-8") + with pytest.raises(ConfigError, match="Malformed TOML"): + read_raw_toml(config_path) + + +def test_read_raw_toml_non_file(tmp_path: Path) -> None: + config_path = tmp_path / "config_dir" + config_path.mkdir() + with pytest.raises(ConfigError, match="exists but is not a file"): + read_raw_toml(config_path) diff --git a/tests/test_exec_bridge.py b/tests/test_exec_bridge.py index b36c476..7813408 100644 --- a/tests/test_exec_bridge.py +++ b/tests/test_exec_bridge.py @@ -15,18 +15,6 @@ from tests.factories import action_completed, action_started CODEX_ENGINE = EngineId("codex") -def _patch_config(monkeypatch, config): - from pathlib import Path - - from takopi import cli - - monkeypatch.setattr( - cli, - "load_telegram_config", - lambda *args, **kwargs: (config, Path("takopi.toml")), - ) - - class _FakeTransport: def __init__(self) -> None: self._next_id = 1 @@ -88,22 +76,32 @@ def _return_runner( ) -def test_load_and_validate_config_rejects_empty_token(monkeypatch) -> None: +def test_load_and_validate_config_rejects_empty_token(tmp_path) -> None: from takopi import cli - _patch_config(monkeypatch, {"bot_token": " ", "chat_id": 123}) + config_path = tmp_path / "takopi.toml" + config_path.write_text( + 'transport = "telegram"\n\n[transports.telegram]\n' + 'bot_token = " "\nchat_id = 123\n', + encoding="utf-8", + ) - with pytest.raises(cli.ConfigError, match="bot_token"): - cli.load_and_validate_config() + with pytest.raises(cli.ConfigError, match="bot token"): + cli.load_and_validate_config(config_path) -def test_load_and_validate_config_rejects_string_chat_id(monkeypatch) -> None: +def test_load_and_validate_config_rejects_string_chat_id(tmp_path) -> None: from takopi import cli - _patch_config(monkeypatch, {"bot_token": "token", "chat_id": "123"}) + config_path = tmp_path / "takopi.toml" + config_path.write_text( + 'transport = "telegram"\n\n[transports.telegram]\n' + 'bot_token = "token"\nchat_id = "123"\n', + encoding="utf-8", + ) with pytest.raises(cli.ConfigError, match="chat_id"): - cli.load_and_validate_config() + cli.load_and_validate_config(config_path) def test_codex_extract_resume_finds_command() -> None: diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py index 0458ad9..4beb000 100644 --- a/tests/test_onboarding.py +++ b/tests/test_onboarding.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path from takopi import engines +from takopi.settings import TakopiSettings from takopi.telegram import onboarding @@ -11,8 +12,16 @@ def test_check_setup_marks_missing_codex(monkeypatch, tmp_path: Path) -> None: monkeypatch.setattr(onboarding.shutil, "which", lambda _name: None) monkeypatch.setattr( onboarding, - "load_telegram_config", - lambda: ({"bot_token": "token", "chat_id": 123}, tmp_path / "takopi.toml"), + "load_settings", + lambda: ( + TakopiSettings.model_validate( + { + "transport": "telegram", + "transports": {"telegram": {"bot_token": "token", "chat_id": 123}}, + } + ), + tmp_path / "takopi.toml", + ), ) result = onboarding.check_setup(backend) @@ -30,7 +39,7 @@ def test_check_setup_marks_missing_config(monkeypatch) -> None: def _raise() -> None: raise onboarding.ConfigError("Missing config file") - monkeypatch.setattr(onboarding, "load_telegram_config", _raise) + monkeypatch.setattr(onboarding, "load_settings", _raise) result = onboarding.check_setup(backend) @@ -44,8 +53,16 @@ def test_check_setup_marks_invalid_chat_id(monkeypatch, tmp_path: Path) -> None: monkeypatch.setattr(onboarding.shutil, "which", lambda _name: "/usr/bin/codex") monkeypatch.setattr( onboarding, - "load_telegram_config", - lambda: ({"bot_token": "token", "chat_id": "123"}, tmp_path / "takopi.toml"), + "load_settings", + lambda: ( + TakopiSettings.model_validate( + { + "transport": "telegram", + "transports": {"telegram": {"bot_token": "token", "chat_id": None}}, + } + ), + tmp_path / "takopi.toml", + ), ) result = onboarding.check_setup(backend) diff --git a/tests/test_onboarding_interactive.py b/tests/test_onboarding_interactive.py index 7788500..bc883ce 100644 --- a/tests/test_onboarding_interactive.py +++ b/tests/test_onboarding_interactive.py @@ -23,6 +23,8 @@ def test_render_config_escapes() -> None: "codex", ) assert 'default_engine = "codex"' in config + assert 'transport = "telegram"' in config + assert "[transports.telegram]" in config assert 'bot_token = "token\\"with\\\\quote"' in config assert "chat_id = 123" in config assert config.endswith("\n") @@ -56,7 +58,11 @@ def _queue_values(values): def test_interactive_setup_skips_when_config_exists(monkeypatch, tmp_path) -> None: config_path = tmp_path / "takopi.toml" - config_path.write_text('bot_token = "token"\nchat_id = 123\n', encoding="utf-8") + config_path.write_text( + 'transport = "telegram"\n\n[transports.telegram]\n' + 'bot_token = "token"\nchat_id = 123\n', + encoding="utf-8", + ) monkeypatch.setattr(onboarding, "HOME_CONFIG_PATH", config_path) assert onboarding.interactive_setup(force=False) is True @@ -95,6 +101,50 @@ def test_interactive_setup_writes_config(monkeypatch, tmp_path) -> None: assert onboarding.interactive_setup(force=False) is True saved = config_path.read_text(encoding="utf-8") + assert 'transport = "telegram"' in saved + assert "[transports.telegram]" in saved assert 'bot_token = "123456789:ABCdef"' in saved assert "chat_id = 123" in saved assert 'default_engine = "codex"' in saved + + +def test_interactive_setup_preserves_projects(monkeypatch, tmp_path) -> None: + config_path = tmp_path / "takopi.toml" + config_path.write_text( + 'default_project = "z80"\n\n[projects.z80]\npath = "/tmp/repo"\n', + encoding="utf-8", + ) + monkeypatch.setattr(onboarding, "HOME_CONFIG_PATH", config_path) + + backend = EngineBackend(id="codex", build_runner=lambda _cfg, _path: None) + monkeypatch.setattr(onboarding, "list_backends", lambda: [backend]) + monkeypatch.setattr(onboarding.shutil, "which", lambda _cmd: "/usr/bin/codex") + + monkeypatch.setattr(onboarding, "_confirm", _queue_values([True, True, True])) + monkeypatch.setattr( + onboarding.questionary, "password", _queue(["123456789:ABCdef"]) + ) + monkeypatch.setattr(onboarding.questionary, "select", _queue(["codex"])) + + def _fake_run(func, *args, **kwargs): + if func is onboarding._get_bot_info: + return {"username": "my_bot"} + if func is onboarding._wait_for_chat: + return onboarding.ChatInfo( + chat_id=123, + username="alice", + title=None, + first_name="Alice", + last_name=None, + chat_type="private", + ) + if func is onboarding._send_confirmation: + return True + raise AssertionError(f"unexpected anyio.run target: {func}") + + monkeypatch.setattr(onboarding.anyio, "run", _fake_run) + + assert onboarding.interactive_setup(force=True) is True + saved = config_path.read_text(encoding="utf-8") + assert "[projects.z80]" in saved + assert 'path = "/tmp/repo"' in saved diff --git a/tests/test_projects_config.py b/tests/test_projects_config.py index fb78225..bb20121 100644 --- a/tests/test_projects_config.py +++ b/tests/test_projects_config.py @@ -4,14 +4,15 @@ import pytest from typer.testing import CliRunner from takopi import cli -from takopi.config import ConfigError, parse_projects_config +from takopi.config import ConfigError +from takopi.settings import TakopiSettings def test_parse_projects_rejects_engine_alias() -> None: config = {"projects": {"codex": {"path": "/tmp/repo"}}} with pytest.raises(ConfigError, match="aliases must not match engine ids"): - parse_projects_config( - config, + settings = TakopiSettings.model_validate(config) + settings.to_projects_config( config_path=Path("takopi.toml"), engine_ids=["codex"], reserved=("cancel",), @@ -21,8 +22,8 @@ def test_parse_projects_rejects_engine_alias() -> None: def test_parse_projects_default_project_must_exist() -> None: config = {"default_project": "z80", "projects": {}} with pytest.raises(ConfigError, match="default_project"): - parse_projects_config( - config, + settings = TakopiSettings.model_validate(config) + settings.to_projects_config( config_path=Path("takopi.toml"), engine_ids=["codex"], reserved=("cancel",), @@ -47,3 +48,25 @@ def test_init_writes_project(monkeypatch, tmp_path) -> None: assert 'worktrees_dir = ".worktrees"' in saved assert 'default_engine = "codex"' in saved assert 'worktree_base = "main"' in saved + + +def test_projects_default_engine_unknown() -> None: + config = {"projects": {"z80": {"path": "/tmp/repo", "default_engine": "nope"}}} + settings = TakopiSettings.model_validate(config) + with pytest.raises(ConfigError, match="projects.z80.default_engine"): + settings.to_projects_config( + config_path=Path("takopi.toml"), + engine_ids=["codex"], + reserved=("cancel",), + ) + + +def test_projects_relative_path_resolves(tmp_path: Path) -> None: + config_path = tmp_path / "takopi.toml" + settings = TakopiSettings.model_validate({"projects": {"z80": {"path": "repo"}}}) + projects = settings.to_projects_config( + config_path=config_path, + engine_ids=["codex"], + reserved=("cancel",), + ) + assert projects.projects["z80"].path == config_path.parent / "repo" diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..8648399 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from takopi.config import ConfigError +from takopi.settings import ( + TakopiSettings, + load_settings, + load_settings_if_exists, + require_telegram, + validate_settings_data, +) + + +def test_load_settings_from_toml(tmp_path: Path) -> None: + config_path = tmp_path / "takopi.toml" + config_path.write_text( + 'transport = "telegram"\n\n' + "[transports.telegram]\n" + 'bot_token = "token"\n' + "chat_id = 123\n\n" + "[codex]\n" + 'model = "gpt-4"\n', + encoding="utf-8", + ) + + settings, loaded_path = load_settings(config_path) + + assert loaded_path == config_path + assert settings.transport == "telegram" + assert settings.transports.telegram.chat_id == 123 + assert settings.engine_config("codex", config_path=config_path)["model"] == "gpt-4" + + token, chat_id = require_telegram(settings, config_path) + assert token == "token" + assert chat_id == 123 + + dumped = settings.model_dump() + assert dumped["transports"]["telegram"]["bot_token"] == "token" + + +def test_env_overrides_toml(tmp_path: Path, monkeypatch) -> None: + config_path = tmp_path / "takopi.toml" + config_path.write_text( + 'default_engine = "codex"\n' + 'transport = "telegram"\n\n' + "[transports.telegram]\n" + 'bot_token = "token"\n' + "chat_id = 123\n", + encoding="utf-8", + ) + monkeypatch.setenv("TAKOPI__DEFAULT_ENGINE", "claude") + + settings, _ = load_settings(config_path) + + assert settings.default_engine == "claude" + + +def test_legacy_keys_rejected(tmp_path: Path) -> None: + config_path = tmp_path / "takopi.toml" + config_path.write_text('bot_token = "token"\nchat_id = 123\n', encoding="utf-8") + + with pytest.raises(ConfigError, match="transports\\.telegram"): + load_settings(config_path) + + +def test_validate_settings_data_rejects_invalid_bot_token_type(tmp_path: Path) -> None: + config_path = tmp_path / "takopi.toml" + data = { + "transport": "telegram", + "transports": {"telegram": {"bot_token": 123, "chat_id": 123}}, + } + + with pytest.raises(ConfigError, match="bot_token"): + validate_settings_data(data, config_path=config_path) + + +def test_validate_settings_data_rejects_empty_default_engine(tmp_path: Path) -> None: + config_path = tmp_path / "takopi.toml" + data = { + "default_engine": " ", + "transport": "telegram", + "transports": {"telegram": {"bot_token": "token", "chat_id": 123}}, + } + + with pytest.raises(ConfigError, match="default_engine"): + validate_settings_data(data, config_path=config_path) + + +def test_validate_settings_data_rejects_empty_default_project(tmp_path: Path) -> None: + config_path = tmp_path / "takopi.toml" + data = {"default_project": " "} + + with pytest.raises(ConfigError, match="default_project"): + validate_settings_data(data, config_path=config_path) + + +def test_validate_settings_data_rejects_empty_project_path(tmp_path: Path) -> None: + config_path = tmp_path / "takopi.toml" + data = {"projects": {"z80": {"path": " "}}} + + with pytest.raises(ConfigError, match="path"): + validate_settings_data(data, config_path=config_path) + + +def test_engine_config_none_and_invalid(tmp_path: Path) -> None: + config_path = tmp_path / "takopi.toml" + settings = TakopiSettings.model_validate( + { + "transport": "telegram", + "transports": {"telegram": {"bot_token": "token", "chat_id": 123}}, + "codex": None, + } + ) + assert settings.engine_config("codex", config_path=config_path) == {} + + settings = TakopiSettings.model_validate( + { + "transport": "telegram", + "transports": {"telegram": {"bot_token": "token", "chat_id": 123}}, + "codex": "nope", + } + ) + with pytest.raises(ConfigError, match="codex"): + settings.engine_config("codex", config_path=config_path) + + +def test_bot_token_none_allowed() -> None: + settings = TakopiSettings.model_validate( + { + "transport": "telegram", + "transports": {"telegram": {"bot_token": None, "chat_id": 123}}, + } + ) + assert settings.transports.telegram.bot_token is None + + +def test_require_telegram_rejects_non_telegram_transport(tmp_path: Path) -> None: + config_path = tmp_path / "takopi.toml" + settings = TakopiSettings.model_validate( + { + "transport": "discord", + "transports": {"telegram": {"bot_token": "token", "chat_id": 123}}, + } + ) + with pytest.raises(ConfigError, match="Unsupported transport"): + require_telegram(settings, config_path) + + +def test_load_settings_if_exists_missing(tmp_path: Path) -> None: + config_path = tmp_path / "missing.toml" + assert load_settings_if_exists(config_path) is None + + +def test_load_settings_missing_file(tmp_path: Path) -> None: + config_path = tmp_path / "missing.toml" + with pytest.raises(ConfigError, match="Missing config file"): + load_settings(config_path) + + +def test_load_settings_if_exists_loads(tmp_path: Path) -> None: + config_path = tmp_path / "takopi.toml" + config_path.write_text( + 'transport = "telegram"\n\n[transports.telegram]\n' + 'bot_token = "token"\nchat_id = 123\n', + encoding="utf-8", + ) + + loaded = load_settings_if_exists(config_path) + assert loaded is not None + settings, loaded_path = loaded + assert loaded_path == config_path + + +def test_load_settings_if_exists_rejects_non_file(tmp_path: Path) -> None: + config_path = tmp_path / "config_dir" + config_path.mkdir() + with pytest.raises(ConfigError, match="exists but is not a file"): + load_settings_if_exists(config_path) + + +def test_load_settings_rejects_non_file(tmp_path: Path) -> None: + config_path = tmp_path / "config_dir" + config_path.mkdir() + with pytest.raises(ConfigError, match="exists but is not a file"): + load_settings(config_path) diff --git a/uv.lock b/uv.lock index 9a2e46b..7c20299 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.14" +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "anyio" version = "4.12.0" @@ -266,6 +275,74 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -318,6 +395,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + [[package]] name = "questionary" version = "2.1.1" @@ -418,6 +504,8 @@ dependencies = [ { name = "httpx" }, { name = "markdown-it-py" }, { name = "msgspec" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "questionary" }, { name = "rich" }, { name = "structlog" }, @@ -440,6 +528,8 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "markdown-it-py" }, { name = "msgspec", specifier = ">=0.20.0" }, + { name = "pydantic", specifier = ">=2.12.5" }, + { name = "pydantic-settings", specifier = ">=2.12.0" }, { name = "questionary", specifier = ">=2.1.1" }, { name = "rich", specifier = ">=14.2.0" }, { name = "structlog", specifier = ">=25.5.0" }, @@ -505,6 +595,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +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 = "wcwidth" version = "0.2.14"