From 096856009047b4a1b1f09b443909c7c43e85994f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BA=D0=BE=D1=81=20=D0=90=D1=80=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=20=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2=D0=B8?= =?UTF-8?q?=D1=87?= Date: Fri, 10 Jul 2026 12:39:24 +0700 Subject: [PATCH] style: apply ruff format to all source files --- redmine_reporter/cli.py | 26 +++++-- redmine_reporter/client.py | 8 ++- redmine_reporter/config.py | 7 +- redmine_reporter/formatters/odt.py | 22 ++++-- redmine_reporter/formatters/xlsx.py | 49 ++++++++++--- redmine_reporter/mailer.py | 25 ++++--- redmine_reporter/report_builder.py | 12 +++- redmine_reporter/yaml_config.py | 8 ++- tests/test_cli.py | 104 ++++++++++++++++++++-------- tests/test_client.py | 40 ++++++++--- tests/test_config.py | 8 ++- tests/test_formatters.py | 22 ++++-- tests/test_mailer.py | 38 ++++++---- tests/test_yaml_config.py | 24 +++++-- 14 files changed, 286 insertions(+), 107 deletions(-) diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py index 642f3f6..3d5c166 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -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}", diff --git a/redmine_reporter/client.py b/redmine_reporter/client.py index 1501adc..04bb941 100644 --- a/redmine_reporter/client.py +++ b/redmine_reporter/client.py @@ -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 diff --git a/redmine_reporter/config.py b/redmine_reporter/config.py index 7aeb87a..898bf37 100644 --- a/redmine_reporter/config.py +++ b/redmine_reporter/config.py @@ -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. diff --git a/redmine_reporter/formatters/odt.py b/redmine_reporter/formatters/odt.py index 31b70c7..8eb7acd 100644 --- a/redmine_reporter/formatters/odt.py +++ b/redmine_reporter/formatters/odt.py @@ -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) diff --git a/redmine_reporter/formatters/xlsx.py b/redmine_reporter/formatters/xlsx.py index 08824b7..ed3a7ae 100644 --- a/redmine_reporter/formatters/xlsx.py +++ b/redmine_reporter/formatters/xlsx.py @@ -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): diff --git a/redmine_reporter/mailer.py b/redmine_reporter/mailer.py index 0e431dd..485963c 100644 --- a/redmine_reporter/mailer.py +++ b/redmine_reporter/mailer.py @@ -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 - diff --git a/redmine_reporter/report_builder.py b/redmine_reporter/report_builder.py index 22d6273..044edae 100644 --- a/redmine_reporter/report_builder.py +++ b/redmine_reporter/report_builder.py @@ -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 diff --git a/redmine_reporter/yaml_config.py b/redmine_reporter/yaml_config.py index b87602a..c38dd96 100644 --- a/redmine_reporter/yaml_config.py +++ b/redmine_reporter/yaml_config.py @@ -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) diff --git a/tests/test_cli.py b/tests/test_cli.py index b06f72c..ed77ea3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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 diff --git a/tests/test_client.py b/tests/test_client.py index 82a902c..3ec44cc 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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 diff --git a/tests/test_config.py b/tests/test_config.py index 512ee87..8d6f38f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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" diff --git a/tests/test_formatters.py b/tests/test_formatters.py index 7bd1578..dc98707 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -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 diff --git a/tests/test_mailer.py b/tests/test_mailer.py index 78901db..1211a41 100644 --- a/tests/test_mailer.py +++ b/tests/test_mailer.py @@ -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"), - (".odt", "application/vnd.oasis.opendocument.text"), - (".csv", "text/csv"), - (".html", "text/html"), - (".json", "application/json"), - (".md", "text/markdown"), - ]) + @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") diff --git a/tests/test_yaml_config.py b/tests/test_yaml_config.py index 65359a1..e6adbb2 100644 --- a/tests/test_yaml_config.py +++ b/tests/test_yaml_config.py @@ -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