Skip to content

Commit 32b0897

Browse files
committed
fix: harden py38/39 cli typing and backup collision handling
1 parent fae899a commit 32b0897

4 files changed

Lines changed: 69 additions & 10 deletions

File tree

src/reqsync/cli.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import json
1212
from enum import Enum
1313
from pathlib import Path
14-
from typing import Any
14+
from typing import Any, Optional
1515

1616
import typer
1717
from click.core import ParameterSource
@@ -157,7 +157,7 @@ def _build_options(ctx: typer.Context, use_config: bool) -> Options:
157157
return merge_options(options, overrides)
158158

159159

160-
def _print_human_summary(payload: JsonResult, options: Options, report_path: Path | None) -> None:
160+
def _print_human_summary(payload: JsonResult, options: Options, report_path: Optional[Path]) -> None:
161161
mode = "apply"
162162
if options.check:
163163
mode = "check"
@@ -197,11 +197,11 @@ def _resolve_output_mode(output: OutputModeEnum, stdout_json: bool) -> OutputMod
197197

198198
def _emit_result(
199199
payload: JsonResult,
200-
result_diff: str | None,
200+
result_diff: Optional[str],
201201
options: Options,
202202
output_mode: OutputModeEnum,
203203
) -> None:
204-
report_path: Path | None = None
204+
report_path: Optional[Path] = None
205205
if options.json_report:
206206
report_path = write_json_report(payload, str(options.json_report))
207207

@@ -264,12 +264,12 @@ def run_command(
264264
help="Allowlisted pip args passed to upgrade command.",
265265
rich_help_panel="Execution",
266266
),
267-
only: str | None = typer.Option(
267+
only: Optional[str] = typer.Option(
268268
None,
269269
help="Comma-separated package globs to include.",
270270
rich_help_panel="Filtering",
271271
),
272-
exclude: str | None = typer.Option(
272+
exclude: Optional[str] = typer.Option(
273273
None,
274274
help="Comma-separated package globs to exclude.",
275275
rich_help_panel="Filtering",
@@ -296,7 +296,7 @@ def run_command(
296296
help="Stdout output mode.",
297297
rich_help_panel="Output",
298298
),
299-
json_report: Path | None = typer.Option(
299+
json_report: Optional[Path] = typer.Option(
300300
None,
301301
help="Write machine-readable JSON report to file.",
302302
rich_help_panel="Output",
@@ -348,7 +348,7 @@ def run_command(
348348
help="For duplicate packages, rewrite only final occurrence.",
349349
rich_help_panel="Safety",
350350
),
351-
log_file: Path | None = typer.Option(
351+
log_file: Optional[Path] = typer.Option(
352352
None,
353353
help="Optional log file path.",
354354
rich_help_panel="Logging",

src/reqsync/io.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,15 +80,29 @@ def _prune_old_backups(path: Path, suffix: str, keep_last: int) -> None:
8080
logging.warning("Unable to prune old backup: %s", stale)
8181

8282

83+
def _build_unique_timestamped_backup_path(path: Path, suffix: str) -> Path:
84+
"""Return a collision-safe timestamped backup path for one source file."""
85+
86+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
87+
base_name = f"{path.name}{suffix}.{stamp}"
88+
backup = path.with_name(base_name)
89+
90+
counter = 1
91+
while backup.exists():
92+
backup = path.with_name(f"{base_name}-{counter:02d}")
93+
counter += 1
94+
95+
return backup
96+
97+
8398
def backup_file(path: Path, suffix: str, timestamped: bool, keep_last: int) -> Path:
8499
"""Create a backup copy and return its path."""
85100

86101
if not path.exists():
87102
raise FileNotFoundError(f"Cannot back up missing file: {path}")
88103

89104
if timestamped:
90-
stamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
91-
backup = path.with_name(f"{path.name}{suffix}.{stamp}")
105+
backup = _build_unique_timestamped_backup_path(path=path, suffix=suffix)
92106
else:
93107
backup = path.with_name(f"{path.name}{suffix}")
94108

tests/test_cli.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77

88
from __future__ import annotations
99

10+
import inspect
1011
import textwrap
1112
from pathlib import Path
1213

1314
from typer.testing import CliRunner
1415

16+
from reqsync import cli as cli_mod
1517
from reqsync import core as core_mod
1618
from reqsync._types import ExitCode
1719
from reqsync.cli import app
@@ -92,3 +94,20 @@ def test_cli_version_option_prints_version() -> None:
9294
result = runner.invoke(app, ["--version"])
9395
assert result.exit_code == 0
9496
assert result.output.startswith("reqsync ")
97+
98+
99+
def test_cli_annotations_avoid_pep604_runtime_union_for_py39_typer_compatibility() -> None:
100+
callbacks = [
101+
cli_mod.app_callback,
102+
cli_mod.run_command,
103+
cli_mod.help_command,
104+
cli_mod.version_command,
105+
cli_mod.mcp_server,
106+
]
107+
108+
for callback in callbacks:
109+
for annotation in callback.__annotations__.values():
110+
normalized = annotation if isinstance(annotation, str) else repr(annotation)
111+
assert "|" not in normalized, f"PEP 604 union found in {callback.__name__}: {normalized}"
112+
113+
inspect.signature(callback)

tests/test_io.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from __future__ import annotations
99

1010
from reqsync import core as core_mod
11+
from reqsync import io as io_mod
1112
from reqsync._types import Options
1213
from reqsync.core import sync
1314
from reqsync.io import backup_file, read_text_preserve, write_text_preserve
@@ -64,3 +65,28 @@ def test_timestamped_backup_pruning_can_be_disabled(tmp_path) -> None:
6465

6566
backups = sorted(tmp_path.glob("requirements.txt.bak.*"))
6667
assert len(backups) == 4
68+
69+
70+
def test_timestamped_backup_naming_avoids_collisions(tmp_path, monkeypatch) -> None:
71+
target = tmp_path / "requirements.txt"
72+
target.write_text("requests>=2.0\n", encoding="utf-8")
73+
74+
class _FixedNow:
75+
@staticmethod
76+
def strftime(_fmt: str) -> str:
77+
return "20260223-000000-000000"
78+
79+
class _FixedDateTime:
80+
@staticmethod
81+
def now() -> _FixedNow:
82+
return _FixedNow()
83+
84+
monkeypatch.setattr(io_mod, "datetime", _FixedDateTime)
85+
86+
for i in range(3):
87+
target.write_text(f"requests>={i}.0\n", encoding="utf-8")
88+
backup_file(target, suffix=".bak", timestamped=True, keep_last=0)
89+
90+
backups = sorted(tmp_path.glob("requirements.txt.bak.*"))
91+
assert len(backups) == 3
92+
assert len({backup.name for backup in backups}) == 3

0 commit comments

Comments
 (0)