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, calculate_summary 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, .json, .xlsx). 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", ) parser.add_argument( "--summary", action="store_true", help="Print summary (total hours by project/version) to stderr", ) 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 if not issue_hours: 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.summary: summary = calculate_summary(rows) print(f"⏱️ Total time: {summary['total']}h", file=sys.stderr) for key, value in summary.items(): if key.startswith("project:"): project = key.split(":", 1)[1] print(f" {project}: {value}h", file=sys.stderr) if args.output: output_ext = os.path.splitext(args.output)[1].lower() if not output_ext: print( "❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx", 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", ".json", ".xlsx"]) 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())