Files
redmine-reporter/redmine_reporter/cli.py
Кокос Артем Николаевич 5d1cf4ab74 feat: add --my-tasks mode for open assigned issues (#71)
List open Redmine issues assigned to the current (or overridden) user,
sorted by priority rank (P1..P5) then version due date with a stable id
tie-break. Renders an OSC 8 hyperlinked console table on stdout and a
counts/hours summary line on stderr; version due dates are fetched once
per unique version, unavailable versions degrade to an empty due date.

Also migrates the codebase to ruff 0.17.3 defaults (PEP 585/604
annotations, targeted noqa for intentional blind excepts and CLI root
logging). Behavior-neutral: 408 tests pass with -W error.
2026-10-28 15:26:41 +07:00

658 lines
25 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 pathlib import Path
import yaml
from dotenv import find_dotenv, load_dotenv
from . import __version__
from .client import RedmineAPIError, fetch_issues_with_spent_time, fetch_open_issues
from .config import Config
from .formatters.factory import get_console_formatter, get_formatter_by_extension
from .formatters.task_table import TaskTableFormatter
from .mailer import send_report
from .report_builder import build_grouped_report, calculate_summary
from .task_list import build_task_rows, calculate_task_summary, format_summary_line
from .yaml_config import ensure_config_dir, resolve_output_path, save_period_to_config
# Маскируем API-ключ Redmine в тексте ошибок (#55):
# - query-параметр key=<значение> в URL (?key=... / &key=...);
# - заголовок X-Redmine-API-Key: <значение>.
_SANITIZE_PATTERNS = [
(re.compile(r"([?&]key=)[^\s&\"')]+", re.IGNORECASE), r"\1***"),
(
re.compile(
r"(X-Redmine-API-Key[\"']?\s*[:=]\s*[\"']?)[^\s\"',}]+", re.IGNORECASE
),
r"\1***",
),
]
def _sanitize_error_text(text: str) -> str:
"""Маскирует API-ключ Redmine в тексте ошибки перед выводом в stderr (#55)."""
for pattern, replacement in _SANITIZE_PATTERNS:
text = pattern.sub(replacement, text)
return text
def _parse_boundary(value: str) -> datetime:
"""Разбирает одну границу диапазона как дату или datetime без timezone."""
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
fmt = "%Y-%m-%d"
elif re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", value):
fmt = "%Y-%m-%dT%H:%M:%S"
else:
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
try:
return datetime.strptime(value, fmt)
except ValueError as e:
raise ValueError("Date range contains invalid calendar date") from e
def parse_date_range(date_arg: str) -> tuple[str, str]:
"""Возвращает границы диапазона в виде дат YYYY-MM-DD.
Каждая граница может быть датой или datetime; время отбрасывается
(границы окна -- целые дни), поэтому смешение форматов допустимо.
"""
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()
start = _parse_boundary(from_date)
end = _parse_boundary(to_date)
if start.date() > end.date():
raise ValueError("Date range start must be less than or equal to end")
return start.date().isoformat(), end.date().isoformat()
def _run_init_config(config_path: str, force: bool) -> int:
"""Создаёт YAML-конфиг из текущих настроек окружения.
Подгружает .env из текущей директории (override=False: реальные
переменные окружения не перебиваются), чтобы --init-config видел
значения, заданные только в .env (#64). Поиск идёт от cwd
(usecwd=True): find_dotenv() без него стартует от директории
cli.py и .env пользователя не находит.
"""
load_dotenv(find_dotenv(usecwd=True), override=False)
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": {
"from": os.getenv("FROM_DATE", "").strip(),
},
"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
)
fh.write(
"\n"
"# report.status_translation: переопределение/дополнение перевода\n"
"# статусов Redmine. Без секции используется встроенный словарь.\n"
"# Пример:\n"
"# report:\n"
"# status_translation:\n"
'# "New": "Новая"\n'
'# "Wait Release": "Ожидает релиза"\n'
)
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 _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,
) -> tuple[int, bool]:
"""Сохраняет отчёт в файл и опционально отправляет по email.
Returns (exit_code, sent): sent отражает факт реальной отправки письма,
чтобы main мог отличить отказ от подтверждения от успешной отправки.
"""
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, False
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:
print(
f"❌ Неизвестный формат файла: {output_ext!r}. "
"Поддерживаются: .odt, .csv, .md, .html, .json, .xlsx",
file=sys.stderr,
)
return 1, False
try:
formatter.save(rows, output_arg)
print(f"✅ Report saved to {output_arg}")
except Exception as e: # noqa: BLE001 -- форматёры (odfpy/openpyxl/csv) бросают произвольные типы, CLI обязан вернуть код 1 на любой сбой
fmt = output_ext.lstrip(".").upper()
print(f"❌ {fmt} export error: {e}", file=sys.stderr)
return 1, False
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, False
print("\n📧 Готово к отправке:")
print(f" From: {email_config.from_}")
print(f" To: {', '.join(email_config.to)}")
if email_config.cc:
print(f" Cc: {', '.join(email_config.cc)}")
if email_config.bcc:
print(f" Bcc: {', '.join(email_config.bcc)}")
print(f" File: {output_arg}")
response = input("Отправить? [y/N]: ").strip().lower()
if response not in ("y", "yes"):
print("⏭️ Отправка отменена.")
return 0, False
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"❌ {_sanitize_error_text(e.message)}", file=sys.stderr)
return 1, False
return 0, True
return 0, False
def _run_my_tasks(args: argparse.Namespace) -> int:
"""Режим --my-tasks: таблица открытых задач пользователя в stdout (#71).
Саммари печатается в stderr, а не в stdout: при перенаправлении вывода
в файл остаётся только таблица без служебных строк.
"""
try:
items = fetch_open_issues(
user_id=args.user_id or args.user_login or args.user_name
)
except RedmineAPIError as e:
print(f"❌ {_sanitize_error_text(e.message)}", file=sys.stderr)
if (args.verbose or args.debug) and e.original is not None:
# Точка входа CLI: root-логгер осознанно настроен через basicConfig
# в main(), module-логгер здесь дал бы второй набор хендлеров.
logging.exception("Original Redmine API error") # noqa: LOG015
return 1
except Exception as e:
print(f"❌ Unexpected error: {_sanitize_error_text(str(e))}", file=sys.stderr)
if args.verbose or args.debug:
logging.exception("Unexpected error") # noqa: LOG015
return 1
if not items:
print("ℹ️ Открытых задач нет.", file=sys.stderr)
return 0
rows = build_task_rows(items, Config.get_status_translation())
summary = calculate_task_summary(items)
print(format_summary_line(summary), file=sys.stderr)
print(file=sys.stderr)
try:
table = TaskTableFormatter(base_url=Config.get_redmine_url()).format(rows)
except Exception as e: # noqa: BLE001 -- сбой рендера таблицы не должен ронять CLI трейсбеком: печатаем ошибку и возвращаем код 1
print(f"❌ Formatting error: {e}", file=sys.stderr)
return 1
print(table)
return 0
def main(argv: list[str] | None = 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 the report and advance period start to its end date in YAML config",
)
parser.add_argument(
"--send",
action="store_true",
help="Send generated report via email after saving (requires email section in config); on success advance period start to its end date",
)
parser.add_argument(
"--my-tasks",
action="store_true",
help="List open issues assigned to you, sorted by urgency, and exit",
)
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)
# --my-tasks: отдельный режим, отчётное окно и файлы к нему неприменимы
if args.my_tasks:
report_flags = [
args.date is not None,
args.output,
args.compact,
args.summary,
args.no_time,
args.by_activity,
args.commit,
args.send,
]
if any(report_flags):
print(
"❌ --my-tasks cannot be used with report-generating flags.",
file=sys.stderr,
)
return 1
# Валидация взаимоисключающих флагов пользователя
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
# Ветка --my-tasks: даты отчётного окна режиму не нужны.
if args.my_tasks:
return _run_my_tasks(args)
# Если --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,
)
except RedmineAPIError as e:
print(f"❌ {_sanitize_error_text(e.message)}", file=sys.stderr)
if (args.verbose or args.debug) and e.original is not None:
# Точка входа CLI: root-логгер осознанно настроен через basicConfig
# в main(), module-логгер здесь дал бы второй набор хендлеров.
logging.exception("Original Redmine API error") # noqa: LOG015
return 1
except Exception as e:
print(f"❌ Unexpected error: {_sanitize_error_text(str(e))}", file=sys.stderr)
if args.verbose or args.debug:
logging.exception("Unexpected error") # noqa: LOG015
return 1
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,
status_translation=Config.get_status_translation(),
)
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)
save_ok = False
report_sent = False
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, report_sent = _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
save_ok = True
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, report_sent = _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
save_ok = True
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: # noqa: BLE001 -- консольный форматтер не должен ронять CLI: печатаем ошибку и возвращаем код 1
print(f"❌ Formatting error: {e}", file=sys.stderr)
return 1
# Окно двигается ровно один раз за запуск: либо по явному --commit
# (операция завершилась кодом 0), либо по фактически отправленному письму --send.
should_advance = (args.commit and save_ok) or (args.send and report_sent)
if should_advance:
# Начало следующего окна совпадает с концом текущего: граничный день
# перечитывается. Дубли записей дня коммита осознанны, зато поздние
# записи, добавленные в тот же день после отправки, не теряются.
next_from = to_date
try:
save_period_to_config(args.config_path, from_str=next_from)
except Exception as e: # noqa: BLE001 -- сбой записи конфига не должен ронять CLI трейсбеком: сообщаем и возвращаем код 1
print(
f"❌ Не удалось сохранить период в конфиг: {e}",
file=sys.stderr,
)
return 1
print(
f"📌 Period committed [{next_from}] → {args.config_path}",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
sys.exit(main())