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
This commit is contained in:
Кокос Артем Николаевич
2026-06-29 12:01:32 +07:00
parent 738d9d543e
commit f6afc4096d
5 changed files with 168 additions and 9 deletions

View File

@@ -16,7 +16,9 @@ CLI-инструмент для генерации отчётов по зада
- Вывод в консоль в табличном или компактном виде. - Вывод в консоль в табличном или компактном виде.
- Экспорт в ODT, CSV, Markdown и HTML. - Экспорт в ODT, CSV, Markdown и HTML.
- Автоматическое определение месяца ODT-отчёта по конечной дате периода. - Автоматическое определение месяца ODT-отчёта по конечной дате периода.
- Настройка периода отчёта по умолчанию через `.env`. - Настройка периода отчёта по умолчанию через `.env` (или автоматически — текущий месяц).
- Переопределение URL и API-ключа через CLI.
- Загрузка альтернативного `.env` через `--config`.
## Установка ## Установка
@@ -44,7 +46,7 @@ DEFAULT_FROM_DATE=2026-01-01
DEFAULT_TO_DATE=2026-01-31 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 source .venv/bin/activate
``` ```
Отчёт за период по умолчанию: Отчёт за период по умолчанию (текущий месяц или из `.env`):
```bash ```bash
redmine-reporter redmine-reporter
@@ -100,12 +102,37 @@ redmine-reporter --date 2026-02-01--2026-02-28
Период должен быть задан в формате `YYYY-MM-DD--YYYY-MM-DD`. Начальная дата не может быть позже конечной. Период должен быть задан в формате `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 ```bash
redmine-reporter --compact redmine-reporter --compact
``` ```
Подробный или отладочный вывод:
```bash
redmine-reporter --verbose
redmine-reporter --debug
```
Вывод версии:
```bash
redmine-reporter --version
```
Экспорт: Экспорт:
```bash ```bash

View File

@@ -1,10 +1,12 @@
import argparse import argparse
import logging
import os import os
import re import re
import sys import sys
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List, Optional
from . import __version__
from .client import fetch_issues_with_spent_time from .client import fetch_issues_with_spent_time
from .config import Config from .config import Config
from .formatters.factory import get_console_formatter, get_formatter_by_extension 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( parser.add_argument(
"--date", "--date",
default=Config.get_default_date_range(), 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: current month or from .env)",
help="Date range in format YYYY-MM-DD--YYYY-MM-DD (default from .env or %(default)s)",
) )
parser.add_argument( parser.add_argument(
"--compact", "--compact",
@@ -59,8 +60,33 @@ def main(argv: Optional[List[str]] = None) -> int:
parser.add_argument( parser.add_argument(
"--no-time", action="store_true", help="Do not include spent time into table" "--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) 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: try:
Config.validate() Config.validate()
except ValueError as e: except ValueError as e:

View File

@@ -1,4 +1,5 @@
import os import os
from datetime import date, timedelta
from typing import Union from typing import Union
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -11,12 +12,32 @@ TRUE_VALUES = {"1", "true", "yes", "on"}
class Config: 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 @classmethod
def get_redmine_url(cls) -> str: def get_redmine_url(cls) -> str:
if cls._cli_url is not None:
return cls._cli_url
return os.getenv("REDMINE_URL", "").strip().rstrip("/") return os.getenv("REDMINE_URL", "").strip().rstrip("/")
@classmethod @classmethod
def get_redmine_api_key(cls) -> str: 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() return os.getenv("REDMINE_API_KEY", "").strip()
@classmethod @classmethod
@@ -53,8 +74,17 @@ class Config:
default_to_date = os.getenv("DEFAULT_TO_DATE", "").strip() default_to_date = os.getenv("DEFAULT_TO_DATE", "").strip()
if default_from_date and default_to_date: if default_from_date and default_to_date:
return f"{default_from_date}--{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 @classmethod
def validate(cls) -> None: def validate(cls) -> None:

View File

@@ -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( @pytest.mark.parametrize(
"date_arg, expected", "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 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: class _MockIssue:
"""Простой mock Redmine Issue для CLI-тестов.""" """Простой mock Redmine Issue для CLI-тестов."""
@@ -104,6 +138,38 @@ class _MockIssue:
self.fixed_version = None 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.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_total_issues_message_goes_to_stderr(mock_fetch, capsys): def test_total_issues_message_goes_to_stderr(mock_fetch, capsys):

View File

@@ -74,9 +74,19 @@ def test_get_default_date_range_from_env():
@mock.patch.dict(os.environ, {}, clear=True) @mock.patch.dict(os.environ, {}, clear=True)
def test_get_default_date_range_fallback(): 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() 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) @mock.patch.dict(os.environ, {}, clear=True)