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,