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() from_date, to_date = parts[0].strip(), parts[1].strip()
date_pattern = r"\d{4}-\d{2}-\d{2}" 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") raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
try: try:
@@ -46,7 +48,7 @@ def _run_init_config(config_path: str, force: bool) -> int:
if path.exists() and not force: if path.exists() and not force:
print( 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, file=sys.stderr,
) )
return 1 return 1
@@ -90,7 +92,9 @@ def _run_init_config(config_path: str, force: bool) -> int:
ensure_config_dir(path.parent) ensure_config_dir(path.parent)
with open(path, "w", encoding="utf-8") as fh: 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) path.chmod(0o600)
sections_found = [s for s in data if data[s]] 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" "--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("--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("--config", help="Path to .env config file")
parser.add_argument("--verbose", action="store_true", help="Enable verbose output") 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("--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: if output_arg is None:
print("Не удалось определить путь для сохранения отчёта.", file=sys.stderr) print(
"Не удалось определить путь для сохранения отчёта.", file=sys.stderr
)
return 1 return 1
ret = _save_and_maybe_send( ret = _save_and_maybe_send(
@@ -446,7 +454,9 @@ def main(argv: Optional[List[str]] = None) -> int:
) )
if output_arg is None: if output_arg is None:
print("Не удалось определить путь для сохранения отчёта.", file=sys.stderr) print(
"Не удалось определить путь для сохранения отчёта.", file=sys.stderr
)
return 1 return 1
ret = _save_and_maybe_send( ret = _save_and_maybe_send(
@@ -493,7 +503,9 @@ def main(argv: Optional[List[str]] = None) -> int:
to_str = to_date to_str = to_date
try: 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: except Exception as e:
print( print(
f"Не удалось сохранить период в конфиг: {e}", 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): for i in range(0, len(issue_ids), ISSUE_ID_CHUNK_SIZE):
chunk = issue_ids[i : i + ISSUE_ID_CHUNK_SIZE] chunk = issue_ids[i : i + ISSUE_ID_CHUNK_SIZE]
issue_list_str = ",".join(str(x) for x in chunk) 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) all_issues.extend(issues)
return all_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 {} activities_lookup = _load_time_entry_activities(redmine) if by_activity else {}
time_entries = list( 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: except RedmineAPIError:
raise raise

View File

@@ -106,7 +106,8 @@ class AppConfig:
output_dir=cls._resolve_str(raw, "output", "dir"), output_dir=cls._resolve_str(raw, "output", "dir"),
output_filename=cls._resolve_str(raw, "output", "filename") output_filename=cls._resolve_str(raw, "output", "filename")
or "{author}_{from}_{to}.{ext}", 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), 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. """Compute the next report period based on the last committed period.
- For a full calendar month → next full calendar month. - For a full calendar month → next full calendar month.

View File

@@ -15,7 +15,9 @@ from .base import Formatter
class ODTFormatter(Formatter): class ODTFormatter(Formatter):
"""Форматтер для экспорта в ODT.""" """Форматтер для экспорта в 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. Форматирует данные в объект 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) doc = load(f)
# Удаляем все текстовые параграфы из шаблона, оставляя только # Удаляем все текстовые параграфы из шаблона, оставляя только
@@ -52,7 +58,9 @@ class ODTFormatter(Formatter):
# Стиль ячеек # Стиль ячеек
cell_style_name = "TableCellStyle" cell_style_name = "TableCellStyle"
cell_style = Style(name=cell_style_name, family="table-cell") 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) cell_style.addElement(cell_props)
doc.automaticstyles.addElement(cell_style) doc.automaticstyles.addElement(cell_style)
@@ -100,7 +108,9 @@ class ODTFormatter(Formatter):
# в остальных — covered-cell для валидности ODF (#13) # в остальных — covered-cell для валидности ODF (#13)
if first_version_in_project and first_row_in_version: if first_version_in_project and first_row_in_version:
cell_project = TableCell(stylename=cell_style_name) 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) p = P(stylename=para_style_name, text=project)
cell_project.addElement(p) cell_project.addElement(p)
row.addElement(cell_project) row.addElement(cell_project)
@@ -111,7 +121,9 @@ class ODTFormatter(Formatter):
# в остальных — covered-cell для валидности ODF (#13) # в остальных — covered-cell для валидности ODF (#13)
if first_row_in_version: if first_row_in_version:
cell_version = TableCell(stylename=cell_style_name) 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) p = P(stylename=para_style_name, text=version)
cell_version.addElement(p) cell_version.addElement(p)
row.addElement(cell_version) 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") _HEADER_FILL = PatternFill(
_TOTAL_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") start_color="D9E1F2", end_color="D9E1F2", fill_type="solid"
)
_TOTAL_FILL = PatternFill(
start_color="FFF2CC", end_color="FFF2CC", fill_type="solid"
)
_BORDER = Border( _BORDER = Border(
left=Side(style="thin"), left=Side(style="thin"),
right=Side(style="thin"), right=Side(style="thin"),
@@ -40,7 +44,15 @@ class XLSXFormatter(Formatter):
else: else:
ws.title = "Report" 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) ws.append(headers)
self._style_header_row(ws, headers) self._style_header_row(ws, headers)
@@ -89,7 +101,10 @@ class XLSXFormatter(Formatter):
) )
self._style_total_row(ws, current_row, bold=False) self._style_total_row(ws, current_row, bold=False)
ws.merge_cells( 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 current_row += 1
@@ -99,7 +114,8 @@ class XLSXFormatter(Formatter):
if not self.no_time: if not self.no_time:
project_hours = sum( 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( ws.append(
[ [
@@ -114,7 +130,10 @@ class XLSXFormatter(Formatter):
) )
self._style_total_row(ws, current_row, bold=True) self._style_total_row(ws, current_row, bold=True)
ws.merge_cells( 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 current_row += 1
project_totals[project] = project_hours project_totals[project] = project_hours
@@ -137,7 +156,9 @@ class XLSXFormatter(Formatter):
] ]
) )
self._style_total_row(ws, current_row, bold=True) 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: for start, end in project_ranges:
ws.merge_cells(start_row=start, start_column=1, end_row=end, end_column=1) 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.font = Font(bold=True)
cell.fill = self._HEADER_FILL cell.fill = self._HEADER_FILL
cell.border = self._BORDER 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: def _style_data_row(self, ws: Worksheet, row: int) -> None:
for col_idx in range(1, 8): for col_idx in range(1, 8):
@@ -190,7 +213,15 @@ class XLSXFormatter(Formatter):
def _apply_column_widths(self, ws: Worksheet) -> None: 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 row in ws.iter_rows(min_row=2, max_row=ws.max_row):
for col_idx, cell in enumerate(row, start=1): for col_idx, cell in enumerate(row, start=1):

View File

@@ -40,8 +40,12 @@ def build_message(
period: str, period: str,
) -> MIMEMultipart: ) -> MIMEMultipart:
"""Формирует MIME-письмо с подстановками и вложением.""" """Формирует MIME-письмо с подстановками и вложением."""
subject = email_config.subject.replace("{author}", author).replace("{period}", period) subject = email_config.subject.replace("{author}", author).replace(
body = email_config.body_text.replace("{author}", author).replace("{period}", period) "{period}", period
)
body = email_config.body_text.replace("{author}", author).replace(
"{period}", period
)
msg = MIMEMultipart() msg = MIMEMultipart()
msg["Subject"] = subject msg["Subject"] = subject
@@ -91,7 +95,9 @@ def send_report(
""" """
smtp_cfg = email_config.smtp smtp_cfg = email_config.smtp
msg = build_message(email_config, file_path, author, period) 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: try:
with smtplib.SMTP(smtp_cfg.host, smtp_cfg.port, timeout=SMTP_TIMEOUT) as server: 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: if smtp_cfg.user:
server.login(smtp_cfg.user, smtp_cfg.password) 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: except smtplib.SMTPAuthenticationError:
raise RedmineAPIError( raise RedmineAPIError(
"Ошибка аутентификации SMTP. Проверьте логин и пароль." "Ошибка аутентификации SMTP. Проверьте логин и пароль."
) from None ) from None
except TimeoutError: except TimeoutError:
raise RedmineAPIError( raise RedmineAPIError("Таймаут соединения с SMTP-сервером.") from None
"Таймаут соединения с SMTP-сервером."
) from None
except smtplib.SMTPException as exc: except smtplib.SMTPException as exc:
raise RedmineAPIError( raise RedmineAPIError(f"Ошибка отправки письма: {exc}") from exc
f"Ошибка отправки письма: {exc}"
) from exc
except OSError as exc: except OSError as exc:
raise RedmineAPIError( raise RedmineAPIError(
f"Не удалось подключиться к SMTP-серверу {smtp_cfg.host}:{smtp_cfg.port}" f"Не удалось подключиться к SMTP-серверу {smtp_cfg.host}:{smtp_cfg.port}"
) from exc ) 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] = [] rows: List[ReportRow] = []
prev_project: str = "" prev_project: str = ""
@@ -67,7 +69,9 @@ def build_grouped_report(
time_text = "" time_text = ""
display_project = project if project != prev_project else "" 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( rows.append(
cast( cast(
@@ -122,7 +126,9 @@ def calculate_summary(
**{f"version:{k}": round(v, 2) for k, v in by_project_version.items()}, **{f"version:{k}": round(v, 2) for k, v in by_project_version.items()},
} }
if by_activity: 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 return result

View File

@@ -21,7 +21,9 @@ def resolve_env_vars(value: str) -> str:
var_name = match.group(1) var_name = match.group(1)
env_value = os.environ.get(var_name) env_value = os.environ.get(var_name)
if env_value is None: 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 ""
return env_value return env_value
@@ -182,5 +184,7 @@ def save_period_to_config(
ensure_config_dir(path.parent) ensure_config_dir(path.parent)
with open(path, "w", encoding="utf-8") as fh: 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) 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) @mock.patch.dict(os.environ, {}, clear=True)
def test_cli_config_error(): def test_cli_config_error():
"""Невалидный конфиг -- выход 1.""" """Невалидный конфиг -- выход 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 assert code == 1
@@ -183,7 +185,7 @@ def test_cli_config_file_loading(mock_fetch, tmp_path):
"""--config загружает переменные из указанного .env-файла.""" """--config загружает переменные из указанного .env-файла."""
config_path = tmp_path / "custom.env" config_path = tmp_path / "custom.env"
config_path.write_text( 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", encoding="utf-8",
) )
mock_fetch.return_value = None mock_fetch.return_value = None
@@ -248,7 +250,9 @@ def test_cli_prints_readable_timeout_error(mock_fetch, capsys):
"""CLI выводит понятное сообщение при таймауте.""" """CLI выводит понятное сообщение при таймауте."""
from redmine_reporter.client import RedmineAPIError 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"]) code = main(["--date", "2026-01-01--2026-01-31"])
captured = capsys.readouterr() captured = capsys.readouterr()
assert code == 1 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)] mock_fetch.return_value = [(issue, 1.0)]
output = str(tmp_path / "report.xlsx") 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_formatter = mock.MagicMock()
mock_get_formatter.return_value = mock_formatter mock_get_formatter.return_value = mock_formatter
main(["--date", "2026-01-01--2026-01-31", "--output", output, "--no-time"]) 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.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @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.""" """--output report без расширения → добавляет .xlsx."""
issue = _MockIssue() issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)] mock_fetch.return_value = [(issue, 1.0)]
@@ -548,7 +556,9 @@ class TestCommitFlag:
mock_fetch.return_value = [(issue, 1.0)] mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml" 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: with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock() mock_formatter = mock.MagicMock()
@@ -583,7 +593,9 @@ class TestCommitFlag:
mock_fetch.return_value = [(issue, 1.0)] mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml" 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: with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock() mock_formatter = mock.MagicMock()
@@ -630,7 +642,9 @@ class TestCommitFlag:
@mock.patch.dict(os.environ, VALID_ENV, clear=True) @mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config") @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.""" """При dynamic=false --commit перезаписывает default_from/to."""
import yaml import yaml
@@ -638,7 +652,9 @@ class TestCommitFlag:
mock_fetch.return_value = [(issue, 1.0)] mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml" 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: with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock() mock_formatter = mock.MagicMock()
@@ -663,7 +679,9 @@ class TestCommitFlag:
@mock.patch.dict(os.environ, VALID_ENV, clear=True) @mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config") @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 сохраняет файл по шаблону из конфига.""" """--commit без --output сохраняет файл по шаблону из конфига."""
import yaml import yaml
@@ -702,7 +720,9 @@ class TestCommitFlag:
@mock.patch.dict(os.environ, VALID_ENV, clear=True) @mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config") @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.""" """--commit выводит сообщение о фиксации в stderr."""
import yaml import yaml
@@ -710,7 +730,9 @@ class TestCommitFlag:
mock_fetch.return_value = [(issue, 1.0)] mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml" 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: with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock() mock_formatter = mock.MagicMock()
@@ -741,7 +763,9 @@ class TestCommitFlag:
mock_fetch.return_value = [] mock_fetch.return_value = []
config_path = tmp_path / "config.yml" 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( code = main(
[ [
@@ -791,10 +815,13 @@ class TestSendFlag:
output = str(tmp_path / "report.xlsx") output = str(tmp_path / "report.xlsx")
code = main( code = main(
[ [
"--date", "2026-06-01--2026-06-30", "--date",
"--output", output, "2026-06-01--2026-06-30",
"--output",
output,
"--send", "--send",
"--config-path", str(config_path), "--config-path",
str(config_path),
] ]
) )
assert code == 0 assert code == 0
@@ -804,7 +831,9 @@ class TestSendFlag:
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.send_report") @mock.patch("redmine_reporter.cli.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension") @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 сохраняет файл по шаблону, затем отправляет.""" """--send без --output сохраняет файл по шаблону, затем отправляет."""
issue = _MockIssue() issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)] mock_fetch.return_value = [(issue, 1.0)]
@@ -830,9 +859,11 @@ class TestSendFlag:
code = main( code = main(
[ [
"--date", "2026-06-01--2026-06-30", "--date",
"2026-06-01--2026-06-30",
"--send", "--send",
"--config-path", str(config_path), "--config-path",
str(config_path),
] ]
) )
assert code == 0 assert code == 0
@@ -851,10 +882,13 @@ class TestSendFlag:
code = main( code = main(
[ [
"--date", "2026-06-01--2026-06-30", "--date",
"--output", str(tmp_path / "report.xlsx"), "2026-06-01--2026-06-30",
"--output",
str(tmp_path / "report.xlsx"),
"--send", "--send",
"--config-path", str(config_path), "--config-path",
str(config_path),
] ]
) )
assert code == 1 assert code == 1
@@ -865,7 +899,9 @@ class TestSendFlag:
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.send_report") @mock.patch("redmine_reporter.cli.send_report")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension") @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.""" """Ошибка SMTP выводится в stderr, exit code 1."""
from redmine_reporter.client import RedmineAPIError from redmine_reporter.client import RedmineAPIError
@@ -873,7 +909,9 @@ class TestSendFlag:
mock_fetch.return_value = [(issue, 1.0)] mock_fetch.return_value = [(issue, 1.0)]
mock_formatter = mock.MagicMock() mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter 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 = tmp_path / "config.yml"
config_path.write_text( config_path.write_text(
@@ -890,10 +928,13 @@ class TestSendFlag:
code = main( code = main(
[ [
"--date", "2026-06-01--2026-06-30", "--date",
"--output", str(tmp_path / "report.xlsx"), "2026-06-01--2026-06-30",
"--output",
str(tmp_path / "report.xlsx"),
"--send", "--send",
"--config-path", str(config_path), "--config-path",
str(config_path),
] ]
) )
assert code == 1 assert code == 1
@@ -932,11 +973,14 @@ class TestSendFlag:
code = main( code = main(
[ [
"--date", "2026-06-01--2026-06-30", "--date",
"--output", str(tmp_path / "report.xlsx"), "2026-06-01--2026-06-30",
"--output",
str(tmp_path / "report.xlsx"),
"--send", "--send",
"--commit", "--commit",
"--config-path", str(config_path), "--config-path",
str(config_path),
] ]
) )
assert code == 0 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 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") @mock.patch("redmine_reporter.client.Redmine")
def test_fetch_uses_custom_verify_path(mock_redmine_class): def test_fetch_uses_custom_verify_path(mock_redmine_class):
mock_redmine = mock_redmine_class.return_value mock_redmine = mock_redmine_class.return_value
@@ -390,7 +392,9 @@ def test_fetch_mounts_retry_adapter(mock_redmine_class):
assert "http://" in prefixes assert "http://" in prefixes
# Проверяем retry-конфигурацию адаптера # Проверяем 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 max_retries = https_adapter.max_retries
assert max_retries.total == 3 assert max_retries.total == 3
assert 429 in max_retries.status_forcelist 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", "") ids_str = kwargs.get("issue_id", "")
call_chunks.append(ids_str) call_chunks.append(ids_str)
ids = [int(x) for x in ids_str.split(",")] 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 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_issue2.project = "P"
mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2] 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 result is not None
assert len(result) == 1 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.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine") @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-логика).""" """Записи с created_on < cutoff исключаются даже при updated_on >= cutoff (AND-логика)."""
from datetime import datetime, timezone 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.time_entry.filter.return_value = [e1]
mock_redmine.issue.filter.return_value = [] 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 assert result is None
@@ -531,7 +543,9 @@ def test_dedup_keeps_entries_created_after_cutoff(mock_redmine_class):
mock_issue1.status = "New" mock_issue1.status = "New"
mock_redmine.issue.filter.return_value = [mock_issue1] 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 result is not None
assert len(result) == 1 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_issue1.status = "New"
mock_redmine.issue.filter.return_value = [mock_issue1] 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 result is not None
assert len(result) == 1 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.time_entry.filter.return_value = [e1]
mock_redmine.issue.filter.return_value = [] 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 assert result is None
@@ -665,7 +683,9 @@ def test_dedup_mixed_entries_correct_filtering(mock_redmine_class):
mock_issue_akiy.status = "New" mock_issue_akiy.status = "New"
mock_redmine.issue.filter.return_value = [mock_issue_new, mock_issue_akiy] 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 result is not None
assert len(result) == 2 assert len(result) == 2

View File

@@ -328,7 +328,9 @@ class TestConfigYamlFallback:
assert Config.get_redmine_url() == "https://cli-override.example.com" 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): def test_env_beats_yaml(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml" yaml_path = Path(tmp) / "config.yml"
@@ -501,7 +503,9 @@ class TestComputeNextPeriod:
def test_datetime_precision_moves_by_seconds(self): def test_datetime_precision_moves_by_seconds(self):
from redmine_reporter.config import compute_next_period 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 nf == "2026-06-30T12:00:01"
assert nt == "2026-06-30T15: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 как полноценного пакета -- # -- Тесты упаковки formatters как полноценного пакета --
@@ -171,7 +173,11 @@ def _simulate_missing_odfpy():
saved = {} saved = {}
for key in list(sys.modules.keys()): 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) saved[key] = sys.modules.pop(key)
return saved 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) doc = formatter.format(fake_rows)
from odf.text import P 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" output_file = tmp_path / "report.odt"
formatter.save(fake_rows, str(output_file)) 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) doc = formatter.format(fake_rows)
from odf.table import CoveredTableCell from odf.table import CoveredTableCell

View File

@@ -36,12 +36,16 @@ class TestBuildMessage:
def test_subject_substitution(self): def test_subject_substitution(self):
"""{author} и {period} подставляются в тему.""" """{author} и {period} подставляются в тему."""
cfg = _make_email_config(subject="Отчёт {author} за {period}", attach=False) 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" assert msg["Subject"] == "Отчёт Кокос А.Н. за 2026-06-01--2026-06-30"
def test_body_substitution(self): def test_body_substitution(self):
"""{author} и {period} подставляются в тело.""" """{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") msg = build_message(cfg, "/tmp/report.xlsx", "Иванов", "Q1")
plain_part = msg.get_payload()[0] plain_part = msg.get_payload()[0]
assert "Автор: Иванов, период: Q1" in plain_part.as_string() assert "Автор: Иванов, период: Q1" in plain_part.as_string()
@@ -81,14 +85,20 @@ class TestBuildMessage:
msg = build_message(cfg, str(report), "A", "P") msg = build_message(cfg, str(report), "A", "P")
assert len(msg.get_payload()) == 1 assert len(msg.get_payload()) == 1
@pytest.mark.parametrize("ext,expected_mime", [ @pytest.mark.parametrize(
(".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"), "ext,expected_mime",
(".odt", "application/vnd.oasis.opendocument.text"), [
(".csv", "text/csv"), (
(".html", "text/html"), ".xlsx",
(".json", "application/json"), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
(".md", "text/markdown"), ),
]) (".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): def test_mime_type_by_extension(self, ext, expected_mime, tmp_path):
report = tmp_path / f"report{ext}" report = tmp_path / f"report{ext}"
report.write_text("content") report.write_text("content")
@@ -154,7 +164,9 @@ class TestSendReport:
with mock.patch("smtplib.SMTP") as mock_smtp_class: with mock.patch("smtplib.SMTP") as mock_smtp_class:
mock_smtp = mock.MagicMock() mock_smtp = mock.MagicMock()
mock_smtp_class.return_value.__enter__.return_value = mock_smtp 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"): with pytest.raises(RedmineAPIError, match="Ошибка аутентификации SMTP"):
send_report(cfg, str(report), "A", "P") send_report(cfg, str(report), "A", "P")
@@ -176,7 +188,9 @@ class TestSendReport:
with mock.patch("smtplib.SMTP") as mock_smtp_class: with mock.patch("smtplib.SMTP") as mock_smtp_class:
mock_smtp = mock.MagicMock() mock_smtp = mock.MagicMock()
mock_smtp_class.return_value.__enter__.return_value = mock_smtp 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="Ошибка отправки письма"): with pytest.raises(RedmineAPIError, match="Ошибка отправки письма"):
send_report(cfg, str(report), "A", "P") 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 from redmine_reporter.yaml_config import save_period_to_config
config_path = tmp_path / "config.yml" 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: with open(config_path) as fh:
data = yaml.safe_load(fh) data = yaml.safe_load(fh)
@@ -233,10 +235,12 @@ class TestSavePeriodToConfig:
config_path = tmp_path / "config.yml" config_path = tmp_path / "config.yml"
config_path.write_text( 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: with open(config_path) as fh:
data = yaml.safe_load(fh) data = yaml.safe_load(fh)
@@ -251,10 +255,12 @@ class TestSavePeriodToConfig:
config_path = tmp_path / "config.yml" config_path = tmp_path / "config.yml"
config_path.write_text( 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: with open(config_path) as fh:
data = yaml.safe_load(fh) data = yaml.safe_load(fh)
@@ -299,7 +305,9 @@ class TestSavePeriodToConfig:
" dir: /tmp\n" " 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: with open(config_path) as fh:
data = yaml.safe_load(fh) data = yaml.safe_load(fh)
@@ -314,7 +322,9 @@ class TestSavePeriodToConfig:
config_path = tmp_path / "config.yml" 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 mode = config_path.stat().st_mode & 0o777
assert mode == 0o600 assert mode == 0o600