diff --git a/pyproject.toml b/pyproject.toml index 7f44df5..d2b778b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "python-dotenv>=1.0.0", "odfpy>=1.4.0", "openpyxl>=3.1.0", + "pyyaml>=6.0", ] [project.optional-dependencies] diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py index 21f8248..38b4ca4 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -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, diff --git a/redmine_reporter/config.py b/redmine_reporter/config.py index 45a54f9..0a40e77 100644 --- a/redmine_reporter/config.py +++ b/redmine_reporter/config.py @@ -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: diff --git a/redmine_reporter/yaml_config.py b/redmine_reporter/yaml_config.py new file mode 100644 index 0000000..42d35d8 --- /dev/null +++ b/redmine_reporter/yaml_config.py @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py index 2d942ff..1cfdac3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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"] == "" diff --git a/tests/test_config.py b/tests/test_config.py index 84ec47a..7eea661 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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" diff --git a/tests/test_yaml_config.py b/tests/test_yaml_config.py new file mode 100644 index 0000000..8c4f619 --- /dev/null +++ b/tests/test_yaml_config.py @@ -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 == []