Files
redmine-reporter/redmine_reporter/cli.py
Кокос Артем Николаевич 46674ba926 fix: normalize datetimes to aware UTC in dedup
Dedup with precision=datetime crashed with TypeError: redminelib returns
naive created_on/updated_on while the cutoff from _compute_dedup_cutoff
is aware UTC. Normalize at a single point: _parse_datetime now always
returns an aware datetime (naive treated as UTC), matching its docstring.
The dedup cutoff is normalized the same way, and any residual comparison
TypeError is wrapped in RedmineAPIError instead of leaking raw.

Also fix --commit saving last_used.to as naive local time; it now stores
aware UTC (datetime.now(timezone.utc)) so the next run computes a correct
aware cutoff.

Closes #58
2026-07-17 12:11:35 +07:00

560 lines
18 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, 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 .mailer import send_report
from .report_builder import build_grouped_report, calculate_summary
from .yaml_config import ensure_config_dir, resolve_output_path, save_period_to_config
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}"
datetime_pattern = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}"
if re.fullmatch(date_pattern, from_date) and re.fullmatch(date_pattern, to_date):
fmt = "%Y-%m-%d"
elif re.fullmatch(datetime_pattern, from_date) and re.fullmatch(
datetime_pattern, to_date
):
fmt = "%Y-%m-%dT%H:%M:%S"
else:
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
try:
start = datetime.strptime(from_date, fmt)
end = datetime.strptime(to_date, fmt)
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")
if fmt == "%Y-%m-%d":
return start.date().isoformat(), end.date().isoformat()
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 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",
},
"report": {
"no_time": False,
},
"email": {
"html": False,
"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 _resolve_no_time(cli_flag: bool, is_auto_mode: bool) -> bool:
"""Возвращает финальное значение no_time.
Приоритет:
1. CLI-флаг --no-time (всегда побеждает).
2. YAML report.no_time (только в автоматических режимах --commit/--send).
3. Иначе False.
"""
if cli_flag:
return True
if is_auto_mode:
return Config.get_report_no_time()
return False
def _save_and_maybe_send(
rows,
output_arg: str,
author: str,
from_date: str,
to_date: str,
no_time: bool,
do_send: bool,
) -> int:
"""Сохраняет отчёт в файл и опционально отправляет по email.
Returns 0 on success, 1 on error.
"""
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=author,
from_date=from_date,
to_date=to_date,
no_time=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
if do_send:
email_config = Config.get_email_config()
if email_config is None:
print(
"❌ Email не настроен. Добавьте секцию 'email' в конфиг "
"(~/.config/redmine-reporter/config.yml).",
file=sys.stderr,
)
return 1
try:
send_report(
email_config,
output_arg,
author,
f"{from_date}--{to_date}",
rows=rows,
)
print(f"📧 Report sent to {', '.join(email_config.to)}")
except RedmineAPIError as e:
print(f"{e.message}", file=sys.stderr)
return 1
return 0
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)",
)
parser.add_argument(
"--commit",
action="store_true",
help="Save used period as last_used in YAML config and auto-commit to file",
)
parser.add_argument(
"--send",
action="store_true",
help="Send generated report via email after saving (requires email section in config)",
)
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,
args.send,
]
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)
is_auto_mode = args.commit or args.send
no_time = _resolve_no_time(args.no_time, is_auto_mode)
rows = build_grouped_report(
issue_hours,
fill_time=not 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 or args.commit:
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,
)
else:
# --commit без --output: используем default_format
default_format = Config.get_output_default_format()
output_arg = resolve_output_path(
default_format,
output_dir=Config.get_output_dir(),
filename_template=Config.get_output_filename(),
default_format=default_format,
author=Config.get_author(args.author),
from_date=from_date,
to_date=to_date,
)
if output_arg is None:
print(
"Не удалось определить путь для сохранения отчёта.", file=sys.stderr
)
return 1
ret = _save_and_maybe_send(
rows,
output_arg,
author=Config.get_author(args.author),
from_date=from_date,
to_date=to_date,
no_time=no_time,
do_send=args.send,
)
if ret != 0:
return ret
elif args.send:
# --send без --output и --commit: сохраняем по шаблону и отправляем
default_format = Config.get_output_default_format()
output_arg = resolve_output_path(
default_format,
output_dir=Config.get_output_dir(),
filename_template=Config.get_output_filename(),
default_format=default_format,
author=Config.get_author(args.author),
from_date=from_date,
to_date=to_date,
)
if output_arg is None:
print(
"Не удалось определить путь для сохранения отчёта.", file=sys.stderr
)
return 1
ret = _save_and_maybe_send(
rows,
output_arg,
author=Config.get_author(args.author),
from_date=from_date,
to_date=to_date,
no_time=no_time,
do_send=True,
)
if ret != 0:
return ret
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
if args.commit:
precision = Config.get_period_precision()
dynamic = Config._app.period_dynamic if Config._app else False
if precision == "datetime":
# Сохраняем aware UTC (#58): следующий запуск вычисляет из этой
# метки aware cutoff для дедупликации.
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
from_str = now
to_str = now
else:
from_str = from_date
to_str = to_date
try:
save_period_to_config(
args.config_path, from_str, to_str, precision, dynamic
)
except Exception as e:
print(
f"Не удалось сохранить период в конфиг: {e}",
file=sys.stderr,
)
return 1
print(
f"📌 Period committed [{from_str} -- {to_str}] → {args.config_path}",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
sys.exit(main())