Add src/selectel_ml_tui/config.py

This commit is contained in:
2026-09-02 16:42:17 +03:00
parent db895ab565
commit 2d3af443fe
+206
View File
@@ -0,0 +1,206 @@
"""Конфигурация приложения: настройки, пути, секреты.
Поддерживается чтение переменных окружения с префиксом SELCTEL_ и файла
конфигурации config.toml. Секреты (пароль сервисного пользователя,
статический токен) хранятся отдельно в файле credentials (права 0600).
"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
from typing import Any
from platformdirs import user_config_dir, user_data_dir
from pydantic import BaseModel, Field, ValidationError
from pydantic_settings import BaseSettings, SettingsConfigDict
APP_NAME = "selectel-ml-tui"
if sys.version_info >= (3, 11):
import tomllib
else: # pragma: no cover - Python 3.10 fallback
import tomli as tomllib
class Settings(BaseSettings):
"""Несекретные настройки приложения.
Источники: env (SELCTEL_*), затем config.toml.
"""
model_config = SettingsConfigDict(
env_prefix="SELCTEL_", env_file=".env", extra="ignore"
)
api_base_url: str = Field(
default="https://api.selectel.ru",
description="Базовый URL API Selectel",
)
identity_url: str = Field(
default="https://cloud.api.selcloud.ru/identity/v3",
description="URL сервиса IAM-авторизации",
)
refresh_interval: int = Field(
default=60,
ge=10,
description="Интервал автообновления TUI, секунд",
)
router_id: str = Field(
default="",
description="ID ИИ-роутера (если не указан, берётся первый из списка)",
)
region: str = Field(
default="",
description="Регион/пул (необязательно, для совместимости)",
)
class Credentials(BaseModel):
"""Учётные данные для доступа к API."""
service_user: str = ""
password: str = ""
project_id: str = ""
domain_id: str = ""
static_token: str = ""
def config_dir() -> Path:
"""Каталог конфигурации (Linux: ~/.config, macOS: ~/Library/Application Support)."""
return Path(user_config_dir(APP_NAME))
def data_dir() -> Path:
"""Каталог данных (Linux: ~/.local/share, macOS: ~/Library/Application Support)."""
return Path(user_data_dir(APP_NAME))
def _config_file() -> Path:
return config_dir() / "config.toml"
def _credentials_file() -> Path:
return config_dir() / "credentials.json"
def load_settings() -> Settings:
"""Загрузить настройки: env > config.toml > значения по умолчанию.
Дополнительные env-алиасы: SELCTEL_API_URL (для api_base_url).
Некорректные значения (например, refresh_interval < 10 из старого
конфига) не приводят к ошибке: поле приводится к допустимому значению.
"""
base = _settings_from_env()
cfg = _read_config_file()
if not cfg:
settings = base
else:
# Поля, заданные через env, имеют приоритет над файлом конфигурации.
env_fields = {
name: value
for name, value in base.model_dump().items()
if name in base.model_fields_set
}
settings = _validate_settings({**cfg, **env_fields})
api_url = os.environ.get("SELCTEL_API_URL")
if api_url:
settings = settings.model_copy(update={"api_base_url": api_url})
return settings
def _settings_from_env() -> Settings:
"""Настройки из переменных окружения (устойчиво к некорректным значениям)."""
try:
return Settings()
except ValidationError:
# Некорректное значение в env (например, SELCTEL_REFRESH_INTERVAL=5):
# model_construct() не читает env и не валидирует — чистые дефолты.
return Settings.model_construct()
def _validate_settings(data: dict[str, Any]) -> Settings:
"""Валидировать настройки; при ошибке починить и повторить."""
try:
return Settings.model_validate(data)
except ValidationError:
return Settings.model_validate(_sanitize_settings(data))
def _sanitize_settings(data: dict[str, Any]) -> dict[str, Any]:
"""Привести некорректные значения к допустимым (refresh_interval >= 10)."""
repaired = dict(data)
interval = repaired.get("refresh_interval")
if interval is not None:
try:
repaired["refresh_interval"] = max(10, int(interval))
except (TypeError, ValueError):
repaired["refresh_interval"] = 60
return repaired
def save_settings(settings: Settings) -> None:
"""Сохранить настройки в config.toml."""
path = _config_file()
path.parent.mkdir(parents=True, exist_ok=True)
_write_config_file(path, settings.model_dump(exclude_defaults=True))
def _read_config_file() -> dict[str, Any]:
path = _config_file()
if not path.exists():
return {}
with path.open("rb") as fh:
return dict(tomllib.load(fh))
def _write_config_file(path: Path, data: dict[str, Any]) -> None:
try:
import tomli_w
except ImportError: # pragma: no cover
# tomli_w не установлен — пишем JSON (совместимый фолбэк)
path.write_text(json.dumps(data, indent=2, ensure_ascii=False))
return
path.write_text(tomli_w.dumps(data))
def load_credentials() -> Credentials:
"""Загрузить секреты: env (SELCTEL_*) > файл credentials.json.
Env-алиасы: SELCTEL_TOKEN (static_token), SELCTEL_SERVICE_USER,
SELCTEL_PASSWORD, SELCTEL_PROJECT_ID, SELCTEL_DOMAIN_ID.
"""
path = _credentials_file()
if path.exists():
with path.open(encoding="utf-8") as fh:
data: dict[str, Any] = json.load(fh)
else:
data = {}
env_overrides = {
"static_token": os.environ.get("SELCTEL_TOKEN"),
"service_user": os.environ.get("SELCTEL_SERVICE_USER"),
"password": os.environ.get("SELCTEL_PASSWORD"),
"project_id": os.environ.get("SELCTEL_PROJECT_ID"),
"domain_id": os.environ.get("SELCTEL_DOMAIN_ID"),
}
for name, value in env_overrides.items():
if value is not None:
data[name] = value
return Credentials.model_validate(data)
def save_credentials(credentials: Credentials) -> None:
"""Сохранить секреты в файл credentials.json с правами 0600."""
path = _credentials_file()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(credentials.model_dump_json(indent=2))
_chmod_0600(path)
def _chmod_0600(path: Path) -> None:
if os.name == "posix": # pragma: no cover
path.chmod(0o600)