From f6afc4096dc6863c1fcd9ed1ed073ec5f7bd1725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BA=D0=BE=D1=81=20=D0=90=D1=80=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=20=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2=D0=B8?= =?UTF-8?q?=D1=87?= Date: Mon, 29 Jun 2026 12:01:32 +0700 Subject: [PATCH] feat(cli): dynamic default range, --version, --verbose, --debug, --url, --api-key, --config - #17: default date range falls back to current month when .env dates are missing - #18: add --version, --verbose, --debug flags - #20: add --url, --api-key, --config CLI overrides - Config supports CLI overrides for URL/API key and explicit config file loading - Update README with new CLI options - Add tests for new flags and config overrides Closes #17, closes #18, closes #20 --- README.md | 33 +++++++++++++++++-- redmine_reporter/cli.py | 30 +++++++++++++++-- redmine_reporter/config.py | 34 ++++++++++++++++++-- tests/test_cli.py | 66 ++++++++++++++++++++++++++++++++++++++ tests/test_config.py | 14 ++++++-- 5 files changed, 168 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 9ff6364..89919b1 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,9 @@ CLI-инструмент для генерации отчётов по зада - Вывод в консоль в табличном или компактном виде. - Экспорт в ODT, CSV, Markdown и HTML. - Автоматическое определение месяца ODT-отчёта по конечной дате периода. -- Настройка периода отчёта по умолчанию через `.env`. +- Настройка периода отчёта по умолчанию через `.env` (или автоматически — текущий месяц). +- Переопределение URL и API-ключа через CLI. +- Загрузка альтернативного `.env` через `--config`. ## Установка @@ -44,7 +46,7 @@ DEFAULT_FROM_DATE=2026-01-01 DEFAULT_TO_DATE=2026-01-31 ``` -Если задан `REDMINE_API_KEY`, он используется в первую очередь. +Если задан `REDMINE_API_KEY`, он используется в первую очередь. Значения из `.env` можно переопределить через CLI: `--url`, `--api-key`, `--author`, а также загрузить другой файл конфигурации через `--config`. Резервный вариант авторизации: @@ -86,7 +88,7 @@ DEFAULT_TO_DATE=2026-01-31 source .venv/bin/activate ``` -Отчёт за период по умолчанию: +Отчёт за период по умолчанию (текущий месяц или из `.env`): ```bash redmine-reporter @@ -100,12 +102,37 @@ redmine-reporter --date 2026-02-01--2026-02-28 Период должен быть задан в формате `YYYY-MM-DD--YYYY-MM-DD`. Начальная дата не может быть позже конечной. +Переопределение URL и API-ключа из `.env`: + +```bash +redmine-reporter --url https://red.example.com --api-key ваш_токен +``` + +Использование альтернативного конфигурационного файла: + +```bash +redmine-reporter --config /path/to/.env +``` + Компактный вывод: ```bash redmine-reporter --compact ``` +Подробный или отладочный вывод: + +```bash +redmine-reporter --verbose +redmine-reporter --debug +``` + +Вывод версии: + +```bash +redmine-reporter --version +``` + Экспорт: ```bash diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py index ed4bae6..1f0eacd 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -1,10 +1,12 @@ import argparse +import logging import os import re import sys from datetime import datetime from typing import List, Optional +from . import __version__ from .client import fetch_issues_with_spent_time from .config import Config from .formatters.factory import get_console_formatter, get_formatter_by_extension @@ -41,8 +43,7 @@ def main(argv: Optional[List[str]] = None) -> int: parser.add_argument( "--date", default=Config.get_default_date_range(), - # help="Date range in format YYYY-MM-DD--YYYY-MM-DD (default: %(default)s)" - help="Date range in format YYYY-MM-DD--YYYY-MM-DD (default from .env or %(default)s)", + help="Date range in format YYYY-MM-DD--YYYY-MM-DD (default: current month or from .env)", ) parser.add_argument( "--compact", @@ -59,8 +60,33 @@ def main(argv: Optional[List[str]] = None) -> int: parser.add_argument( "--no-time", action="store_true", help="Do not include spent time into table" ) + parser.add_argument("--url", help="Override Redmine URL from .env (REDMINE_URL)") + parser.add_argument("--api-key", help="Override Redmine API key from .env (REDMINE_API_KEY)") + parser.add_argument("--config", help="Path to .env config file") + parser.add_argument("--verbose", action="store_true", help="Enable verbose output") + parser.add_argument("--debug", action="store_true", help="Enable debug output") + parser.add_argument( + "--version", + action="version", + version=f"%(prog)s {__version__}", + help="Show version and exit", + ) args = parser.parse_args(argv) + # CLI-переопределения имеют приоритет над .env/env. + if args.config: + Config.load_config(args.config) + Config.set_redmine_url(args.url) + Config.set_redmine_api_key(args.api_key) + + # Настройка уровня логирования + if args.debug: + logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s") + elif args.verbose: + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + else: + logging.basicConfig(level=logging.WARNING, format="%(levelname)s: %(message)s") + try: Config.validate() except ValueError as e: diff --git a/redmine_reporter/config.py b/redmine_reporter/config.py index d156ace..45a54f9 100644 --- a/redmine_reporter/config.py +++ b/redmine_reporter/config.py @@ -1,4 +1,5 @@ import os +from datetime import date, timedelta from typing import Union from dotenv import load_dotenv @@ -11,12 +12,32 @@ TRUE_VALUES = {"1", "true", "yes", "on"} class Config: + _cli_url: str | None = None + _cli_api_key: str | None = None + + @classmethod + def load_config(cls, path: str) -> None: + """Загружает переменные из указанного .env-файла с override.""" + load_dotenv(path, override=True) + + @classmethod + def set_redmine_url(cls, url: str) -> None: + cls._cli_url = url.strip().rstrip("/") if url else None + + @classmethod + def set_redmine_api_key(cls, key: str) -> None: + cls._cli_api_key = key.strip() if key else None + @classmethod def get_redmine_url(cls) -> str: + if cls._cli_url is not None: + return cls._cli_url return os.getenv("REDMINE_URL", "").strip().rstrip("/") @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() @classmethod @@ -53,8 +74,17 @@ class Config: 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}" - # fallback hardcoded - return "2025-12-19--2026-01-31" + + # 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: + next_month = today.replace(month=today.month + 1, day=1) + end = next_month - timedelta(days=1) + return f"{start.isoformat()}--{end.isoformat()}" @classmethod def validate(cls) -> None: diff --git a/tests/test_cli.py b/tests/test_cli.py index 313ccb8..26ee8c6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -11,6 +11,18 @@ VALID_ENV = { } +@pytest.fixture(autouse=True) +def _reset_config_overrides(): + """Сбрасывает CLI-переопределения Config перед каждым тестом.""" + from redmine_reporter.config import Config + + Config.set_redmine_url("") + Config.set_redmine_api_key("") + yield + Config.set_redmine_url("") + Config.set_redmine_api_key("") + + @pytest.mark.parametrize( "date_arg, expected", [ @@ -93,6 +105,28 @@ def test_cli_odt_missing_odfpy_message(mock_gf, mock_fetch, tmp_path, capsys): assert "odfpy" in captured.err +@mock.patch.dict(os.environ, VALID_ENV, clear=True) +def test_cli_version_flag(capsys): + """--version выводит версию и завершается с 0.""" + with pytest.raises(SystemExit) as exc_info: + main(["--version"]) + assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert "redmine-reporter" in captured.out + assert "1.5.0" in captured.out + + +@mock.patch.dict(os.environ, VALID_ENV, clear=True) +@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") +def test_cli_verbose_and_debug_flags_accepted(mock_fetch): + """--verbose и --debug не ломают запуск.""" + mock_fetch.return_value = None + code = main(["--date", "2026-01-01--2026-01-31", "--verbose"]) + assert code == 0 + code = main(["--date", "2026-01-01--2026-01-31", "--debug"]) + assert code == 0 + + class _MockIssue: """Простой mock Redmine Issue для CLI-тестов.""" @@ -104,6 +138,38 @@ class _MockIssue: self.fixed_version = None +@mock.patch.dict(os.environ, {}, clear=True) +@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") +def test_cli_url_and_api_key_override_env(mock_fetch): + """--url и --api-key переопределяют отсутствующие/другие env-переменные.""" + mock_fetch.return_value = None + code = main( + [ + "--date", + "2026-01-01--2026-01-31", + "--url", + "https://other.redmine.loc", + "--api-key", + "cli-token", + ] + ) + assert code == 0 + + +@mock.patch.dict(os.environ, {}, clear=True) +@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") +def test_cli_config_file_loading(mock_fetch, tmp_path): + """--config загружает переменные из указанного .env-файла.""" + config_path = tmp_path / "custom.env" + config_path.write_text( + "REDMINE_URL=https://config.redmine.loc\n" "REDMINE_API_KEY=config-token\n", + encoding="utf-8", + ) + mock_fetch.return_value = None + code = main(["--date", "2026-01-01--2026-01-31", "--config", str(config_path)]) + assert code == 0 + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") def test_total_issues_message_goes_to_stderr(mock_fetch, capsys): diff --git a/tests/test_config.py b/tests/test_config.py index b7a54ca..84ec47a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -74,9 +74,19 @@ def test_get_default_date_range_from_env(): @mock.patch.dict(os.environ, {}, clear=True) def test_get_default_date_range_fallback(): - """Если даты не заданы -- используется хардкод-заглушка.""" + """Если даты не заданы -- используется текущий месяц.""" + from datetime import date, timedelta + + 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: + next_month = today.replace(month=today.month + 1, day=1) + end = next_month - timedelta(days=1) + result = Config.get_default_date_range() - assert "--" in result # формат YYYY-MM-DD--YYYY-MM-DD + assert result == f"{start.isoformat()}--{end.isoformat()}" @mock.patch.dict(os.environ, {}, clear=True)