feat: YAML config support (~/.config/redmine-reporter/config.yml)
- Add AppConfig/SmtpConfig/EmailConfig dataclasses with from_yaml()/from_env()
- Add yaml_config.py: ${VAR} resolver, 0700/0600 permission helpers
- Config.get_*() methods gain YAML fallback in priority chain
- Priority: CLI > env > .env > YAML > code defaults
- CLI --init-config generates YAML from current environment
- --force flag allows overwriting existing config
- Secrets default to ${VAR} references, plaintext allowed
- Full backward compatibility with existing .env setups
Closes #46
This commit is contained in:
@@ -1,19 +1,227 @@
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
import yaml
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from .yaml_config import check_file_permissions, resolve_env_vars
|
||||
|
||||
load_dotenv(override=False)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_REDMINE_VERIFY = "/etc/ssl/certs/ca-certificates.crt"
|
||||
FALSE_VALUES = {"0", "false", "no", "off"}
|
||||
TRUE_VALUES = {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SmtpConfig:
|
||||
host: str = ""
|
||||
port: int = 587
|
||||
user: str = ""
|
||||
password: str = ""
|
||||
tls: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmailConfig:
|
||||
smtp: SmtpConfig = field(default_factory=SmtpConfig)
|
||||
from_: str = ""
|
||||
to: list = field(default_factory=list)
|
||||
cc: list = field(default_factory=list)
|
||||
bcc: list = field(default_factory=list)
|
||||
subject: str = "Отчёт {author} за {period}"
|
||||
body_text: str = "Во вложении отчёт."
|
||||
attach: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppConfig:
|
||||
redmine_url: str = ""
|
||||
redmine_api_key: str = ""
|
||||
redmine_author: str = ""
|
||||
redmine_verify: Union[bool, str] = DEFAULT_REDMINE_VERIFY
|
||||
period_precision: str = "date"
|
||||
period_default_from: str = ""
|
||||
period_default_to: str = ""
|
||||
period_dynamic: bool = False
|
||||
output_dir: str = ""
|
||||
output_filename: str = "{author}_{from}_{to}.{ext}"
|
||||
output_default_format: str = "xlsx"
|
||||
email: EmailConfig = field(default_factory=EmailConfig)
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, path: Union[str, Path]) -> "AppConfig":
|
||||
"""Загружает настройки из YAML-файла.
|
||||
|
||||
Если файл не существует — возвращает конфиг со значениями по умолчанию.
|
||||
Неизвестные ключи верхнего уровня логируются с warning.
|
||||
"""
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
return cls()
|
||||
|
||||
# Проверяем права файла
|
||||
for warning in check_file_permissions(path):
|
||||
logger.warning(warning)
|
||||
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
raw = yaml.safe_load(fh) or {}
|
||||
|
||||
# Предупреждаем о неизвестных ключах верхнего уровня
|
||||
known_keys = {
|
||||
"redmine",
|
||||
"period",
|
||||
"output",
|
||||
"email",
|
||||
}
|
||||
for key in raw:
|
||||
if key not in known_keys:
|
||||
logger.warning("Unknown top-level key in config: %s", key)
|
||||
|
||||
return cls(
|
||||
redmine_url=cls._resolve_str(raw, "redmine", "url"),
|
||||
redmine_api_key=cls._resolve_str(raw, "redmine", "api_key"),
|
||||
redmine_author=cls._resolve_str(raw, "redmine", "author"),
|
||||
redmine_verify=cls._resolve_verify(raw),
|
||||
period_precision=cls._resolve_str(raw, "period", "precision") or "date",
|
||||
period_default_from=cls._resolve_str(raw, "period", "default_from"),
|
||||
period_default_to=cls._resolve_str(raw, "period", "default_to"),
|
||||
period_dynamic=cls._resolve_bool(raw, "period", "dynamic"),
|
||||
output_dir=cls._resolve_str(raw, "output", "dir"),
|
||||
output_filename=cls._resolve_str(raw, "output", "filename")
|
||||
or "{author}_{from}_{to}.{ext}",
|
||||
output_default_format=cls._resolve_str(raw, "output", "default_format") or "xlsx",
|
||||
email=cls._resolve_email(raw),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "AppConfig":
|
||||
"""Загружает настройки из переменных окружения."""
|
||||
return cls(
|
||||
redmine_url=os.getenv("REDMINE_URL", "").strip().rstrip("/"),
|
||||
redmine_api_key=os.getenv("REDMINE_API_KEY", "").strip(),
|
||||
redmine_author=os.getenv("REDMINE_AUTHOR", "").strip(),
|
||||
period_default_from=os.getenv("DEFAULT_FROM_DATE", "").strip(),
|
||||
period_default_to=os.getenv("DEFAULT_TO_DATE", "").strip(),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_str(raw: dict, section: str, key: str) -> str:
|
||||
value = raw.get(section, {}).get(key, "")
|
||||
if isinstance(value, str):
|
||||
return resolve_env_vars(value)
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_bool(raw: dict, section: str, key: str) -> bool:
|
||||
value = raw.get(section, {}).get(key)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
normalized = value.lower()
|
||||
if normalized in TRUE_VALUES:
|
||||
return True
|
||||
if normalized in FALSE_VALUES:
|
||||
return False
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _resolve_verify(cls, raw: dict) -> Union[bool, str]:
|
||||
value = raw.get("redmine", {}).get("verify_ssl")
|
||||
if value is None:
|
||||
return DEFAULT_REDMINE_VERIFY
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
normalized = value.lower()
|
||||
if normalized in FALSE_VALUES:
|
||||
return False
|
||||
if normalized in TRUE_VALUES:
|
||||
return True
|
||||
return resolve_env_vars(value)
|
||||
return DEFAULT_REDMINE_VERIFY
|
||||
|
||||
@classmethod
|
||||
def _resolve_email(cls, raw: dict) -> EmailConfig:
|
||||
email_raw = raw.get("email", {}) or {}
|
||||
smtp_raw = email_raw.get("smtp", {}) or {}
|
||||
|
||||
return EmailConfig(
|
||||
smtp=SmtpConfig(
|
||||
host=cls._safe_str(smtp_raw.get("host")),
|
||||
port=cls._safe_int(smtp_raw.get("port"), 587),
|
||||
user=cls._safe_str(smtp_raw.get("user")),
|
||||
password=resolve_env_vars(cls._safe_str(smtp_raw.get("password"))),
|
||||
tls=cls._safe_bool(smtp_raw.get("tls"), True),
|
||||
),
|
||||
from_=cls._safe_str(email_raw.get("from")),
|
||||
to=cls._safe_list(email_raw.get("to")),
|
||||
cc=cls._safe_list(email_raw.get("cc")),
|
||||
bcc=cls._safe_list(email_raw.get("bcc")),
|
||||
subject=resolve_env_vars(
|
||||
cls._safe_str(email_raw.get("subject")) or "Отчёт {author} за {period}"
|
||||
),
|
||||
body_text=resolve_env_vars(
|
||||
cls._safe_str(email_raw.get("body_text")) or "Во вложении отчёт."
|
||||
),
|
||||
attach=cls._safe_bool(email_raw.get("attach"), True),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _safe_str(value) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
@staticmethod
|
||||
def _safe_int(value, default: int = 0) -> int:
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _safe_bool(value, default: bool = False) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
normalized = value.lower()
|
||||
if normalized in TRUE_VALUES:
|
||||
return True
|
||||
if normalized in FALSE_VALUES:
|
||||
return False
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _safe_list(value) -> list:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return [str(v) for v in value]
|
||||
return [str(value)]
|
||||
|
||||
|
||||
class Config:
|
||||
_cli_url: str | None = None
|
||||
_cli_api_key: str | None = None
|
||||
_app: AppConfig | None = None
|
||||
|
||||
@classmethod
|
||||
def load_yaml(cls, path: str) -> None:
|
||||
"""Загружает YAML-конфиг. Не бросает исключений при отсутствии файла."""
|
||||
cls._app = AppConfig.from_yaml(path)
|
||||
|
||||
@classmethod
|
||||
def load_config(cls, path: str) -> None:
|
||||
@@ -32,13 +240,23 @@ class Config:
|
||||
def get_redmine_url(cls) -> str:
|
||||
if cls._cli_url is not None:
|
||||
return cls._cli_url
|
||||
return os.getenv("REDMINE_URL", "").strip().rstrip("/")
|
||||
env = os.getenv("REDMINE_URL", "").strip().rstrip("/")
|
||||
if env:
|
||||
return env
|
||||
if cls._app:
|
||||
return cls._app.redmine_url.strip().rstrip("/")
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def get_redmine_api_key(cls) -> str:
|
||||
if cls._cli_api_key is not None:
|
||||
return cls._cli_api_key
|
||||
return os.getenv("REDMINE_API_KEY", "").strip()
|
||||
env = os.getenv("REDMINE_API_KEY", "").strip()
|
||||
if env:
|
||||
return env
|
||||
if cls._app:
|
||||
return cls._app.redmine_api_key
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def get_redmine_user(cls) -> str:
|
||||
@@ -51,34 +269,41 @@ class Config:
|
||||
@classmethod
|
||||
def get_redmine_verify(cls) -> Union[bool, str]:
|
||||
value = os.getenv("REDMINE_VERIFY", "").strip()
|
||||
if not value:
|
||||
return DEFAULT_REDMINE_VERIFY
|
||||
|
||||
normalized = value.lower()
|
||||
if normalized in FALSE_VALUES:
|
||||
return False
|
||||
if normalized in TRUE_VALUES:
|
||||
return True
|
||||
return value
|
||||
if value:
|
||||
normalized = value.lower()
|
||||
if normalized in FALSE_VALUES:
|
||||
return False
|
||||
if normalized in TRUE_VALUES:
|
||||
return True
|
||||
return value
|
||||
if cls._app:
|
||||
return cls._app.redmine_verify
|
||||
return DEFAULT_REDMINE_VERIFY
|
||||
|
||||
@classmethod
|
||||
def get_author(cls, cli_author: str = "") -> str:
|
||||
"""Возвращает автора: из CLI если задан, иначе из .env, иначе — заглушку."""
|
||||
if cli_author:
|
||||
return cli_author
|
||||
return os.getenv("REDMINE_AUTHOR", "").strip()
|
||||
env = os.getenv("REDMINE_AUTHOR", "").strip()
|
||||
if env:
|
||||
return env
|
||||
if cls._app:
|
||||
return cls._app.redmine_author
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def get_default_date_range(cls) -> str:
|
||||
default_from_date = os.getenv("DEFAULT_FROM_DATE", "").strip()
|
||||
default_to_date = os.getenv("DEFAULT_TO_DATE", "").strip()
|
||||
if default_from_date and default_to_date:
|
||||
return f"{default_from_date}--{default_to_date}"
|
||||
from_env = os.getenv("DEFAULT_FROM_DATE", "").strip()
|
||||
to_env = os.getenv("DEFAULT_TO_DATE", "").strip()
|
||||
if from_env and to_env:
|
||||
return f"{from_env}--{to_env}"
|
||||
|
||||
if cls._app and cls._app.period_default_from and cls._app.period_default_to:
|
||||
return f"{cls._app.period_default_from}--{cls._app.period_default_to}"
|
||||
|
||||
# fallback: текущий месяц
|
||||
today = date.today()
|
||||
start = today.replace(day=1)
|
||||
# последний день месяца: берём первое число следующего месяца и вычитаем день
|
||||
if today.month == 12:
|
||||
next_month = today.replace(year=today.year + 1, month=1, day=1)
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user