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-отчёта по конечной дате периода.
- Настройка периода отчёта по умолчанию через `.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

View File

@@ -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:

View File

@@ -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:

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(
"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):

View File

@@ -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)