style: apply ruff format to all source files

This commit is contained in:
Кокос Артем Николаевич
2026-07-10 12:39:24 +07:00
parent 25425901b1
commit 0968560090
14 changed files with 286 additions and 107 deletions

View File

@@ -25,7 +25,9 @@ def parse_date_range(date_arg: str) -> tuple[str, str]:
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):
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:
@@ -46,7 +48,7 @@ def _run_init_config(config_path: str, force: bool) -> int:
if path.exists() and not force:
print(
f"⚠️ {path} already exists.\n" f" Use --init-config --force to overwrite.",
f"⚠️ {path} already exists.\n Use --init-config --force to overwrite.",
file=sys.stderr,
)
return 1
@@ -90,7 +92,9 @@ def _run_init_config(config_path: str, force: bool) -> int:
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)
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]]
@@ -222,7 +226,9 @@ def main(argv: Optional[List[str]] = None) -> int:
"--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(
"--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")
@@ -417,7 +423,9 @@ def main(argv: Optional[List[str]] = None) -> int:
)
if output_arg is None:
print("Не удалось определить путь для сохранения отчёта.", file=sys.stderr)
print(
"Не удалось определить путь для сохранения отчёта.", file=sys.stderr
)
return 1
ret = _save_and_maybe_send(
@@ -446,7 +454,9 @@ def main(argv: Optional[List[str]] = None) -> int:
)
if output_arg is None:
print("Не удалось определить путь для сохранения отчёта.", file=sys.stderr)
print(
"Не удалось определить путь для сохранения отчёта.", file=sys.stderr
)
return 1
ret = _save_and_maybe_send(
@@ -493,7 +503,9 @@ def main(argv: Optional[List[str]] = None) -> int:
to_str = to_date
try:
save_period_to_config(args.config_path, from_str, to_str, precision, dynamic)
save_period_to_config(
args.config_path, from_str, to_str, precision, dynamic
)
except Exception as e:
print(
f"Не удалось сохранить период в конфиг: {e}",

View File

@@ -170,7 +170,9 @@ def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]
for i in range(0, len(issue_ids), ISSUE_ID_CHUNK_SIZE):
chunk = issue_ids[i : i + ISSUE_ID_CHUNK_SIZE]
issue_list_str = ",".join(str(x) for x in chunk)
issues = redmine.issue.filter(issue_id=issue_list_str, status_id="*", sort="project:asc")
issues = redmine.issue.filter(
issue_id=issue_list_str, status_id="*", sort="project:asc"
)
all_issues.extend(issues)
return all_issues
@@ -262,7 +264,9 @@ def fetch_issues_with_spent_time(
)
activities_lookup = _load_time_entry_activities(redmine) if by_activity else {}
time_entries = list(
redmine.time_entry.filter(user_id=target_user_id, from_date=from_date, to_date=to_date)
redmine.time_entry.filter(
user_id=target_user_id, from_date=from_date, to_date=to_date
)
)
except RedmineAPIError:
raise

View File

@@ -106,7 +106,8 @@ class AppConfig:
output_dir=cls._resolve_str(raw, "output", "dir"),
output_filename=cls._resolve_str(raw, "output", "filename")
or "{author}_{from}_{to}.{ext}",
output_default_format=cls._resolve_str(raw, "output", "default_format") or "xlsx",
output_default_format=cls._resolve_str(raw, "output", "default_format")
or "xlsx",
email=cls._resolve_email(raw),
)
@@ -397,7 +398,9 @@ class Config:
)
def compute_next_period(last_from: str, last_to: str, precision: str) -> tuple[str, str]:
def compute_next_period(
last_from: str, last_to: str, precision: str
) -> tuple[str, str]:
"""Compute the next report period based on the last committed period.
- For a full calendar month → next full calendar month.

View File

@@ -15,7 +15,9 @@ from .base import Formatter
class ODTFormatter(Formatter):
"""Форматтер для экспорта в ODT."""
def __init__(self, author: str = "", from_date: str = "", to_date: str = "", **_kwargs):
def __init__(
self, author: str = "", from_date: str = "", to_date: str = "", **_kwargs
):
"""
Инициализирует форматтер с параметрами для шапки отчета.
"""
@@ -28,7 +30,11 @@ class ODTFormatter(Formatter):
Форматирует данные в объект OpenDocument.
"""
with resources.files("redmine_reporter").joinpath("templates/template.odt").open("rb") as f:
with (
resources.files("redmine_reporter")
.joinpath("templates/template.odt")
.open("rb") as f
):
doc = load(f)
# Удаляем все текстовые параграфы из шаблона, оставляя только
@@ -52,7 +58,9 @@ class ODTFormatter(Formatter):
# Стиль ячеек
cell_style_name = "TableCellStyle"
cell_style = Style(name=cell_style_name, family="table-cell")
cell_props = TableCellProperties(padding="0.04in", border="0.05pt solid #000000")
cell_props = TableCellProperties(
padding="0.04in", border="0.05pt solid #000000"
)
cell_style.addElement(cell_props)
doc.automaticstyles.addElement(cell_style)
@@ -100,7 +108,9 @@ class ODTFormatter(Formatter):
# в остальных — covered-cell для валидности ODF (#13)
if first_version_in_project and first_row_in_version:
cell_project = TableCell(stylename=cell_style_name)
cell_project.setAttribute("numberrowsspanned", str(total_project_rows))
cell_project.setAttribute(
"numberrowsspanned", str(total_project_rows)
)
p = P(stylename=para_style_name, text=project)
cell_project.addElement(p)
row.addElement(cell_project)
@@ -111,7 +121,9 @@ class ODTFormatter(Formatter):
# в остальных — covered-cell для валидности ODF (#13)
if first_row_in_version:
cell_version = TableCell(stylename=cell_style_name)
cell_version.setAttribute("numberrowsspanned", str(row_span_version))
cell_version.setAttribute(
"numberrowsspanned", str(row_span_version)
)
p = P(stylename=para_style_name, text=version)
cell_version.addElement(p)
row.addElement(cell_version)

View File

@@ -19,8 +19,12 @@ class XLSXFormatter(Formatter):
и числовой столбец с часами для удобного суммирования.
"""
_HEADER_FILL = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid")
_TOTAL_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid")
_HEADER_FILL = PatternFill(
start_color="D9E1F2", end_color="D9E1F2", fill_type="solid"
)
_TOTAL_FILL = PatternFill(
start_color="FFF2CC", end_color="FFF2CC", fill_type="solid"
)
_BORDER = Border(
left=Side(style="thin"),
right=Side(style="thin"),
@@ -40,7 +44,15 @@ class XLSXFormatter(Formatter):
else:
ws.title = "Report"
headers = ["Project", "Version", "Issue ID", "Subject", "Status", "Hours", "Spent Time"]
headers = [
"Project",
"Version",
"Issue ID",
"Subject",
"Status",
"Hours",
"Spent Time",
]
ws.append(headers)
self._style_header_row(ws, headers)
@@ -89,7 +101,10 @@ class XLSXFormatter(Formatter):
)
self._style_total_row(ws, current_row, bold=False)
ws.merge_cells(
start_row=current_row, start_column=2, end_row=current_row, end_column=5
start_row=current_row,
start_column=2,
end_row=current_row,
end_column=5,
)
current_row += 1
@@ -99,7 +114,8 @@ class XLSXFormatter(Formatter):
if not self.no_time:
project_hours = sum(
sum(r.get("hours", 0.0) for r in task_rows) for task_rows in versions.values()
sum(r.get("hours", 0.0) for r in task_rows)
for task_rows in versions.values()
)
ws.append(
[
@@ -114,7 +130,10 @@ class XLSXFormatter(Formatter):
)
self._style_total_row(ws, current_row, bold=True)
ws.merge_cells(
start_row=current_row, start_column=1, end_row=current_row, end_column=5
start_row=current_row,
start_column=1,
end_row=current_row,
end_column=5,
)
current_row += 1
project_totals[project] = project_hours
@@ -137,7 +156,9 @@ class XLSXFormatter(Formatter):
]
)
self._style_total_row(ws, current_row, bold=True)
ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=5)
ws.merge_cells(
start_row=current_row, start_column=1, end_row=current_row, end_column=5
)
for start, end in project_ranges:
ws.merge_cells(start_row=start, start_column=1, end_row=end, end_column=1)
@@ -167,7 +188,9 @@ class XLSXFormatter(Formatter):
cell.font = Font(bold=True)
cell.fill = self._HEADER_FILL
cell.border = self._BORDER
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
cell.alignment = Alignment(
horizontal="center", vertical="center", wrap_text=True
)
def _style_data_row(self, ws: Worksheet, row: int) -> None:
for col_idx in range(1, 8):
@@ -190,7 +213,15 @@ class XLSXFormatter(Formatter):
def _apply_column_widths(self, ws: Worksheet) -> None:
# Минимальные ширины по умолчанию
widths: Dict[int, float] = {1: 18.0, 2: 16.0, 3: 12.0, 4: 45.0, 5: 14.0, 6: 10.0, 7: 14.0}
widths: Dict[int, float] = {
1: 18.0,
2: 16.0,
3: 12.0,
4: 45.0,
5: 14.0,
6: 10.0,
7: 14.0,
}
for row in ws.iter_rows(min_row=2, max_row=ws.max_row):
for col_idx, cell in enumerate(row, start=1):

View File

@@ -40,8 +40,12 @@ def build_message(
period: str,
) -> MIMEMultipart:
"""Формирует MIME-письмо с подстановками и вложением."""
subject = email_config.subject.replace("{author}", author).replace("{period}", period)
body = email_config.body_text.replace("{author}", author).replace("{period}", period)
subject = email_config.subject.replace("{author}", author).replace(
"{period}", period
)
body = email_config.body_text.replace("{author}", author).replace(
"{period}", period
)
msg = MIMEMultipart()
msg["Subject"] = subject
@@ -91,7 +95,9 @@ def send_report(
"""
smtp_cfg = email_config.smtp
msg = build_message(email_config, file_path, author, period)
all_recipients = list(email_config.to) + list(email_config.cc) + list(email_config.bcc)
all_recipients = (
list(email_config.to) + list(email_config.cc) + list(email_config.bcc)
)
try:
with smtplib.SMTP(smtp_cfg.host, smtp_cfg.port, timeout=SMTP_TIMEOUT) as server:
@@ -100,21 +106,18 @@ def send_report(
if smtp_cfg.user:
server.login(smtp_cfg.user, smtp_cfg.password)
server.send_message(msg, from_addr=email_config.from_, to_addrs=all_recipients)
server.send_message(
msg, from_addr=email_config.from_, to_addrs=all_recipients
)
except smtplib.SMTPAuthenticationError:
raise RedmineAPIError(
"Ошибка аутентификации SMTP. Проверьте логин и пароль."
) from None
except TimeoutError:
raise RedmineAPIError(
"Таймаут соединения с SMTP-сервером."
) from None
raise RedmineAPIError("Таймаут соединения с SMTP-сервером.") from None
except smtplib.SMTPException as exc:
raise RedmineAPIError(
f"Ошибка отправки письма: {exc}"
) from exc
raise RedmineAPIError(f"Ошибка отправки письма: {exc}") from exc
except OSError as exc:
raise RedmineAPIError(
f"Не удалось подключиться к SMTP-серверу {smtp_cfg.host}:{smtp_cfg.port}"
) from exc

View File

@@ -45,7 +45,9 @@ def build_grouped_report(
"""
# Защитная сортировка -- гарантирует корректную группировку независимо от порядка на входе
issue_hours = sorted(issue_hours, key=lambda x: (str(x[0].project), get_version(x[0]), x[0].id))
issue_hours = sorted(
issue_hours, key=lambda x: (str(x[0].project), get_version(x[0]), x[0].id)
)
rows: List[ReportRow] = []
prev_project: str = ""
@@ -67,7 +69,9 @@ def build_grouped_report(
time_text = ""
display_project = project if project != prev_project else ""
display_version = version if (project != prev_project or version != prev_version) else ""
display_version = (
version if (project != prev_project or version != prev_version) else ""
)
rows.append(
cast(
@@ -122,7 +126,9 @@ def calculate_summary(
**{f"version:{k}": round(v, 2) for k, v in by_project_version.items()},
}
if by_activity:
result.update({f"activity:{k}": round(v, 2) for k, v in by_activity_name.items()})
result.update(
{f"activity:{k}": round(v, 2) for k, v in by_activity_name.items()}
)
return result

View File

@@ -21,7 +21,9 @@ def resolve_env_vars(value: str) -> str:
var_name = match.group(1)
env_value = os.environ.get(var_name)
if env_value is None:
logger.warning("Environment variable %s is not set, using empty string", var_name)
logger.warning(
"Environment variable %s is not set, using empty string", var_name
)
return ""
return env_value
@@ -182,5 +184,7 @@ def save_period_to_config(
ensure_config_dir(path.parent)
with open(path, "w", encoding="utf-8") as fh:
yaml.dump(raw, fh, allow_unicode=True, default_flow_style=False, sort_keys=False)
yaml.dump(
raw, fh, allow_unicode=True, default_flow_style=False, sort_keys=False
)
path.chmod(0o600)

View File

@@ -62,7 +62,9 @@ def test_cli_returns_zero_on_no_entries(mock_fetch):
@mock.patch.dict(os.environ, {}, clear=True)
def test_cli_config_error():
"""Невалидный конфиг -- выход 1."""
code = main(["--date", "2026-01-01--2026-01-31", "--config-path", "/nonexistent/config.yml"])
code = main(
["--date", "2026-01-01--2026-01-31", "--config-path", "/nonexistent/config.yml"]
)
assert code == 1
@@ -183,7 +185,7 @@ def test_cli_config_file_loading(mock_fetch, tmp_path):
"""--config загружает переменные из указанного .env-файла."""
config_path = tmp_path / "custom.env"
config_path.write_text(
"REDMINE_URL=https://config.redmine.loc\n" "REDMINE_API_KEY=config-token\n",
"REDMINE_URL=https://config.redmine.loc\nREDMINE_API_KEY=config-token\n",
encoding="utf-8",
)
mock_fetch.return_value = None
@@ -248,7 +250,9 @@ def test_cli_prints_readable_timeout_error(mock_fetch, capsys):
"""CLI выводит понятное сообщение при таймауте."""
from redmine_reporter.client import RedmineAPIError
mock_fetch.side_effect = RedmineAPIError("Redmine request timed out after 30 seconds")
mock_fetch.side_effect = RedmineAPIError(
"Redmine request timed out after 30 seconds"
)
code = main(["--date", "2026-01-01--2026-01-31"])
captured = capsys.readouterr()
assert code == 1
@@ -263,7 +267,9 @@ def test_cli_no_time_passed_to_formatter(mock_fetch, tmp_path):
mock_fetch.return_value = [(issue, 1.0)]
output = str(tmp_path / "report.xlsx")
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get_formatter:
with mock.patch(
"redmine_reporter.cli.get_formatter_by_extension"
) as mock_get_formatter:
mock_formatter = mock.MagicMock()
mock_get_formatter.return_value = mock_formatter
main(["--date", "2026-01-01--2026-01-31", "--output", output, "--no-time"])
@@ -454,7 +460,9 @@ class TestOutputPathResolution:
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_output_without_extension_appends_default_format(self, mock_fetch, tmp_path):
def test_output_without_extension_appends_default_format(
self, mock_fetch, tmp_path
):
"""--output report без расширения → добавляет .xlsx."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
@@ -548,7 +556,9 @@ class TestCommitFlag:
mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml"
config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": True}}))
config_path.write_text(
yaml.dump({"period": {"precision": "date", "dynamic": True}})
)
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
@@ -583,7 +593,9 @@ class TestCommitFlag:
mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml"
config_path.write_text(yaml.dump({"period": {"precision": "datetime", "dynamic": True}}))
config_path.write_text(
yaml.dump({"period": {"precision": "datetime", "dynamic": True}})
)
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
@@ -630,7 +642,9 @@ class TestCommitFlag:
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_dynamic_false_overwrites_defaults(self, mock_save, mock_fetch, tmp_path):
def test_commit_dynamic_false_overwrites_defaults(
self, mock_save, mock_fetch, tmp_path
):
"""При dynamic=false --commit перезаписывает default_from/to."""
import yaml
@@ -638,7 +652,9 @@ class TestCommitFlag:
mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml"
config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": False}}))
config_path.write_text(
yaml.dump({"period": {"precision": "date", "dynamic": False}})
)
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
@@ -663,7 +679,9 @@ class TestCommitFlag:
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_without_output_uses_default_path(self, mock_save, mock_fetch, tmp_path):
def test_commit_without_output_uses_default_path(
self, mock_save, mock_fetch, tmp_path
):
"""--commit без --output сохраняет файл по шаблону из конфига."""
import yaml
@@ -702,7 +720,9 @@ class TestCommitFlag:
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_prints_info_to_stderr(self, mock_save, mock_fetch, capsys, tmp_path):
def test_commit_prints_info_to_stderr(
self, mock_save, mock_fetch, capsys, tmp_path
):
"""--commit выводит сообщение о фиксации в stderr."""
import yaml
@@ -710,7 +730,9 @@ class TestCommitFlag:
mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml"
config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": True}}))
config_path.write_text(
yaml.dump({"period": {"precision": "date", "dynamic": True}})
)
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
@@ -741,7 +763,9 @@ class TestCommitFlag:
mock_fetch.return_value = []
config_path = tmp_path / "config.yml"
config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": True}}))
config_path.write_text(
yaml.dump({"period": {"precision": "date", "dynamic": True}})
)
code = main(
[
@@ -791,10 +815,13 @@ class TestSendFlag:
output = str(tmp_path / "report.xlsx")
code = main(
[
"--date", "2026-06-01--2026-06-30",
"--output", output,
"--date",
"2026-06-01--2026-06-30",
"--output",
output,
"--send",
"--config-path", str(config_path),
"--config-path",
str(config_path),
]
)
assert code == 0
@@ -804,7 +831,9 @@ class TestSendFlag:
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
def test_send_without_output_saves_to_default_path(self, mock_get, mock_send, mock_fetch, tmp_path):
def test_send_without_output_saves_to_default_path(
self, mock_get, mock_send, mock_fetch, tmp_path
):
"""--send без --output сохраняет файл по шаблону, затем отправляет."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
@@ -830,9 +859,11 @@ class TestSendFlag:
code = main(
[
"--date", "2026-06-01--2026-06-30",
"--date",
"2026-06-01--2026-06-30",
"--send",
"--config-path", str(config_path),
"--config-path",
str(config_path),
]
)
assert code == 0
@@ -851,10 +882,13 @@ class TestSendFlag:
code = main(
[
"--date", "2026-06-01--2026-06-30",
"--output", str(tmp_path / "report.xlsx"),
"--date",
"2026-06-01--2026-06-30",
"--output",
str(tmp_path / "report.xlsx"),
"--send",
"--config-path", str(config_path),
"--config-path",
str(config_path),
]
)
assert code == 1
@@ -865,7 +899,9 @@ class TestSendFlag:
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
def test_send_smtp_error_reported_to_stderr(self, mock_get, mock_send, mock_fetch, tmp_path, capsys):
def test_send_smtp_error_reported_to_stderr(
self, mock_get, mock_send, mock_fetch, tmp_path, capsys
):
"""Ошибка SMTP выводится в stderr, exit code 1."""
from redmine_reporter.client import RedmineAPIError
@@ -873,7 +909,9 @@ class TestSendFlag:
mock_fetch.return_value = [(issue, 1.0)]
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
mock_send.side_effect = RedmineAPIError("Не удалось подключиться к SMTP-серверу bad:587")
mock_send.side_effect = RedmineAPIError(
"Не удалось подключиться к SMTP-серверу bad:587"
)
config_path = tmp_path / "config.yml"
config_path.write_text(
@@ -890,10 +928,13 @@ class TestSendFlag:
code = main(
[
"--date", "2026-06-01--2026-06-30",
"--output", str(tmp_path / "report.xlsx"),
"--date",
"2026-06-01--2026-06-30",
"--output",
str(tmp_path / "report.xlsx"),
"--send",
"--config-path", str(config_path),
"--config-path",
str(config_path),
]
)
assert code == 1
@@ -932,11 +973,14 @@ class TestSendFlag:
code = main(
[
"--date", "2026-06-01--2026-06-30",
"--output", str(tmp_path / "report.xlsx"),
"--date",
"2026-06-01--2026-06-30",
"--output",
str(tmp_path / "report.xlsx"),
"--send",
"--commit",
"--config-path", str(config_path),
"--config-path",
str(config_path),
]
)
assert code == 0

View File

@@ -157,7 +157,9 @@ def test_fetch_uses_username_password_when_no_api_key(mock_redmine_class):
assert "key" not in kwargs
@mock.patch.dict(os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "/tmp/redmine-ca.pem"}, clear=True)
@mock.patch.dict(
os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "/tmp/redmine-ca.pem"}, clear=True
)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_uses_custom_verify_path(mock_redmine_class):
mock_redmine = mock_redmine_class.return_value
@@ -390,7 +392,9 @@ def test_fetch_mounts_retry_adapter(mock_redmine_class):
assert "http://" in prefixes
# Проверяем retry-конфигурацию адаптера
https_adapter = next(call.args[1] for call in mount_calls if call.args[0] == "https://")
https_adapter = next(
call.args[1] for call in mount_calls if call.args[0] == "https://"
)
max_retries = https_adapter.max_retries
assert max_retries.total == 3
assert 429 in max_retries.status_forcelist
@@ -422,7 +426,9 @@ def test_fetch_chunks_large_issue_count(mock_redmine_class):
ids_str = kwargs.get("issue_id", "")
call_chunks.append(ids_str)
ids = [int(x) for x in ids_str.split(",")]
return [mock.MagicMock(id=i, project="P", subject="T", status="New") for i in ids]
return [
mock.MagicMock(id=i, project="P", subject="T", status="New") for i in ids
]
mock_redmine.issue.filter.side_effect = issue_filter_side_effect
@@ -473,7 +479,9 @@ def test_dedup_filters_entries_created_before_cutoff(mock_redmine_class):
mock_issue2.project = "P"
mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2]
result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff)
result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
)
assert result is not None
assert len(result) == 1
@@ -482,7 +490,9 @@ def test_dedup_filters_entries_created_before_cutoff(mock_redmine_class):
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_dedup_filters_entries_with_old_created_even_if_updated_recently(mock_redmine_class):
def test_dedup_filters_entries_with_old_created_even_if_updated_recently(
mock_redmine_class,
):
"""Записи с created_on < cutoff исключаются даже при updated_on >= cutoff (AND-логика)."""
from datetime import datetime, timezone
@@ -500,7 +510,9 @@ def test_dedup_filters_entries_with_old_created_even_if_updated_recently(mock_re
mock_redmine.time_entry.filter.return_value = [e1]
mock_redmine.issue.filter.return_value = []
result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff)
result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
)
assert result is None
@@ -531,7 +543,9 @@ def test_dedup_keeps_entries_created_after_cutoff(mock_redmine_class):
mock_issue1.status = "New"
mock_redmine.issue.filter.return_value = [mock_issue1]
result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff)
result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
)
assert result is not None
assert len(result) == 1
@@ -563,7 +577,9 @@ def test_dedup_entries_without_created_on_are_kept(mock_redmine_class):
mock_issue1.status = "New"
mock_redmine.issue.filter.return_value = [mock_issue1]
result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff)
result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
)
assert result is not None
assert len(result) == 1
@@ -589,7 +605,9 @@ def test_dedup_handles_string_created_on(mock_redmine_class):
mock_redmine.time_entry.filter.return_value = [e1]
mock_redmine.issue.filter.return_value = []
result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff)
result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
)
assert result is None
@@ -665,7 +683,9 @@ def test_dedup_mixed_entries_correct_filtering(mock_redmine_class):
mock_issue_akiy.status = "New"
mock_redmine.issue.filter.return_value = [mock_issue_new, mock_issue_akiy]
result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff)
result = fetch_issues_with_spent_time(
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
)
assert result is not None
assert len(result) == 2

View File

@@ -328,7 +328,9 @@ class TestConfigYamlFallback:
assert Config.get_redmine_url() == "https://cli-override.example.com"
@mock.patch.dict(os.environ, {"REDMINE_URL": "https://env-redmine.example.com/"}, clear=True)
@mock.patch.dict(
os.environ, {"REDMINE_URL": "https://env-redmine.example.com/"}, clear=True
)
def test_env_beats_yaml(self):
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
@@ -501,7 +503,9 @@ class TestComputeNextPeriod:
def test_datetime_precision_moves_by_seconds(self):
from redmine_reporter.config import compute_next_period
nf, nt = compute_next_period("2026-06-30T09:00:00", "2026-06-30T12:00:00", "datetime")
nf, nt = compute_next_period(
"2026-06-30T09:00:00", "2026-06-30T12:00:00", "datetime"
)
assert nf == "2026-06-30T12:00:01"
assert nt == "2026-06-30T15:00:01"

View File

@@ -134,7 +134,9 @@ def odt_formatter():
)
),
):
yield ODTFormatter(author="Тест Автор", from_date="2026-01-01", to_date="2026-01-31")
yield ODTFormatter(
author="Тест Автор", from_date="2026-01-01", to_date="2026-01-31"
)
# -- Тесты упаковки formatters как полноценного пакета --
@@ -171,7 +173,11 @@ def _simulate_missing_odfpy():
saved = {}
for key in list(sys.modules.keys()):
if key == "odf" or key.startswith("odf.") or key == "redmine_reporter.formatters.odt":
if (
key == "odf"
or key.startswith("odf.")
or key == "redmine_reporter.formatters.odt"
):
saved[key] = sys.modules.pop(key)
return saved
@@ -474,7 +480,9 @@ def test_odt_empty_author_no_garbage_in_header(fake_rows):
)
),
):
formatter = ODTFormatter(author="", from_date="2026-01-01", to_date="2026-01-31")
formatter = ODTFormatter(
author="", from_date="2026-01-01", to_date="2026-01-31"
)
doc = formatter.format(fake_rows)
from odf.text import P
@@ -501,7 +509,9 @@ def test_odt_formatter_save_creates_valid_file(fake_rows, tmp_path):
)
),
):
formatter = ODTFormatter(author="Тест", from_date="2026-01-01", to_date="2026-01-31")
formatter = ODTFormatter(
author="Тест", from_date="2026-01-01", to_date="2026-01-31"
)
output_file = tmp_path / "report.odt"
formatter.save(fake_rows, str(output_file))
@@ -537,7 +547,9 @@ def test_odt_has_covered_cells_for_spans(fake_rows):
)
),
):
formatter = ODTFormatter(author="Тест", from_date="2026-01-01", to_date="2026-01-31")
formatter = ODTFormatter(
author="Тест", from_date="2026-01-01", to_date="2026-01-31"
)
doc = formatter.format(fake_rows)
from odf.table import CoveredTableCell

View File

@@ -36,12 +36,16 @@ class TestBuildMessage:
def test_subject_substitution(self):
"""{author} и {period} подставляются в тему."""
cfg = _make_email_config(subject="Отчёт {author} за {period}", attach=False)
msg = build_message(cfg, "/tmp/report.xlsx", "Кокос А.Н.", "2026-06-01--2026-06-30")
msg = build_message(
cfg, "/tmp/report.xlsx", "Кокос А.Н.", "2026-06-01--2026-06-30"
)
assert msg["Subject"] == "Отчёт Кокос А.Н. за 2026-06-01--2026-06-30"
def test_body_substitution(self):
"""{author} и {period} подставляются в тело."""
cfg = _make_email_config(body_text="Автор: {author}, период: {period}", attach=False)
cfg = _make_email_config(
body_text="Автор: {author}, период: {period}", attach=False
)
msg = build_message(cfg, "/tmp/report.xlsx", "Иванов", "Q1")
plain_part = msg.get_payload()[0]
assert "Автор: Иванов, период: Q1" in plain_part.as_string()
@@ -81,14 +85,20 @@ class TestBuildMessage:
msg = build_message(cfg, str(report), "A", "P")
assert len(msg.get_payload()) == 1
@pytest.mark.parametrize("ext,expected_mime", [
(".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
@pytest.mark.parametrize(
"ext,expected_mime",
[
(
".xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
),
(".odt", "application/vnd.oasis.opendocument.text"),
(".csv", "text/csv"),
(".html", "text/html"),
(".json", "application/json"),
(".md", "text/markdown"),
])
],
)
def test_mime_type_by_extension(self, ext, expected_mime, tmp_path):
report = tmp_path / f"report{ext}"
report.write_text("content")
@@ -154,7 +164,9 @@ class TestSendReport:
with mock.patch("smtplib.SMTP") as mock_smtp_class:
mock_smtp = mock.MagicMock()
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
mock_smtp.login.side_effect = smtplib.SMTPAuthenticationError(535, b"Bad auth")
mock_smtp.login.side_effect = smtplib.SMTPAuthenticationError(
535, b"Bad auth"
)
with pytest.raises(RedmineAPIError, match="Ошибка аутентификации SMTP"):
send_report(cfg, str(report), "A", "P")
@@ -176,7 +188,9 @@ class TestSendReport:
with mock.patch("smtplib.SMTP") as mock_smtp_class:
mock_smtp = mock.MagicMock()
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
mock_smtp.send_message.side_effect = smtplib.SMTPException("Something went wrong")
mock_smtp.send_message.side_effect = smtplib.SMTPException(
"Something went wrong"
)
with pytest.raises(RedmineAPIError, match="Ошибка отправки письма"):
send_report(cfg, str(report), "A", "P")

View File

@@ -217,7 +217,9 @@ class TestSavePeriodToConfig:
from redmine_reporter.yaml_config import save_period_to_config
config_path = tmp_path / "config.yml"
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True)
save_period_to_config(
str(config_path), "2026-06-01", "2026-06-30", "date", True
)
with open(config_path) as fh:
data = yaml.safe_load(fh)
@@ -233,10 +235,12 @@ class TestSavePeriodToConfig:
config_path = tmp_path / "config.yml"
config_path.write_text(
"period:\n" " last_used:\n" " from: '2026-05-01'\n" " to: '2026-05-31'\n"
"period:\n last_used:\n from: '2026-05-01'\n to: '2026-05-31'\n"
)
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True)
save_period_to_config(
str(config_path), "2026-06-01", "2026-06-30", "date", True
)
with open(config_path) as fh:
data = yaml.safe_load(fh)
@@ -251,10 +255,12 @@ class TestSavePeriodToConfig:
config_path = tmp_path / "config.yml"
config_path.write_text(
"period:\n" " default_from: '2026-01-01'\n" " default_to: '2026-01-31'\n"
"period:\n default_from: '2026-01-01'\n default_to: '2026-01-31'\n"
)
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", False)
save_period_to_config(
str(config_path), "2026-06-01", "2026-06-30", "date", False
)
with open(config_path) as fh:
data = yaml.safe_load(fh)
@@ -299,7 +305,9 @@ class TestSavePeriodToConfig:
" dir: /tmp\n"
)
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True)
save_period_to_config(
str(config_path), "2026-06-01", "2026-06-30", "date", True
)
with open(config_path) as fh:
data = yaml.safe_load(fh)
@@ -314,7 +322,9 @@ class TestSavePeriodToConfig:
config_path = tmp_path / "config.yml"
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True)
save_period_to_config(
str(config_path), "2026-06-01", "2026-06-30", "date", True
)
mode = config_path.stat().st_mode & 0o777
assert mode == 0o600