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, 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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user