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:
@@ -24,6 +24,7 @@ dependencies = [
|
||||
"python-dotenv>=1.0.0",
|
||||
"odfpy>=1.4.0",
|
||||
"openpyxl>=3.1.0",
|
||||
"pyyaml>=6.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
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:
|
||||
|
||||
58
redmine_reporter/yaml_config.py
Normal file
58
redmine_reporter/yaml_config.py
Normal 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
|
||||
@@ -18,9 +18,11 @@ def _reset_config_overrides():
|
||||
|
||||
Config.set_redmine_url("")
|
||||
Config.set_redmine_api_key("")
|
||||
Config._app = None
|
||||
yield
|
||||
Config.set_redmine_url("")
|
||||
Config.set_redmine_api_key("")
|
||||
Config._app = None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -301,3 +303,93 @@ def test_cli_rejects_multiple_user_flags():
|
||||
]
|
||||
)
|
||||
assert code == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --init-config tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInitConfig:
|
||||
"""Tests for --init-config flag."""
|
||||
|
||||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||
def test_init_config_creates_yaml_file(self, tmp_path):
|
||||
"""--init-config создаёт YAML файл с правильной структурой."""
|
||||
import yaml
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
code = main(["--init-config", "--config-path", str(config_path)])
|
||||
assert code == 0
|
||||
assert config_path.exists()
|
||||
|
||||
with open(config_path) as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
|
||||
assert "redmine" in data
|
||||
assert data["redmine"]["url"] == "https://red.eltex.loc"
|
||||
assert data["redmine"]["api_key"] == "${REDMINE_API_KEY}"
|
||||
|
||||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||
def test_init_config_existing_file_no_force(self, tmp_path, capsys):
|
||||
"""Без --force существующий файл → предупреждение и выход 1."""
|
||||
config_path = tmp_path / "config.yml"
|
||||
config_path.write_text("existing: true")
|
||||
|
||||
code = main(["--init-config", "--config-path", str(config_path)])
|
||||
assert code == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "already exists" in captured.err or "already exists" in captured.out
|
||||
|
||||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||
def test_init_config_force_overwrites(self, tmp_path):
|
||||
"""--init-config --force перезаписывает существующий файл."""
|
||||
import yaml
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
config_path.write_text("existing: true")
|
||||
|
||||
code = main(["--init-config", "--force", "--config-path", str(config_path)])
|
||||
assert code == 0
|
||||
|
||||
with open(config_path) as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
|
||||
assert "redmine" in data
|
||||
assert "existing" not in data
|
||||
|
||||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||
def test_init_config_with_date_is_error(self, capsys):
|
||||
"""--init-config с --date → ошибка, взаимоисключающие."""
|
||||
code = main(["--init-config", "--date", "2026-01-01--2026-01-31"])
|
||||
assert code == 1
|
||||
|
||||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||
def test_init_config_writes_secrets_as_env_ref(self, tmp_path):
|
||||
"""Секреты пишутся как ${VAR} когда переменная окружения существует."""
|
||||
import yaml
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
code = main(["--init-config", "--config-path", str(config_path)])
|
||||
assert code == 0
|
||||
|
||||
with open(config_path) as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
|
||||
assert data["redmine"]["api_key"] == "${REDMINE_API_KEY}"
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_init_config_no_env_vars(self, tmp_path):
|
||||
"""Без переменных окружения --init-config создаёт скелет."""
|
||||
import yaml
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
code = main(["--init-config", "--config-path", str(config_path)])
|
||||
assert code == 0
|
||||
|
||||
with open(config_path) as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
|
||||
assert "redmine" in data
|
||||
assert data["redmine"]["url"] == ""
|
||||
assert data["redmine"]["api_key"] == ""
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from redmine_reporter.config import DEFAULT_REDMINE_VERIFY, Config
|
||||
from redmine_reporter.config import AppConfig, Config, DEFAULT_REDMINE_VERIFY
|
||||
|
||||
|
||||
@mock.patch.dict(
|
||||
@@ -133,3 +135,235 @@ def test_env_var_takes_priority_over_dotenv(mock_load):
|
||||
def test_get_redmine_password_strips_whitespace():
|
||||
"""Пароль обрезается от whitespace, как и все остальные геттеры."""
|
||||
assert Config.get_redmine_password() == "secret123"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AppConfig tests — YAML config loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
FULL_YAML = """\
|
||||
redmine:
|
||||
url: https://redmine.example.com/
|
||||
api_key: ${MISSING_API_KEY_FOR_TEST}
|
||||
author: "Тестовый Автор"
|
||||
verify_ssl: false
|
||||
|
||||
period:
|
||||
precision: datetime
|
||||
default_from: "2026-06-01"
|
||||
default_to: "2026-06-30"
|
||||
dynamic: true
|
||||
|
||||
output:
|
||||
dir: ~/reports
|
||||
filename: "{author}_{from}_{to}.{ext}"
|
||||
default_format: xlsx
|
||||
|
||||
email:
|
||||
smtp:
|
||||
host: smtp.example.com
|
||||
port: 587
|
||||
user: bot@example.com
|
||||
password: ${MISSING_SMTP_PASSWORD_FOR_TEST}
|
||||
tls: true
|
||||
from: bot@example.com
|
||||
to:
|
||||
- boss@example.com
|
||||
cc: []
|
||||
bcc: []
|
||||
subject: "Отчёт {author} за {period}"
|
||||
body_text: "Во вложении отчёт."
|
||||
attach: true
|
||||
"""
|
||||
|
||||
|
||||
class TestAppConfigFromYaml:
|
||||
"""Tests for AppConfig.from_yaml()."""
|
||||
|
||||
def test_loads_all_sections_from_valid_yaml(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text(FULL_YAML)
|
||||
|
||||
cfg = AppConfig.from_yaml(yaml_path)
|
||||
|
||||
assert cfg.redmine_url == "https://redmine.example.com/"
|
||||
assert cfg.redmine_api_key == "" # ${MISSING_API_KEY_FOR_TEST} not set
|
||||
assert cfg.redmine_author == "Тестовый Автор"
|
||||
assert cfg.redmine_verify is False
|
||||
assert cfg.period_precision == "datetime"
|
||||
assert cfg.period_default_from == "2026-06-01"
|
||||
assert cfg.period_default_to == "2026-06-30"
|
||||
assert cfg.period_dynamic is True
|
||||
assert cfg.output_dir == "~/reports"
|
||||
assert cfg.output_filename == "{author}_{from}_{to}.{ext}"
|
||||
assert cfg.output_default_format == "xlsx"
|
||||
assert cfg.email.smtp.host == "smtp.example.com"
|
||||
assert cfg.email.smtp.port == 587
|
||||
assert cfg.email.smtp.user == "bot@example.com"
|
||||
assert cfg.email.smtp.password == ""
|
||||
assert cfg.email.smtp.tls is True
|
||||
assert cfg.email.from_ == "bot@example.com"
|
||||
assert cfg.email.to == ["boss@example.com"]
|
||||
assert cfg.email.subject == "Отчёт {author} за {period}"
|
||||
|
||||
def test_partial_yaml_uses_defaults_for_missing(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text("redmine:\n url: https://x.com/\n")
|
||||
|
||||
cfg = AppConfig.from_yaml(yaml_path)
|
||||
|
||||
assert cfg.redmine_url == "https://x.com/"
|
||||
# defaults for everything else
|
||||
assert cfg.redmine_author == ""
|
||||
assert cfg.period_precision == "date"
|
||||
assert cfg.output_dir == ""
|
||||
assert cfg.email.to == []
|
||||
|
||||
def test_empty_file_returns_all_defaults(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text("")
|
||||
|
||||
cfg = AppConfig.from_yaml(yaml_path)
|
||||
|
||||
assert cfg.redmine_url == ""
|
||||
assert cfg.redmine_api_key == ""
|
||||
assert cfg.period_precision == "date"
|
||||
|
||||
def test_file_not_found_returns_all_defaults(self):
|
||||
cfg = AppConfig.from_yaml("/nonexistent/path/config.yml")
|
||||
assert cfg.redmine_url == ""
|
||||
|
||||
@mock.patch.dict(os.environ, {"REDMINE_API_KEY": "secret-token"}, clear=True)
|
||||
def test_env_var_resolved_in_yaml(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text("redmine:\n api_key: ${REDMINE_API_KEY}\n")
|
||||
|
||||
cfg = AppConfig.from_yaml(yaml_path)
|
||||
|
||||
assert cfg.redmine_api_key == "secret-token"
|
||||
|
||||
def test_missing_env_var_logs_warning(self, caplog):
|
||||
import logging
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text("redmine:\n api_key: ${MISSING_KEY}\n")
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
cfg = AppConfig.from_yaml(yaml_path)
|
||||
|
||||
assert cfg.redmine_api_key == ""
|
||||
assert "MISSING_KEY" in caplog.text
|
||||
|
||||
def test_unknown_top_level_key_logs_warning(self, caplog):
|
||||
import logging
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text("unknown_section:\n foo: bar\n")
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
AppConfig.from_yaml(yaml_path)
|
||||
|
||||
assert "unknown_section" in caplog.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config priority chain tests — YAML fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConfigYamlFallback:
|
||||
"""Tests that Config.get_*() falls back to YAML when env/CLI not set."""
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_url_from_yaml_when_no_env_or_cli(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text("redmine:\n url: https://yaml-redmine.example.com/\n")
|
||||
|
||||
Config._cli_url = None
|
||||
Config._app = AppConfig.from_yaml(yaml_path)
|
||||
|
||||
assert Config.get_redmine_url() == "https://yaml-redmine.example.com"
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_cli_beats_yaml(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text("redmine:\n url: https://yaml-redmine.example.com/\n")
|
||||
|
||||
Config._app = AppConfig.from_yaml(yaml_path)
|
||||
Config.set_redmine_url("https://cli-override.example.com")
|
||||
|
||||
assert Config.get_redmine_url() == "https://cli-override.example.com"
|
||||
|
||||
@mock.patch.dict(os.environ, {"REDMINE_URL": "https://env-redmine.example.com/"}, clear=True)
|
||||
def test_env_beats_yaml(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text("redmine:\n url: https://yaml-redmine.example.com/\n")
|
||||
|
||||
Config._cli_url = None
|
||||
Config._app = AppConfig.from_yaml(yaml_path)
|
||||
|
||||
assert Config.get_redmine_url() == "https://env-redmine.example.com"
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_date_range_from_yaml(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text(
|
||||
"period:\n default_from: '2026-03-01'\n default_to: '2026-03-15'\n"
|
||||
)
|
||||
|
||||
Config._app = AppConfig.from_yaml(yaml_path)
|
||||
|
||||
assert Config.get_default_date_range() == "2026-03-01--2026-03-15"
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_author_from_yaml(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text("redmine:\n author: 'Автор Из Ямла'\n")
|
||||
|
||||
Config._app = AppConfig.from_yaml(yaml_path)
|
||||
|
||||
assert Config.get_author("") == "Автор Из Ямла"
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_validate_passes_with_yaml_url_and_api_key(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text(
|
||||
"redmine:\n url: https://yaml.example.com/\n api_key: yaml-token\n"
|
||||
)
|
||||
|
||||
Config._cli_url = None
|
||||
Config._cli_api_key = None
|
||||
Config._app = AppConfig.from_yaml(yaml_path)
|
||||
|
||||
# Не должно быть исключения
|
||||
Config.validate()
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_default_date_range_env_beats_yaml(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text(
|
||||
"period:\n default_from: '2026-03-01'\n default_to: '2026-03-15'\n"
|
||||
)
|
||||
|
||||
Config._app = AppConfig.from_yaml(yaml_path)
|
||||
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{"DEFAULT_FROM_DATE": "2026-07-01", "DEFAULT_TO_DATE": "2026-07-15"},
|
||||
clear=True,
|
||||
):
|
||||
assert Config.get_default_date_range() == "2026-07-01--2026-07-15"
|
||||
|
||||
80
tests/test_yaml_config.py
Normal file
80
tests/test_yaml_config.py
Normal file
@@ -0,0 +1,80 @@
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
from redmine_reporter.yaml_config import (
|
||||
check_file_permissions,
|
||||
ensure_config_dir,
|
||||
resolve_env_vars,
|
||||
)
|
||||
|
||||
|
||||
class TestResolveEnvVars:
|
||||
"""Tests for ${VAR} resolution."""
|
||||
|
||||
@mock.patch.dict(os.environ, {"MY_VAR": "hello"}, clear=True)
|
||||
def test_replaces_var_with_env_value(self):
|
||||
assert resolve_env_vars("prefix_${MY_VAR}_suffix") == "prefix_hello_suffix"
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_missing_var_returns_empty_and_warns(self, caplog):
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = resolve_env_vars("${MISSING_VAR}")
|
||||
assert result == ""
|
||||
assert "MISSING_VAR" in caplog.text
|
||||
|
||||
def test_no_braces_returns_unchanged(self):
|
||||
assert resolve_env_vars("plain text no vars") == "plain text no vars"
|
||||
|
||||
@mock.patch.dict(os.environ, {"A": "${B}", "B": "final"}, clear=True)
|
||||
def test_non_recursive_no_double_resolution(self):
|
||||
# A → literal "${B}", B → "final". Non-recursive means we get "${B}", not "final".
|
||||
result = resolve_env_vars("${A}")
|
||||
assert result == "${B}"
|
||||
|
||||
|
||||
class TestEnsureConfigDir:
|
||||
"""Tests for config directory creation."""
|
||||
|
||||
def test_creates_dir_with_0700(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config_dir = Path(tmp) / "test_config"
|
||||
result = ensure_config_dir(config_dir)
|
||||
assert result == config_dir
|
||||
assert result.exists()
|
||||
assert result.is_dir()
|
||||
mode = result.stat().st_mode & 0o777
|
||||
assert mode == 0o700
|
||||
|
||||
def test_noop_if_dir_exists(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config_dir = Path(tmp) / "existing"
|
||||
config_dir.mkdir(mode=0o700)
|
||||
mtime_before = config_dir.stat().st_mtime
|
||||
result = ensure_config_dir(config_dir)
|
||||
assert result == config_dir
|
||||
assert config_dir.stat().st_mtime == mtime_before
|
||||
|
||||
|
||||
class TestCheckFilePermissions:
|
||||
"""Tests for config file permission checks."""
|
||||
|
||||
def test_warns_on_permissive_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
f = Path(tmp) / "open.yml"
|
||||
f.write_text("key: value")
|
||||
f.chmod(0o644)
|
||||
warnings = check_file_permissions(f)
|
||||
assert len(warnings) >= 1
|
||||
assert "0644" in warnings[0] or "permissions" in warnings[0].lower()
|
||||
|
||||
def test_silent_on_0600(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
f = Path(tmp) / "secure.yml"
|
||||
f.write_text("key: value")
|
||||
f.chmod(0o600)
|
||||
warnings = check_file_permissions(f)
|
||||
assert warnings == []
|
||||
Reference in New Issue
Block a user