import argparse import logging import os import re import sys from datetime import datetime, timezone from pathlib import Path from typing import List, Optional import yaml from . import __version__ from .client import RedmineAPIError, 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 from .yaml_config import ensure_config_dir, resolve_output_path 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 _run_init_config(config_path: str, force: bool) -> int: """Создаёт YAML-конфиг из текущих настроек окружения.""" path = Path(config_path) if path.exists() and not force: print( f"⚠️ {path} already exists.\n" f" Use --init-config --force to overwrite.", file=sys.stderr, ) return 1 # Собираем значения из окружения data = { "redmine": { "url": os.getenv("REDMINE_URL", "").strip().rstrip("/"), "api_key": ("${REDMINE_API_KEY}" if os.getenv("REDMINE_API_KEY") else ""), "author": os.getenv("REDMINE_AUTHOR", "").strip(), "verify_ssl": True, }, "period": { "precision": "date", "default_from": os.getenv("DEFAULT_FROM_DATE", "").strip(), "default_to": os.getenv("DEFAULT_TO_DATE", "").strip(), "dynamic": False, }, "output": { "dir": "", "filename": "{author}_{from}_{to}.{ext}", "default_format": "xlsx", }, "email": { "smtp": { "host": "", "port": 587, "user": "", "password": ("${SMTP_PASSWORD}" if os.getenv("SMTP_PASSWORD") else ""), "tls": True, }, "from": "", "to": [], "cc": [], "bcc": [], "subject": "Отчёт {author} за {period}", "body_text": "Во вложении отчёт.", "attach": True, }, } ensure_config_dir(path.parent) with open(path, "w", encoding="utf-8") as fh: yaml.dump(data, fh, allow_unicode=True, default_flow_style=False, sort_keys=False) path.chmod(0o600) sections_found = [s for s in data if data[s]] print(f"✅ Config written to {path}") print(f" Sections: {', '.join(sections_found)}") print(" Secrets stored as ${VAR} references where detected.") return 0 def _compute_dedup_cutoff() -> Optional[datetime]: """Compute deduplication cutoff from config. If period.precision == 'datetime' and period.last_used.to is set, return the datetime to filter out entries already accounted for. Otherwise return None. """ if Config.get_period_precision() != "datetime": return None last_to = Config.get_last_used_to() if not last_to: return None try: dt = datetime.fromisoformat(last_to.replace("Z", "+00:00")) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt except (ValueError, TypeError): return None 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=None, 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", ) parser.add_argument( "--user-id", help="Redmine user ID for the report (default: current user)", ) parser.add_argument( "--user-login", help="Redmine user login for the report (alternative to --user-id)", ) parser.add_argument( "--user-name", help="Redmine user full name for the report (alternative to --user-id; ambiguous names are rejected)", ) parser.add_argument( "--by-activity", action="store_true", help="Break down spent time by activity type", ) parser.add_argument( "--init-config", action="store_true", help="Generate YAML config from current environment and exit", ) parser.add_argument( "--force", action="store_true", help="Allow overwriting existing config with --init-config", ) parser.add_argument( "--config-path", default=str(Path.home() / ".config" / "redmine-reporter" / "config.yml"), help="Path for --init-config output (default: ~/.config/redmine-reporter/config.yml)", ) args = parser.parse_args(argv) # --init-config: обработка до всего остального if args.init_config: # Проверка на взаимоисключающие флаги report_flags = [ args.date is not None, args.output, args.compact, args.summary, args.user_id, args.user_login, args.user_name, args.no_time, args.by_activity, ] if any(report_flags): print( "❌ --init-config cannot be used with report-generating flags.", file=sys.stderr, ) return 1 return _run_init_config(args.config_path, args.force) # Валидация взаимоисключающих флагов пользователя user_args = [args.user_id, args.user_login, args.user_name] if sum(bool(a) for a in user_args) > 1: print( "❌ Specify only one of --user-id, --user-login, or --user-name.", file=sys.stderr, ) return 1 # 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) # Автозагрузка YAML-конфига yaml_path = args.config_path Config.load_yaml(yaml_path) # Настройка уровня логирования 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 # Если --date не указан, используем дефолтный диапазон date_arg = args.date if args.date is not None else Config.get_default_date_range() try: from_date, to_date = parse_date_range(date_arg) 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, user_id=args.user_id or args.user_login or args.user_name, by_activity=args.by_activity, dedup_before=_compute_dedup_cutoff(), ) except RedmineAPIError as e: print(f"❌ {e.message}", file=sys.stderr) if args.debug and e.original is not None: logging.exception("Original Redmine API error") return 1 except Exception as e: print(f"❌ Unexpected 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)} [{date_arg}]", file=sys.stderr) rows = build_grouped_report( issue_hours, fill_time=not args.no_time, by_activity=args.by_activity, ) if args.summary: summary = calculate_summary(rows, by_activity=args.by_activity) print(f"⏱️ Total time: {summary['total']}h", file=sys.stderr) project_keys = [k for k in sorted(summary) if k.startswith("project:")] activity_keys = [k for k in sorted(summary) if k.startswith("activity:")] for key in project_keys + activity_keys: value = summary[key] if key.startswith("project:"): project = key.split(":", 1)[1] print(f" {project}: {value}h", file=sys.stderr) elif key.startswith("activity:"): activity = key.split(":", 1)[1] print(f" [{activity}]: {value}h", file=sys.stderr) if args.output: output_arg = resolve_output_path( args.output, output_dir=Config.get_output_dir(), filename_template=Config.get_output_filename(), default_format=Config.get_output_default_format(), author=Config.get_author(args.author), from_date=from_date, to_date=to_date, ) if output_arg is None: print( "❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx", file=sys.stderr, ) return 1 output_ext = os.path.splitext(output_arg)[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, no_time=args.no_time, ) 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, output_arg) print(f"✅ Report saved to {output_arg}") 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())