feat: migrate config to pydantic-settings (#65)
This commit is contained in:
@@ -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")
|
||||
|
||||
|
||||
def _patch_config(monkeypatch, config):
|
||||
from pathlib import Path
|
||||
|
||||
from takopi import cli
|
||||
|
||||
monkeypatch.setattr(
|
||||
cli,
|
||||
"load_telegram_config",
|
||||
lambda *args, **kwargs: (config, Path("takopi.toml")),
|
||||
)
|
||||
|
||||
|
||||
class _FakeTransport:
|
||||
def __init__(self) -> None:
|
||||
self._next_id = 1
|
||||
@@ -88,22 +76,32 @@ def _return_runner(
|
||||
)
|
||||
|
||||
|
||||
def test_load_and_validate_config_rejects_empty_token(monkeypatch) -> None:
|
||||
def test_load_and_validate_config_rejects_empty_token(tmp_path) -> None:
|
||||
from takopi import cli
|
||||
|
||||
_patch_config(monkeypatch, {"bot_token": " ", "chat_id": 123})
|
||||
config_path = tmp_path / "takopi.toml"
|
||||
config_path.write_text(
|
||||
'transport = "telegram"\n\n[transports.telegram]\n'
|
||||
'bot_token = " "\nchat_id = 123\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(cli.ConfigError, match="bot_token"):
|
||||
cli.load_and_validate_config()
|
||||
with pytest.raises(cli.ConfigError, match="bot token"):
|
||||
cli.load_and_validate_config(config_path)
|
||||
|
||||
|
||||
def test_load_and_validate_config_rejects_string_chat_id(monkeypatch) -> None:
|
||||
def test_load_and_validate_config_rejects_string_chat_id(tmp_path) -> None:
|
||||
from takopi import cli
|
||||
|
||||
_patch_config(monkeypatch, {"bot_token": "token", "chat_id": "123"})
|
||||
config_path = tmp_path / "takopi.toml"
|
||||
config_path.write_text(
|
||||
'transport = "telegram"\n\n[transports.telegram]\n'
|
||||
'bot_token = "token"\nchat_id = "123"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(cli.ConfigError, match="chat_id"):
|
||||
cli.load_and_validate_config()
|
||||
cli.load_and_validate_config(config_path)
|
||||
|
||||
|
||||
def test_codex_extract_resume_finds_command() -> None:
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
from takopi import engines
|
||||
from takopi.settings import TakopiSettings
|
||||
from takopi.telegram import onboarding
|
||||
|
||||
|
||||
@@ -11,8 +12,16 @@ def test_check_setup_marks_missing_codex(monkeypatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setattr(onboarding.shutil, "which", lambda _name: None)
|
||||
monkeypatch.setattr(
|
||||
onboarding,
|
||||
"load_telegram_config",
|
||||
lambda: ({"bot_token": "token", "chat_id": 123}, tmp_path / "takopi.toml"),
|
||||
"load_settings",
|
||||
lambda: (
|
||||
TakopiSettings.model_validate(
|
||||
{
|
||||
"transport": "telegram",
|
||||
"transports": {"telegram": {"bot_token": "token", "chat_id": 123}},
|
||||
}
|
||||
),
|
||||
tmp_path / "takopi.toml",
|
||||
),
|
||||
)
|
||||
|
||||
result = onboarding.check_setup(backend)
|
||||
@@ -30,7 +39,7 @@ def test_check_setup_marks_missing_config(monkeypatch) -> None:
|
||||
def _raise() -> None:
|
||||
raise onboarding.ConfigError("Missing config file")
|
||||
|
||||
monkeypatch.setattr(onboarding, "load_telegram_config", _raise)
|
||||
monkeypatch.setattr(onboarding, "load_settings", _raise)
|
||||
|
||||
result = onboarding.check_setup(backend)
|
||||
|
||||
@@ -44,8 +53,16 @@ def test_check_setup_marks_invalid_chat_id(monkeypatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setattr(onboarding.shutil, "which", lambda _name: "/usr/bin/codex")
|
||||
monkeypatch.setattr(
|
||||
onboarding,
|
||||
"load_telegram_config",
|
||||
lambda: ({"bot_token": "token", "chat_id": "123"}, tmp_path / "takopi.toml"),
|
||||
"load_settings",
|
||||
lambda: (
|
||||
TakopiSettings.model_validate(
|
||||
{
|
||||
"transport": "telegram",
|
||||
"transports": {"telegram": {"bot_token": "token", "chat_id": None}},
|
||||
}
|
||||
),
|
||||
tmp_path / "takopi.toml",
|
||||
),
|
||||
)
|
||||
|
||||
result = onboarding.check_setup(backend)
|
||||
|
||||
@@ -23,6 +23,8 @@ def test_render_config_escapes() -> None:
|
||||
"codex",
|
||||
)
|
||||
assert 'default_engine = "codex"' in config
|
||||
assert 'transport = "telegram"' in config
|
||||
assert "[transports.telegram]" in config
|
||||
assert 'bot_token = "token\\"with\\\\quote"' in config
|
||||
assert "chat_id = 123" in config
|
||||
assert config.endswith("\n")
|
||||
@@ -56,7 +58,11 @@ def _queue_values(values):
|
||||
|
||||
def test_interactive_setup_skips_when_config_exists(monkeypatch, tmp_path) -> None:
|
||||
config_path = tmp_path / "takopi.toml"
|
||||
config_path.write_text('bot_token = "token"\nchat_id = 123\n', encoding="utf-8")
|
||||
config_path.write_text(
|
||||
'transport = "telegram"\n\n[transports.telegram]\n'
|
||||
'bot_token = "token"\nchat_id = 123\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(onboarding, "HOME_CONFIG_PATH", config_path)
|
||||
assert onboarding.interactive_setup(force=False) is True
|
||||
|
||||
@@ -95,6 +101,50 @@ def test_interactive_setup_writes_config(monkeypatch, tmp_path) -> None:
|
||||
|
||||
assert onboarding.interactive_setup(force=False) is True
|
||||
saved = config_path.read_text(encoding="utf-8")
|
||||
assert 'transport = "telegram"' in saved
|
||||
assert "[transports.telegram]" in saved
|
||||
assert 'bot_token = "123456789:ABCdef"' in saved
|
||||
assert "chat_id = 123" in saved
|
||||
assert 'default_engine = "codex"' in saved
|
||||
|
||||
|
||||
def test_interactive_setup_preserves_projects(monkeypatch, tmp_path) -> None:
|
||||
config_path = tmp_path / "takopi.toml"
|
||||
config_path.write_text(
|
||||
'default_project = "z80"\n\n[projects.z80]\npath = "/tmp/repo"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(onboarding, "HOME_CONFIG_PATH", config_path)
|
||||
|
||||
backend = EngineBackend(id="codex", build_runner=lambda _cfg, _path: None)
|
||||
monkeypatch.setattr(onboarding, "list_backends", lambda: [backend])
|
||||
monkeypatch.setattr(onboarding.shutil, "which", lambda _cmd: "/usr/bin/codex")
|
||||
|
||||
monkeypatch.setattr(onboarding, "_confirm", _queue_values([True, True, True]))
|
||||
monkeypatch.setattr(
|
||||
onboarding.questionary, "password", _queue(["123456789:ABCdef"])
|
||||
)
|
||||
monkeypatch.setattr(onboarding.questionary, "select", _queue(["codex"]))
|
||||
|
||||
def _fake_run(func, *args, **kwargs):
|
||||
if func is onboarding._get_bot_info:
|
||||
return {"username": "my_bot"}
|
||||
if func is onboarding._wait_for_chat:
|
||||
return onboarding.ChatInfo(
|
||||
chat_id=123,
|
||||
username="alice",
|
||||
title=None,
|
||||
first_name="Alice",
|
||||
last_name=None,
|
||||
chat_type="private",
|
||||
)
|
||||
if func is onboarding._send_confirmation:
|
||||
return True
|
||||
raise AssertionError(f"unexpected anyio.run target: {func}")
|
||||
|
||||
monkeypatch.setattr(onboarding.anyio, "run", _fake_run)
|
||||
|
||||
assert onboarding.interactive_setup(force=True) is True
|
||||
saved = config_path.read_text(encoding="utf-8")
|
||||
assert "[projects.z80]" in saved
|
||||
assert 'path = "/tmp/repo"' in saved
|
||||
|
||||
@@ -4,14 +4,15 @@ import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from takopi import cli
|
||||
from takopi.config import ConfigError, parse_projects_config
|
||||
from takopi.config import ConfigError
|
||||
from takopi.settings import TakopiSettings
|
||||
|
||||
|
||||
def test_parse_projects_rejects_engine_alias() -> None:
|
||||
config = {"projects": {"codex": {"path": "/tmp/repo"}}}
|
||||
with pytest.raises(ConfigError, match="aliases must not match engine ids"):
|
||||
parse_projects_config(
|
||||
config,
|
||||
settings = TakopiSettings.model_validate(config)
|
||||
settings.to_projects_config(
|
||||
config_path=Path("takopi.toml"),
|
||||
engine_ids=["codex"],
|
||||
reserved=("cancel",),
|
||||
@@ -21,8 +22,8 @@ def test_parse_projects_rejects_engine_alias() -> None:
|
||||
def test_parse_projects_default_project_must_exist() -> None:
|
||||
config = {"default_project": "z80", "projects": {}}
|
||||
with pytest.raises(ConfigError, match="default_project"):
|
||||
parse_projects_config(
|
||||
config,
|
||||
settings = TakopiSettings.model_validate(config)
|
||||
settings.to_projects_config(
|
||||
config_path=Path("takopi.toml"),
|
||||
engine_ids=["codex"],
|
||||
reserved=("cancel",),
|
||||
@@ -47,3 +48,25 @@ def test_init_writes_project(monkeypatch, tmp_path) -> None:
|
||||
assert 'worktrees_dir = ".worktrees"' in saved
|
||||
assert 'default_engine = "codex"' in saved
|
||||
assert 'worktree_base = "main"' in saved
|
||||
|
||||
|
||||
def test_projects_default_engine_unknown() -> None:
|
||||
config = {"projects": {"z80": {"path": "/tmp/repo", "default_engine": "nope"}}}
|
||||
settings = TakopiSettings.model_validate(config)
|
||||
with pytest.raises(ConfigError, match="projects.z80.default_engine"):
|
||||
settings.to_projects_config(
|
||||
config_path=Path("takopi.toml"),
|
||||
engine_ids=["codex"],
|
||||
reserved=("cancel",),
|
||||
)
|
||||
|
||||
|
||||
def test_projects_relative_path_resolves(tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "takopi.toml"
|
||||
settings = TakopiSettings.model_validate({"projects": {"z80": {"path": "repo"}}})
|
||||
projects = settings.to_projects_config(
|
||||
config_path=config_path,
|
||||
engine_ids=["codex"],
|
||||
reserved=("cancel",),
|
||||
)
|
||||
assert projects.projects["z80"].path == config_path.parent / "repo"
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user