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:
Кокос Артем Николаевич
2026-07-03 18:00:28 +07:00
parent 676f7ede30
commit c962a93f30
7 changed files with 815 additions and 22 deletions

View File

@@ -4,13 +4,17 @@ import os
import re
import sys
from datetime import datetime
from pathlib import Path
from typing import List, Optional
import yaml
from . import __version__
from .client import RedmineAPIError, fetch_issues_with_spent_time
from .config import Config
from .formatters.factory import get_console_formatter, get_formatter_by_extension
from .report_builder import build_grouped_report, calculate_summary
from .yaml_config import ensure_config_dir
def parse_date_range(date_arg: str) -> tuple[str, str]:
@@ -35,6 +39,66 @@ def parse_date_range(date_arg: str) -> tuple[str, str]:
return start.isoformat(), end.isoformat()
def _run_init_config(config_path: str, force: bool) -> int:
"""Создаёт YAML-конфиг из текущих настроек окружения."""
path = Path(config_path)
if path.exists() and not force:
print(
f"⚠️ {path} already exists.\n" f" Use --init-config --force to overwrite.",
file=sys.stderr,
)
return 1
# Собираем значения из окружения
data = {
"redmine": {
"url": os.getenv("REDMINE_URL", "").strip().rstrip("/"),
"api_key": ("${REDMINE_API_KEY}" if os.getenv("REDMINE_API_KEY") else ""),
"author": os.getenv("REDMINE_AUTHOR", "").strip(),
"verify_ssl": True,
},
"period": {
"precision": "date",
"default_from": os.getenv("DEFAULT_FROM_DATE", "").strip(),
"default_to": os.getenv("DEFAULT_TO_DATE", "").strip(),
"dynamic": False,
},
"output": {
"dir": "",
"filename": "{author}_{from}_{to}.{ext}",
"default_format": "xlsx",
},
"email": {
"smtp": {
"host": "",
"port": 587,
"user": "",
"password": ("${SMTP_PASSWORD}" if os.getenv("SMTP_PASSWORD") else ""),
"tls": True,
},
"from": "",
"to": [],
"cc": [],
"bcc": [],
"subject": "Отчёт {author} за {period}",
"body_text": "Во вложении отчёт.",
"attach": True,
},
}
ensure_config_dir(path.parent)
with open(path, "w", encoding="utf-8") as fh:
yaml.dump(data, fh, allow_unicode=True, default_flow_style=False, sort_keys=False)
path.chmod(0o600)
sections_found = [s for s in data if data[s]]
print(f"✅ Config written to {path}")
print(f" Sections: {', '.join(sections_found)}")
print(" Secrets stored as ${VAR} references where detected.")
return 0
def main(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser(
prog="redmine-reporter",
@@ -42,7 +106,7 @@ def main(argv: Optional[List[str]] = None) -> int:
)
parser.add_argument(
"--date",
default=Config.get_default_date_range(),
default=None,
help="Date range in format YYYY-MM-DD--YYYY-MM-DD (default: current month or from .env)",
)
parser.add_argument(
@@ -93,8 +157,45 @@ def main(argv: Optional[List[str]] = None) -> int:
action="store_true",
help="Break down spent time by activity type",
)
parser.add_argument(
"--init-config",
action="store_true",
help="Generate YAML config from current environment and exit",
)
parser.add_argument(
"--force",
action="store_true",
help="Allow overwriting existing config with --init-config",
)
parser.add_argument(
"--config-path",
default=str(Path.home() / ".config" / "redmine-reporter" / "config.yml"),
help="Path for --init-config output (default: ~/.config/redmine-reporter/config.yml)",
)
args = parser.parse_args(argv)
# --init-config: обработка до всего остального
if args.init_config:
# Проверка на взаимоисключающие флаги
report_flags = [
args.date is not None,
args.output,
args.compact,
args.summary,
args.user_id,
args.user_login,
args.user_name,
args.no_time,
args.by_activity,
]
if any(report_flags):
print(
"❌ --init-config cannot be used with report-generating flags.",
file=sys.stderr,
)
return 1
return _run_init_config(args.config_path, args.force)
# Валидация взаимоисключающих флагов пользователя
user_args = [args.user_id, args.user_login, args.user_name]
if sum(bool(a) for a in user_args) > 1:
@@ -124,8 +225,10 @@ def main(argv: Optional[List[str]] = None) -> int:
print(f"❌ Configuration error: {e}", file=sys.stderr)
return 1
# Если --date не указан, используем дефолтный диапазон
date_arg = args.date if args.date is not None else Config.get_default_date_range()
try:
from_date, to_date = parse_date_range(args.date)
from_date, to_date = parse_date_range(date_arg)
except ValueError as e:
print(f"❌ Date error: {e}", file=sys.stderr)
return 1
@@ -154,7 +257,7 @@ def main(argv: Optional[List[str]] = None) -> int:
print(" No time entries found in the given period.", file=sys.stderr)
return 0
print(f"✅ Total issues: {len(issue_hours)} [{args.date}]", file=sys.stderr)
print(f"✅ Total issues: {len(issue_hours)} [{date_arg}]", file=sys.stderr)
rows = build_grouped_report(
issue_hours,

View File

@@ -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:

View File

@@ -0,0 +1,58 @@
import logging
import os
import re
from pathlib import Path
logger = logging.getLogger(__name__)
_ENV_VAR_RE = re.compile(r"\$\{([^}]+)\}")
def resolve_env_vars(value: str) -> str:
"""Replace ${VAR} patterns with environment variable values.
Non-recursive: if ${A} expands to a literal ${B}, ${B} is NOT resolved.
Unknown variables are replaced with empty string and a warning is logged.
"""
if not isinstance(value, str):
return value
def _replacer(match: re.Match) -> str:
var_name = match.group(1)
env_value = os.environ.get(var_name)
if env_value is None:
logger.warning("Environment variable %s is not set, using empty string", var_name)
return ""
return env_value
return _ENV_VAR_RE.sub(_replacer, value)
def ensure_config_dir(path: Path | None = None) -> Path:
"""Create ~/.config/redmine-reporter/ with 0o700 permissions.
Returns the path to the config directory.
No-op if the directory already exists.
"""
if path is None:
path = Path.home() / ".config" / "redmine-reporter"
path.mkdir(mode=0o700, parents=True, exist_ok=True)
return path
def check_file_permissions(path: Path) -> list[str]:
"""Check that a config file has safe permissions (0o600 or stricter).
Returns a list of warning messages. Empty list means no issues.
"""
warnings: list[str] = []
if not path.exists():
return warnings
mode = path.stat().st_mode & 0o777
if mode > 0o600:
warnings.append(
f"Config file permissions are too open ({mode:04o}). "
f"Expected 0600. Fix with: chmod 600 {path}"
)
return warnings