feat: JSON, Excel export and time summary

- #23: add JSONFormatter and XLSXFormatter
- add openpyxl dependency for .xlsx export
- #22: add --summary flag and calculate_summary() in report_builder
- ReportRow now carries raw hours for summary calculations
- update CLI help and README with .json/.xlsx formats and --summary
- add tests for new formatters and summary computation

Closes #22, closes #23
This commit is contained in:
Кокос Артем Николаевич
2026-06-29 12:09:58 +07:00
parent f6afc4096d
commit ca89832d74
11 changed files with 255 additions and 7 deletions

View File

@@ -10,7 +10,7 @@ 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
from .report_builder import build_grouped_report, calculate_summary
def parse_date_range(date_arg: str) -> tuple[str, str]:
@@ -52,7 +52,7 @@ def main(argv: Optional[List[str]] = None) -> int:
)
parser.add_argument(
"--output",
help="Path to output file (.odt, .csv, .md, .html). If omitted, prints to stdout.",
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)"
@@ -71,6 +71,11 @@ def main(argv: Optional[List[str]] = None) -> int:
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.
@@ -113,12 +118,20 @@ def main(argv: Optional[List[str]] = None) -> int:
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",
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx",
file=sys.stderr,
)
return 1
@@ -137,7 +150,7 @@ def main(argv: Optional[List[str]] = None) -> int:
file=sys.stderr,
)
else:
known_exts = ", ".join([".odt", ".csv", ".md", ".html"])
known_exts = ", ".join([".odt", ".csv", ".md", ".html", ".json", ".xlsx"])
print(
f"❌ Неизвестный формат файла: {output_ext!r}. "
f"Поддерживаются: {known_exts}",

View File

@@ -4,15 +4,19 @@ from .base import Formatter
from .console import CompactFormatter, TableFormatter
from .csv import CSVFormatter
from .html import HTMLFormatter
from .json import JSONFormatter
from .markdown import MarkdownFormatter
from .xlsx import XLSXFormatter
# Словарь для сопоставления расширений файлов с классами форматтеров.
# ODT намеренно отсутствует — его импорт отложен (ленивый), так как odfpy
# может быть не установлен. См. get_formatter_by_extension.
# ODT и XLSX намеренно отсутствуют — их импорт отложен (ленивый), так как
# odfpy/openpyxl может быть не установлен. См. get_formatter_by_extension.
FORMATTER_MAP: Dict[str, Type[Formatter]] = {
".csv": CSVFormatter,
".json": JSONFormatter,
".md": MarkdownFormatter,
".html": HTMLFormatter,
".xlsx": XLSXFormatter,
}

View File

@@ -0,0 +1,31 @@
import json
from typing import List
from ..types import ReportRow
from .base import Formatter
class JSONFormatter(Formatter):
"""Форматтер для экспорта отчёта в JSON."""
def __init__(self, **_kwargs):
super().__init__()
def format(self, rows: List[ReportRow]) -> str:
data = [
{
"project": r["project"],
"version": r["version"],
"issue_id": r["issue_id"],
"subject": r["subject"],
"status": r["status_ru"],
"time": r["time_text"],
}
for r in rows
]
return json.dumps(data, ensure_ascii=False, indent=2)
def save(self, rows: List[ReportRow], output_path: str) -> None:
content = self.format(rows)
with open(output_path, "w", encoding="utf-8") as f:
f.write(content)

View File

@@ -0,0 +1,42 @@
from typing import List
from openpyxl import Workbook
from openpyxl.styles import Font
from ..types import ReportRow
from .base import Formatter
class XLSXFormatter(Formatter):
"""Форматтер для экспорта отчёта в Excel (.xlsx)."""
def __init__(self, **_kwargs):
super().__init__()
def format(self, rows: List[ReportRow]) -> Workbook:
wb = Workbook()
ws = wb.active
ws.title = "Report"
headers = ["Project", "Version", "Issue ID", "Subject", "Status", "Spent Time"]
ws.append(headers)
for cell in ws[1]:
cell.font = Font(bold=True)
for r in rows:
ws.append(
[
r["project"],
r["version"],
r["issue_id"],
r["subject"],
r["status_ru"],
r["time_text"],
]
)
return wb
def save(self, rows: List[ReportRow], output_path: str) -> None:
wb = self.format(rows)
wb.save(output_path)

View File

@@ -63,6 +63,7 @@ def build_grouped_report(
"subject": issue.subject,
"status_ru": status_ru,
"time_text": time_text,
"hours": round(hours, 2),
},
)
)
@@ -73,6 +74,26 @@ def build_grouped_report(
return rows
def calculate_summary(rows: List[ReportRow]) -> Dict[str, float]:
"""Возвращает сводку: общее время, время по проектам и версиям."""
total = 0.0
by_project: Dict[str, float] = {}
by_project_version: Dict[str, float] = {}
for r in rows:
hours = r.get("hours", 0.0)
total += hours
by_project[r["project"]] = by_project.get(r["project"], 0.0) + hours
key = f"{r['project']}::{r['version']}"
by_project_version[key] = by_project_version.get(key, 0.0) + hours
return {
"total": round(total, 2),
**{f"project:{k}": round(v, 2) for k, v in by_project.items()},
**{f"version:{k}": round(v, 2) for k, v in by_project_version.items()},
}
def group_rows_by_project_and_version(
rows: List[ReportRow],
) -> Dict[str, Dict[str, List[ReportRow]]]:

View File

@@ -12,3 +12,4 @@ class ReportRow(TypedDict):
subject: str
status_ru: str
time_text: str
hours: float