feat: migrate config to pydantic-settings (#65)
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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] = {
|
||||||
|
|||||||
@@ -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)):
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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
|
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user