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:
33
README.md
33
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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user