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.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, List
|
||||
from typing import Any
|
||||
|
||||
from ..types import ReportRow
|
||||
|
||||
@@ -16,19 +16,17 @@ class Formatter(ABC):
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def format(self, rows: List[ReportRow]) -> Any:
|
||||
def format(self, rows: list[ReportRow]) -> Any:
|
||||
"""
|
||||
Форматирует список строк отчета в нужный формат.
|
||||
Для текстовых форматтеров возвращает str.
|
||||
Для ODTFormatter возвращает объект OpenDocument.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
||||
def save(self, rows: list[ReportRow], output_path: str) -> None:
|
||||
"""
|
||||
Сохраняет отформатированный отчет в файл по указанному пути.
|
||||
Для форматтеров, которые не поддерживают сохранение (например, консольные),
|
||||
бросает NotImplementedError.
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from typing import List
|
||||
|
||||
from tabulate import tabulate
|
||||
|
||||
from ..types import ReportRow
|
||||
@@ -9,7 +7,7 @@ from .base import Formatter
|
||||
class TableFormatter(Formatter):
|
||||
"""Форматтер для вывода красивой таблицы в консоль."""
|
||||
|
||||
def format(self, rows: List[ReportRow]) -> str:
|
||||
def format(self, rows: list[ReportRow]) -> str:
|
||||
table_rows = [["Проект", "Версия", "Задача", "Статус", "Затрачено"]]
|
||||
for r in rows:
|
||||
time_text = r["time_text"].replace("\n", " / ")
|
||||
@@ -24,7 +22,7 @@ class TableFormatter(Formatter):
|
||||
)
|
||||
return tabulate(table_rows, headers="firstrow", tablefmt="fancy_grid")
|
||||
|
||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
||||
def save(self, rows: list[ReportRow], output_path: str) -> None:
|
||||
# Консольные форматтеры не умеют сохранять в файл напрямую.
|
||||
# Это делается в CLI.
|
||||
raise NotImplementedError("TableFormatter не поддерживает сохранение в файл.")
|
||||
@@ -33,7 +31,7 @@ class TableFormatter(Formatter):
|
||||
class CompactFormatter(Formatter):
|
||||
"""Форматтер для компактного вывода в консоль."""
|
||||
|
||||
def format(self, rows: List[ReportRow]) -> str:
|
||||
def format(self, rows: list[ReportRow]) -> str:
|
||||
lines = []
|
||||
for r in rows:
|
||||
time_text = r["time_text"].replace("\n", " / ")
|
||||
@@ -43,7 +41,7 @@ class CompactFormatter(Formatter):
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
||||
def save(self, rows: list[ReportRow], output_path: str) -> None:
|
||||
# Консольные форматтеры не умеют сохранять в файл напрямую.
|
||||
# Это делается в CLI.
|
||||
raise NotImplementedError("CompactFormatter не поддерживает сохранение в файл.")
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import csv
|
||||
import io
|
||||
from typing import List
|
||||
|
||||
from ..types import ReportRow
|
||||
from .base import Formatter
|
||||
@@ -19,7 +18,7 @@ class CSVFormatter(Formatter):
|
||||
super().__init__()
|
||||
self.no_time = no_time
|
||||
|
||||
def format(self, rows: List[ReportRow]) -> str:
|
||||
def format(self, rows: list[ReportRow]) -> str:
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output, dialect="excel")
|
||||
headers = ["Project", "Version", "Issue ID", "Subject", "Status"]
|
||||
@@ -40,7 +39,7 @@ class CSVFormatter(Formatter):
|
||||
writer.writerow(data)
|
||||
return output.getvalue()
|
||||
|
||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
||||
def save(self, rows: list[ReportRow], output_path: str) -> None:
|
||||
content = self.format(rows)
|
||||
with open(output_path, "w", encoding="utf-8-sig", newline="") as f:
|
||||
f.write(content)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from typing import Dict, Optional, Type
|
||||
|
||||
from .base import Formatter
|
||||
from .console import CompactFormatter, TableFormatter
|
||||
from .csv import CSVFormatter
|
||||
@@ -11,7 +9,7 @@ from .xlsx import XLSXFormatter
|
||||
# Словарь для сопоставления расширений файлов с классами форматтеров.
|
||||
# ODT и XLSX намеренно отсутствуют — их импорт отложен (ленивый), так как
|
||||
# odfpy/openpyxl может быть не установлен. См. get_formatter_by_extension.
|
||||
FORMATTER_MAP: Dict[str, Type[Formatter]] = {
|
||||
FORMATTER_MAP: dict[str, type[Formatter]] = {
|
||||
".csv": CSVFormatter,
|
||||
".json": JSONFormatter,
|
||||
".md": MarkdownFormatter,
|
||||
@@ -21,13 +19,13 @@ FORMATTER_MAP: Dict[str, Type[Formatter]] = {
|
||||
|
||||
|
||||
# Словарь для сопоставления типа вывода (консоль) с классами форматтеров
|
||||
CONSOLE_FORMATTER_MAP: Dict[str, Type[Formatter]] = {
|
||||
CONSOLE_FORMATTER_MAP: dict[str, type[Formatter]] = {
|
||||
"table": TableFormatter,
|
||||
"compact": CompactFormatter,
|
||||
}
|
||||
|
||||
|
||||
def get_formatter_by_extension(extension: str, **kwargs) -> Optional[Formatter]:
|
||||
def get_formatter_by_extension(extension: str, **kwargs) -> Formatter | None:
|
||||
"""
|
||||
Возвращает экземпляр форматтера по расширению файла.
|
||||
Ключевые аргументы (**kwargs) передаются в конструктор форматтера.
|
||||
@@ -51,7 +49,7 @@ def get_formatter_by_extension(extension: str, **kwargs) -> Optional[Formatter]:
|
||||
return None
|
||||
|
||||
|
||||
def get_console_formatter(formatter_type: str) -> Optional[Formatter]:
|
||||
def get_console_formatter(formatter_type: str) -> Formatter | None:
|
||||
"""
|
||||
Возвращает экземпляр консольного форматтера по его типу.
|
||||
"""
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from html import escape
|
||||
from typing import List
|
||||
|
||||
from ..report_builder import group_rows_by_project_and_version
|
||||
from ..types import ReportRow
|
||||
@@ -12,7 +11,7 @@ class HTMLFormatter(Formatter):
|
||||
def __init__(self, **_kwargs):
|
||||
super().__init__()
|
||||
|
||||
def format(self, rows: List[ReportRow]) -> str:
|
||||
def format(self, rows: list[ReportRow]) -> str:
|
||||
projects = group_rows_by_project_and_version(rows)
|
||||
|
||||
lines = [
|
||||
@@ -79,7 +78,7 @@ class HTMLFormatter(Formatter):
|
||||
lines.append("</html>")
|
||||
return "\n".join(lines)
|
||||
|
||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
||||
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)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import json
|
||||
from typing import List
|
||||
|
||||
from ..types import ReportRow
|
||||
from .base import Formatter
|
||||
@@ -11,7 +10,7 @@ class JSONFormatter(Formatter):
|
||||
def __init__(self, **_kwargs):
|
||||
super().__init__()
|
||||
|
||||
def format(self, rows: List[ReportRow]) -> str:
|
||||
def format(self, rows: list[ReportRow]) -> str:
|
||||
data = []
|
||||
for r in rows:
|
||||
item = {
|
||||
@@ -28,7 +27,7 @@ class JSONFormatter(Formatter):
|
||||
data.append(item)
|
||||
return json.dumps(data, ensure_ascii=False, indent=2)
|
||||
|
||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
||||
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)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from typing import List
|
||||
|
||||
from ..types import ReportRow
|
||||
from .base import Formatter
|
||||
|
||||
@@ -14,7 +12,7 @@ class MarkdownFormatter(Formatter):
|
||||
def __init__(self, **_kwargs):
|
||||
super().__init__()
|
||||
|
||||
def format(self, rows: List[ReportRow]) -> str:
|
||||
def format(self, rows: list[ReportRow]) -> str:
|
||||
lines = [
|
||||
"| Проект | Версия | Задача | Статус | Затрачено |",
|
||||
"|--------|--------|--------|--------|-----------|",
|
||||
@@ -30,7 +28,7 @@ class MarkdownFormatter(Formatter):
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
||||
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)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from importlib import resources
|
||||
from typing import List
|
||||
|
||||
from odf.opendocument import OpenDocument, load
|
||||
from odf.style import Style, TableCellProperties, TableColumnProperties
|
||||
@@ -25,7 +24,7 @@ class ODTFormatter(Formatter):
|
||||
self.from_date = from_date
|
||||
self.to_date = to_date
|
||||
|
||||
def format(self, rows: List[ReportRow]) -> OpenDocument:
|
||||
def format(self, rows: list[ReportRow]) -> OpenDocument:
|
||||
"""
|
||||
Форматирует данные в объект OpenDocument.
|
||||
"""
|
||||
@@ -172,7 +171,7 @@ class ODTFormatter(Formatter):
|
||||
|
||||
return doc
|
||||
|
||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
||||
def save(self, rows: list[ReportRow], output_path: str) -> None:
|
||||
"""
|
||||
Сохраняет сформированный документ в файл.
|
||||
"""
|
||||
|
||||
44
redmine_reporter/formatters/task_table.py
Normal file
44
redmine_reporter/formatters/task_table.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from tabulate import tabulate
|
||||
|
||||
from ..types import TaskRow
|
||||
|
||||
|
||||
class TaskTableFormatter:
|
||||
"""Консольный форматтер таблицы открытых задач пользователя (режим --my-tasks).
|
||||
|
||||
Не входит в фабрику файловых форматтеров: таблица всегда печатается
|
||||
в stdout, поэтому сохранение в файл не поддержано.
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str) -> None:
|
||||
self.base_url = base_url
|
||||
|
||||
def _hyperlink(self, issue_id: int) -> str:
|
||||
# OSC 8-гиперссылка: номер задачи остаётся видимым терминалом без
|
||||
# поддержки ссылок, а поддерживающие делают его кликабельным.
|
||||
url = f"{self.base_url.rstrip('/')}/issues/{issue_id}"
|
||||
return f"\x1b]8;;{url}\x1b\\{issue_id}\x1b]8;;\x1b\\"
|
||||
|
||||
def format(self, rows: list[TaskRow]) -> str:
|
||||
table_rows = [["ID", "Тема", "Статус", "Приор.", "Версия", "Срок"]]
|
||||
for row in rows:
|
||||
due_text = row["due_text"]
|
||||
if row["overdue"]:
|
||||
due_text = f"{due_text} (!)"
|
||||
table_rows.append(
|
||||
[
|
||||
self._hyperlink(row["issue_id"]),
|
||||
row["subject"],
|
||||
row["status_ru"],
|
||||
row["priority"],
|
||||
row["version_name"] or "-",
|
||||
due_text or "-",
|
||||
]
|
||||
)
|
||||
return tabulate(table_rows, headers="firstrow", tablefmt="simple")
|
||||
|
||||
def save(self, rows: list[TaskRow], output_path: str) -> None:
|
||||
# Консольные форматтеры не умеют сохранять в файл напрямую.
|
||||
raise NotImplementedError(
|
||||
"TaskTableFormatter не поддерживает сохранение в файл."
|
||||
)
|
||||
@@ -1,5 +1,3 @@
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
@@ -36,7 +34,7 @@ class XLSXFormatter(Formatter):
|
||||
super().__init__()
|
||||
self.no_time = no_time
|
||||
|
||||
def format(self, rows: List[ReportRow]) -> Workbook:
|
||||
def format(self, rows: list[ReportRow]) -> Workbook:
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Report"
|
||||
@@ -56,9 +54,9 @@ class XLSXFormatter(Formatter):
|
||||
grouped = group_rows_by_project_and_version(rows)
|
||||
|
||||
current_row = 2
|
||||
project_ranges: List[Tuple[int, int]] = []
|
||||
version_ranges: List[Tuple[int, int]] = []
|
||||
project_totals: Dict[str, float] = {}
|
||||
project_ranges: list[tuple[int, int]] = []
|
||||
version_ranges: list[tuple[int, int]] = []
|
||||
project_totals: dict[str, float] = {}
|
||||
|
||||
for project, versions in grouped.items():
|
||||
project_start_row = current_row
|
||||
@@ -175,11 +173,11 @@ class XLSXFormatter(Formatter):
|
||||
|
||||
return wb
|
||||
|
||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
||||
def save(self, rows: list[ReportRow], output_path: str) -> None:
|
||||
wb = self.format(rows)
|
||||
wb.save(output_path)
|
||||
|
||||
def _style_header_row(self, ws: Worksheet, headers: List[str]) -> None:
|
||||
def _style_header_row(self, ws: Worksheet, headers: list[str]) -> None:
|
||||
for col_idx, _ in enumerate(headers, start=1):
|
||||
cell = ws.cell(row=1, column=col_idx)
|
||||
cell.font = Font(bold=True)
|
||||
@@ -193,9 +191,7 @@ class XLSXFormatter(Formatter):
|
||||
for col_idx in range(1, 8):
|
||||
cell = ws.cell(row=row, column=col_idx)
|
||||
cell.border = self._BORDER
|
||||
if col_idx in (1, 2):
|
||||
cell.alignment = Alignment(vertical="top", wrap_text=True)
|
||||
elif col_idx == 4:
|
||||
if col_idx in (1, 2) or col_idx == 4:
|
||||
cell.alignment = Alignment(vertical="top", wrap_text=True)
|
||||
else:
|
||||
cell.alignment = Alignment(vertical="top")
|
||||
@@ -210,7 +206,7 @@ class XLSXFormatter(Formatter):
|
||||
|
||||
def _apply_column_widths(self, ws: Worksheet) -> None:
|
||||
# Минимальные ширины по умолчанию
|
||||
widths: Dict[int, float] = {
|
||||
widths: dict[int, float] = {
|
||||
1: 18.0,
|
||||
2: 16.0,
|
||||
3: 12.0,
|
||||
|
||||
Reference in New Issue
Block a user