feat: migrate config to pydantic-settings (#65)

This commit is contained in:
banteg
2026-01-08 11:20:10 +04:00
committed by GitHub
parent 26df1c8ac0
commit d606833603
18 changed files with 937 additions and 131 deletions
+2 -1
View File
@@ -274,7 +274,8 @@ flowchart LR
subgraph toml_contents["takopi.toml"] subgraph toml_contents["takopi.toml"]
direction TB direction TB
global["bot_token<br/>chat_id<br/>default_engine"] global["transport<br/>default_engine"]
telegram_cfg["[transports.telegram]<br/>bot_token = ...<br/>chat_id = ..."]
claude_cfg["[claude]<br/>model = ..."] claude_cfg["[claude]<br/>model = ..."]
codex_cfg["[codex]<br/>model = ..."] codex_cfg["[codex]<br/>model = ..."]
projects_cfg["[projects.alias]<br/>path = ...<br/>worktrees_dir = ...<br/>default_engine = ..."] projects_cfg["[projects.alias]<br/>path = ...<br/>worktrees_dir = ...<br/>default_engine = ..."]
+10 -3
View File
@@ -229,11 +229,18 @@ Self-documenting msgspec schemas for decoding engine JSONL streams.
class ConfigError(RuntimeError): ... class ConfigError(RuntimeError): ...
``` ```
### `telegram/config.py` - Configuration loading ### `settings.py` - Settings loading
```python ```python
def load_telegram_config() -> tuple[dict, Path]: def load_settings(path: str | Path | None = None) -> tuple[TakopiSettings, Path]:
# Loads ~/.takopi/takopi.toml # 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 ### `logging.py` - Secure logging setup
+3
View File
@@ -20,6 +20,9 @@ All config lives in `~/.takopi/takopi.toml`.
```toml ```toml
default_engine = "codex" # optional default_engine = "codex" # optional
default_project = "z80" # optional default_project = "z80" # optional
transport = "telegram" # required
[transports.telegram]
bot_token = "..." # required bot_token = "..." # required
chat_id = 123 # required chat_id = 123 # required
+2
View File
@@ -11,6 +11,8 @@ dependencies = [
"httpx>=0.28.1", "httpx>=0.28.1",
"markdown-it-py", "markdown-it-py",
"msgspec>=0.20.0", "msgspec>=0.20.0",
"pydantic>=2.12.5",
"pydantic-settings>=2.12.0",
"questionary>=2.1.1", "questionary>=2.1.1",
"rich>=14.2.0", "rich>=14.2.0",
"structlog>=25.5.0", "structlog>=25.5.0",
+5
View File
@@ -53,6 +53,9 @@ global config `~/.takopi/takopi.toml`
```toml ```toml
default_engine = "codex" default_engine = "codex"
transport = "telegram"
[transports.telegram]
bot_token = "123456789:ABCdefGHIjklMNOpqrsTUVwxyz" bot_token = "123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
chat_id = 123456789 chat_id = 123456789
@@ -78,6 +81,8 @@ provider = "openai"
extra_args = ["--no-color"] extra_args = ["--no-color"]
``` ```
note: configs with top-level `bot_token` / `chat_id` must be migrated to `[transports.telegram]`.
## projects ## projects
register the current repo as a project alias: register the current repo as a project alias:
+30 -47
View File
@@ -11,17 +11,19 @@ import typer
from . import __version__ from . import __version__
from .backends import EngineBackend from .backends import EngineBackend
from .config import ( from .config import ConfigError, load_or_init_config, write_config
ConfigError, from .engines import get_backend, list_backends
load_or_init_config,
parse_projects_config,
write_config,
)
from .engines import get_backend, get_engine_config, list_backends
from .lockfile import LockError, LockHandle, acquire_lock, token_fingerprint from .lockfile import LockError, LockHandle, acquire_lock, token_fingerprint
from .logging import get_logger, setup_logging from .logging import get_logger, setup_logging
from .router import AutoRouter, RunnerEntry from .router import AutoRouter, RunnerEntry
from .runner_bridge import ExecBridgeConfig from .runner_bridge import ExecBridgeConfig
from .settings import (
TakopiSettings,
load_settings,
load_settings_if_exists,
require_telegram,
validate_settings_data,
)
from .telegram.bridge import ( from .telegram.bridge import (
TelegramBridgeConfig, TelegramBridgeConfig,
TelegramPresenter, TelegramPresenter,
@@ -29,7 +31,6 @@ from .telegram.bridge import (
run_main_loop, run_main_loop,
) )
from .telegram.client import TelegramClient from .telegram.client import TelegramClient
from .telegram.config import load_telegram_config
from .telegram.onboarding import SetupResult, check_setup, interactive_setup from .telegram.onboarding import SetupResult, check_setup, interactive_setup
from .utils.git import resolve_default_base, resolve_main_worktree_root 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( def load_and_validate_config(
path: str | Path | None = None, path: str | Path | None = None,
) -> tuple[dict, Path, str, int]: ) -> tuple[TakopiSettings, Path, str, int]:
config, config_path = load_telegram_config(path) settings, config_path = load_settings(path)
try: token, chat_id = require_telegram(settings, config_path)
token = config["bot_token"] return settings, config_path, token, chat_id
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
def acquire_config_lock(config_path: Path, token: str) -> LockHandle: 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: def _default_engine_for_setup(override: str | None) -> str:
if override: if override:
return override return override
try: loaded = load_settings_if_exists()
config, config_path = load_telegram_config() if loaded is None:
except ConfigError:
return "codex"
value = config.get("default_engine")
if value is None:
return "codex" return "codex"
settings, config_path = loaded
value = settings.default_engine
if not isinstance(value, str) or not value.strip(): if not isinstance(value, str) or not value.strip():
raise ConfigError( raise ConfigError(
f"Invalid `default_engine` in {config_path}; expected a non-empty string." 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( def _resolve_default_engine(
*, *,
override: str | None, override: str | None,
config: dict, settings: TakopiSettings,
config_path: Path, config_path: Path,
backends: list[EngineBackend], backends: list[EngineBackend],
) -> str: ) -> 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(): if not isinstance(default_engine, str) or not default_engine.strip():
raise ConfigError( raise ConfigError(
f"Invalid `default_engine` in {config_path}; expected a non-empty string." f"Invalid `default_engine` in {config_path}; expected a non-empty string."
@@ -127,7 +111,7 @@ def _resolve_default_engine(
def _build_router( def _build_router(
*, *,
config: dict, settings: TakopiSettings,
config_path: Path, config_path: Path,
backends: list[EngineBackend], backends: list[EngineBackend],
default_engine: str, default_engine: str,
@@ -140,7 +124,7 @@ def _build_router(
issue: str | None = None issue: str | None = None
engine_cfg: dict engine_cfg: dict
try: 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: except ConfigError as exc:
if engine_id == default_engine: if engine_id == default_engine:
raise raise
@@ -193,7 +177,7 @@ def _parse_bridge_config(
*, *,
final_notify: bool, final_notify: bool,
default_engine_override: str | None, default_engine_override: str | None,
config: dict, settings: TakopiSettings,
config_path: Path, config_path: Path,
token: str, token: str,
chat_id: int, chat_id: int,
@@ -201,20 +185,19 @@ def _parse_bridge_config(
startup_pwd = os.getcwd() startup_pwd = os.getcwd()
backends = list_backends() backends = list_backends()
projects = parse_projects_config( projects = settings.to_projects_config(
config,
config_path=config_path, config_path=config_path,
engine_ids=[backend.id for backend in backends], engine_ids=[backend.id for backend in backends],
reserved=("cancel",), reserved=("cancel",),
) )
default_engine = _resolve_default_engine( default_engine = _resolve_default_engine(
override=default_engine_override, override=default_engine_override,
config=config, settings=settings,
config_path=config_path, config_path=config_path,
backends=backends, backends=backends,
) )
router = _build_router( router = _build_router(
config=config, settings=settings,
config_path=config_path, config_path=config_path,
backends=backends, backends=backends,
default_engine=default_engine, default_engine=default_engine,
@@ -317,12 +300,12 @@ def _run_auto_router(
typer.echo(f"error: {first.title}", err=True) typer.echo(f"error: {first.title}", err=True)
raise typer.Exit(code=1) raise typer.Exit(code=1)
try: 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) lock_handle = acquire_config_lock(config_path, token)
cfg = _parse_bridge_config( cfg = _parse_bridge_config(
final_notify=final_notify, final_notify=final_notify,
default_engine_override=default_engine_override, default_engine_override=default_engine_override,
config=config, settings=settings,
config_path=config_path, config_path=config_path,
token=token, token=token,
chat_id=chat_id, chat_id=chat_id,
@@ -391,8 +374,8 @@ def init(
alias = _prompt_alias(alias, default_alias=default_alias) alias = _prompt_alias(alias, default_alias=default_alias)
engine_ids = [backend.id for backend in list_backends()] engine_ids = [backend.id for backend in list_backends()]
projects_cfg = parse_projects_config( settings = validate_settings_data(config, config_path=config_path)
config, projects_cfg = settings.to_projects_config(
config_path=config_path, config_path=config_path,
engine_ids=engine_ids, engine_ids=engine_ids,
reserved=("cancel",), reserved=("cancel",),
@@ -421,7 +404,7 @@ def init(
if existing is not None and existing.alias in projects: if existing is not None and existing.alias in projects:
projects.pop(existing.alias, None) projects.pop(existing.alias, None)
default_engine = _default_engine_for_setup(None) default_engine = settings.default_engine
worktree_base = resolve_default_base(project_path) worktree_base = resolve_default_base(project_path)
entry: dict[str, object] = { entry: dict[str, object] = {
+2
View File
@@ -204,6 +204,8 @@ def _format_toml_value(value: Any) -> str:
return str(value) return str(value)
if isinstance(value, float): if isinstance(value, float):
return repr(value) return repr(value)
if isinstance(value, Path):
return f'"{_toml_escape(str(value))}"'
if isinstance(value, str): if isinstance(value, str):
return f'"{_toml_escape(value)}"' return f'"{_toml_escape(value)}"'
if isinstance(value, (list, tuple)): if isinstance(value, (list, tuple)):
+27
View File
@@ -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")
+358
View File
@@ -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
-31
View File
@@ -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
+49 -19
View File
@@ -23,10 +23,11 @@ from rich.table import Table
from ..backends import EngineBackend, SetupIssue from ..backends import EngineBackend, SetupIssue
from ..backends_helpers import install_issue from ..backends_helpers import install_issue
from ..config import ConfigError from ..config import ConfigError
from ..config_store import read_raw_toml, write_raw_toml
from ..engines import list_backends from ..engines import list_backends
from ..logging import suppress_logs from ..logging import suppress_logs
from ..settings import HOME_CONFIG_PATH, load_settings, require_telegram
from .client import TelegramClient, TelegramRetryAfter from .client import TelegramClient, TelegramRetryAfter
from .config import HOME_CONFIG_PATH, load_telegram_config
@dataclass(slots=True) @dataclass(slots=True)
@@ -83,29 +84,23 @@ def config_issue(path: Path) -> SetupIssue:
def check_setup(backend: EngineBackend) -> SetupResult: def check_setup(backend: EngineBackend) -> SetupResult:
issues: list[SetupIssue] = [] issues: list[SetupIssue] = []
config_path = HOME_CONFIG_PATH config_path = HOME_CONFIG_PATH
config: dict = {}
cmd = backend.cli_cmd or backend.id cmd = backend.cli_cmd or backend.id
backend_issues: list[SetupIssue] = [] backend_issues: list[SetupIssue] = []
if shutil.which(cmd) is None: if shutil.which(cmd) is None:
backend_issues.append(install_issue(cmd, backend.install_cmd)) backend_issues.append(install_issue(cmd, backend.install_cmd))
try: 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: except ConfigError:
issues.extend(backend_issues) issues.extend(backend_issues)
issues.append(config_issue(config_path)) issues.append(config_issue(config_path))
return SetupResult(issues=issues, config_path=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) issues.extend(backend_issues)
if missing_or_invalid_config:
issues.append(config_issue(config_path))
return SetupResult(issues=issues, config_path=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: if default_engine:
lines.append(f'default_engine = "{_toml_escape(default_engine)}"') lines.append(f'default_engine = "{_toml_escape(default_engine)}"')
lines.append("") lines.append("")
lines.append('transport = "telegram"')
lines.append("")
lines.append("[transports.telegram]")
lines.append(f'bot_token = "{_toml_escape(token)}"') lines.append(f'bot_token = "{_toml_escape(token)}"')
lines.append(f"chat_id = {chat_id}") lines.append(f"chat_id = {chat_id}")
return "\n".join(lines) + "\n" 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: async def _get_bot_info(token: str) -> dict[str, Any] | None:
bot = TelegramClient(token) bot = TelegramClient(token)
try: try:
@@ -326,8 +342,6 @@ def interactive_setup(*, force: bool) -> bool:
console = Console() console = Console()
config_path = HOME_CONFIG_PATH config_path = HOME_CONFIG_PATH
suppress_logs = _suppress_logging()
if config_path.exists() and not force: if config_path.exists() and not force:
console.print( console.print(
f"config already exists at {_display_path(config_path)}. " 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: if config_path.exists() and force:
overwrite = _confirm( overwrite = _confirm(
f"overwrite existing config at {_display_path(config_path)}?", f"update existing config at {_display_path(config_path)}?",
default=False, default=False,
) )
if not overwrite: if not overwrite:
return False return False
with suppress_logs: with _suppress_logging():
panel = Panel( panel = Panel(
"let's set up your telegram bot.", "let's set up your telegram bot.",
title="welcome to takopi!", title="welcome to takopi!",
@@ -421,9 +435,25 @@ def interactive_setup(*, force: bool) -> bool:
if not save: if not save:
return False return False
config_path.parent.mkdir(parents=True, exist_ok=True) raw_config: dict[str, Any] = {}
config_text = _render_config(token, chat.chat_id, default_engine) if config_path.exists():
config_path.write_text(config_text, encoding="utf-8") 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)}") console.print(f" config saved to {_display_path(config_path)}")
done_panel = Panel( done_panel = Panel(
+41
View File
@@ -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)
+17 -19
View File
@@ -15,18 +15,6 @@ from tests.factories import action_completed, action_started
CODEX_ENGINE = EngineId("codex") 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: class _FakeTransport:
def __init__(self) -> None: def __init__(self) -> None:
self._next_id = 1 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 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"): with pytest.raises(cli.ConfigError, match="bot token"):
cli.load_and_validate_config() 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 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"): 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: def test_codex_extract_resume_finds_command() -> None:
+22 -5
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from takopi import engines from takopi import engines
from takopi.settings import TakopiSettings
from takopi.telegram import onboarding 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.shutil, "which", lambda _name: None)
monkeypatch.setattr( monkeypatch.setattr(
onboarding, onboarding,
"load_telegram_config", "load_settings",
lambda: ({"bot_token": "token", "chat_id": 123}, tmp_path / "takopi.toml"), lambda: (
TakopiSettings.model_validate(
{
"transport": "telegram",
"transports": {"telegram": {"bot_token": "token", "chat_id": 123}},
}
),
tmp_path / "takopi.toml",
),
) )
result = onboarding.check_setup(backend) result = onboarding.check_setup(backend)
@@ -30,7 +39,7 @@ def test_check_setup_marks_missing_config(monkeypatch) -> None:
def _raise() -> None: def _raise() -> None:
raise onboarding.ConfigError("Missing config file") raise onboarding.ConfigError("Missing config file")
monkeypatch.setattr(onboarding, "load_telegram_config", _raise) monkeypatch.setattr(onboarding, "load_settings", _raise)
result = onboarding.check_setup(backend) 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.shutil, "which", lambda _name: "/usr/bin/codex")
monkeypatch.setattr( monkeypatch.setattr(
onboarding, onboarding,
"load_telegram_config", "load_settings",
lambda: ({"bot_token": "token", "chat_id": "123"}, tmp_path / "takopi.toml"), lambda: (
TakopiSettings.model_validate(
{
"transport": "telegram",
"transports": {"telegram": {"bot_token": "token", "chat_id": None}},
}
),
tmp_path / "takopi.toml",
),
) )
result = onboarding.check_setup(backend) result = onboarding.check_setup(backend)
+51 -1
View File
@@ -23,6 +23,8 @@ def test_render_config_escapes() -> None:
"codex", "codex",
) )
assert 'default_engine = "codex"' in config 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 'bot_token = "token\\"with\\\\quote"' in config
assert "chat_id = 123" in config assert "chat_id = 123" in config
assert config.endswith("\n") assert config.endswith("\n")
@@ -56,7 +58,11 @@ def _queue_values(values):
def test_interactive_setup_skips_when_config_exists(monkeypatch, tmp_path) -> None: def test_interactive_setup_skips_when_config_exists(monkeypatch, tmp_path) -> None:
config_path = tmp_path / "takopi.toml" 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) monkeypatch.setattr(onboarding, "HOME_CONFIG_PATH", config_path)
assert onboarding.interactive_setup(force=False) is True 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 assert onboarding.interactive_setup(force=False) is True
saved = config_path.read_text(encoding="utf-8") 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 'bot_token = "123456789:ABCdef"' in saved
assert "chat_id = 123" in saved assert "chat_id = 123" in saved
assert 'default_engine = "codex"' 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
+28 -5
View File
@@ -4,14 +4,15 @@ import pytest
from typer.testing import CliRunner from typer.testing import CliRunner
from takopi import cli 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: def test_parse_projects_rejects_engine_alias() -> None:
config = {"projects": {"codex": {"path": "/tmp/repo"}}} config = {"projects": {"codex": {"path": "/tmp/repo"}}}
with pytest.raises(ConfigError, match="aliases must not match engine ids"): with pytest.raises(ConfigError, match="aliases must not match engine ids"):
parse_projects_config( settings = TakopiSettings.model_validate(config)
config, settings.to_projects_config(
config_path=Path("takopi.toml"), config_path=Path("takopi.toml"),
engine_ids=["codex"], engine_ids=["codex"],
reserved=("cancel",), reserved=("cancel",),
@@ -21,8 +22,8 @@ def test_parse_projects_rejects_engine_alias() -> None:
def test_parse_projects_default_project_must_exist() -> None: def test_parse_projects_default_project_must_exist() -> None:
config = {"default_project": "z80", "projects": {}} config = {"default_project": "z80", "projects": {}}
with pytest.raises(ConfigError, match="default_project"): with pytest.raises(ConfigError, match="default_project"):
parse_projects_config( settings = TakopiSettings.model_validate(config)
config, settings.to_projects_config(
config_path=Path("takopi.toml"), config_path=Path("takopi.toml"),
engine_ids=["codex"], engine_ids=["codex"],
reserved=("cancel",), reserved=("cancel",),
@@ -47,3 +48,25 @@ def test_init_writes_project(monkeypatch, tmp_path) -> None:
assert 'worktrees_dir = ".worktrees"' in saved assert 'worktrees_dir = ".worktrees"' in saved
assert 'default_engine = "codex"' in saved assert 'default_engine = "codex"' in saved
assert 'worktree_base = "main"' 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"
+188
View File
@@ -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)
Generated
+102
View File
@@ -2,6 +2,15 @@ version = 1
revision = 3 revision = 3
requires-python = ">=3.14" 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]] [[package]]
name = "anyio" name = "anyio"
version = "4.12.0" 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" }, { 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]] [[package]]
name = "pygments" name = "pygments"
version = "2.19.2" 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" }, { 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]] [[package]]
name = "questionary" name = "questionary"
version = "2.1.1" version = "2.1.1"
@@ -418,6 +504,8 @@ dependencies = [
{ name = "httpx" }, { name = "httpx" },
{ name = "markdown-it-py" }, { name = "markdown-it-py" },
{ name = "msgspec" }, { name = "msgspec" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "questionary" }, { name = "questionary" },
{ name = "rich" }, { name = "rich" },
{ name = "structlog" }, { name = "structlog" },
@@ -440,6 +528,8 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", specifier = ">=0.28.1" },
{ name = "markdown-it-py" }, { name = "markdown-it-py" },
{ name = "msgspec", specifier = ">=0.20.0" }, { 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 = "questionary", specifier = ">=2.1.1" },
{ name = "rich", specifier = ">=14.2.0" }, { name = "rich", specifier = ">=14.2.0" },
{ name = "structlog", specifier = ">=25.5.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" }, { 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]] [[package]]
name = "wcwidth" name = "wcwidth"
version = "0.2.14" version = "0.2.14"