Files
redmine-reporter/redmine_reporter/cli.py
Кокос Артем Николаевич f6afc4096d 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
2026-06-29 12:02:51 +07:00

178 lines
6.0 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
from .report_builder import build_grouped_report
def parse_date_range(date_arg: str) -> tuple[str, str]:
if "--" not in date_arg:
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
parts = date_arg.split("--", 1)
from_date, to_date = parts[0].strip(), parts[1].strip()
date_pattern = r"\d{4}-\d{2}-\d{2}"
if not re.fullmatch(date_pattern, from_date) or not re.fullmatch(date_pattern, to_date):
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
try:
start = datetime.strptime(from_date, "%Y-%m-%d").date()
end = datetime.strptime(to_date, "%Y-%m-%d").date()
except ValueError as e:
raise ValueError("Date range contains invalid calendar date") from e
if start > end:
raise ValueError("Date range start must be less than or equal to end")
return start.isoformat(), end.isoformat()
def main(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser(
prog="redmine-reporter",
description="Generate Redmine issue report based on your time entries.",
)
parser.add_argument(
"--date",
default=Config.get_default_date_range(),
help="Date range in format YYYY-MM-DD--YYYY-MM-DD (default: current month or from .env)",
)
parser.add_argument(
"--compact",
action="store_true",
help="Use compact plain-text output instead of table",
)
parser.add_argument(
"--output",
help="Path to output file (.odt, .csv, .md, .html). If omitted, prints to stdout.",
)
parser.add_argument(
"--author", default="", help="Override author name from .env (REDMINE_AUTHOR)"
)
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:
print(f"❌ Configuration error: {e}", file=sys.stderr)
return 1
try:
from_date, to_date = parse_date_range(args.date)
except ValueError as e:
print(f"❌ Date error: {e}", file=sys.stderr)
return 1
try:
issue_hours = fetch_issues_with_spent_time(from_date, to_date)
except Exception as e:
print(f"❌ Redmine API error: {e}", file=sys.stderr)
return 1
if issue_hours is None:
print(" No time entries found in the given period.", file=sys.stderr)
return 0
print(f"✅ Total issues: {len(issue_hours)} [{args.date}]", file=sys.stderr)
rows = build_grouped_report(issue_hours, fill_time=not args.no_time)
if args.output:
output_ext = os.path.splitext(args.output)[1].lower()
if not output_ext:
print(
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md или .html",
file=sys.stderr,
)
return 1
formatter = get_formatter_by_extension(
output_ext,
author=Config.get_author(args.author),
from_date=from_date,
to_date=to_date,
)
if not formatter:
if output_ext == ".odt":
print(
"❌ odfpy is not installed. Install with: pip install odfpy",
file=sys.stderr,
)
else:
known_exts = ", ".join([".odt", ".csv", ".md", ".html"])
print(
f"❌ Неизвестный формат файла: {output_ext!r}. "
f"Поддерживаются: {known_exts}",
file=sys.stderr,
)
return 1
try:
formatter.save(rows, args.output)
print(f"✅ Report saved to {args.output}")
except Exception as e:
fmt = output_ext.lstrip(".").upper()
print(f"{fmt} export error: {e}", file=sys.stderr)
return 1
else:
if args.compact:
formatter = get_console_formatter("compact")
else:
formatter = get_console_formatter("table")
if not formatter:
print("❌ Неизвестный тип консольного форматтера.", file=sys.stderr)
return 1
try:
output = formatter.format(rows)
print(output)
except Exception as e:
print(f"❌ Formatting error: {e}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())