From 1b545ca7a7e947301537aef23e862caa0269f66b Mon Sep 17 00:00:00 2001 From: ANSHUL SINGH <72524975+ekanshul@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:12:35 +0000 Subject: [PATCH] Write --write-changes via a tempfile to avoid emptying files open(..., "w") truncates the target before the new contents are written, so a kill, full disk, or failed write leaves the original file at 0 bytes. Write to a same-directory tempfile, copy the original mode, then os.replace so a failed update keeps the old text. Fixes #4025 --- codespell_lib/_codespell.py | 35 ++++++++++++++++++++++++++++--- codespell_lib/tests/test_basic.py | 35 +++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/codespell_lib/_codespell.py b/codespell_lib/_codespell.py index 1ec09fdf8f..0e8d80df3e 100644 --- a/codespell_lib/_codespell.py +++ b/codespell_lib/_codespell.py @@ -24,7 +24,9 @@ import os import re import shlex +import stat import sys +import tempfile import textwrap from collections.abc import Iterable, Sequence from re import Match, Pattern @@ -1309,13 +1311,40 @@ def parse_file( f" {cfilename}:{cline}: {cwrongword} ==> {crightword}", file=sys.stderr, ) - with open(filename, "w", encoding=encoding, newline="") as f: - for _, _, lines in fragments: - f.writelines(lines) + _write_file_atomically(filename, encoding, fragments) return bad_count +def _write_file_atomically( + filename: str, + encoding: str, + fragments: Iterable[tuple[Any, Any, list[str]]], +) -> None: + """Replace *filename* with a same-directory tempfile so a failed write + cannot leave the original file truncated to zero bytes. + """ + directory = os.path.dirname(os.path.abspath(filename)) + try: + original_mode = stat.S_IMODE(os.stat(filename).st_mode) + except OSError: + original_mode = None + fd, tmp_path = tempfile.mkstemp(prefix=".codespell-", suffix=".tmp", dir=directory) + try: + with os.fdopen(fd, "w", encoding=encoding, newline="") as tmp: + for _, _, lines in fragments: + tmp.writelines(lines) + if original_mode is not None: + os.chmod(tmp_path, original_mode) + os.replace(tmp_path, filename) + except Exception: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + + def flatten_clean_comma_separated_arguments( arguments: Iterable[str], ) -> list[str]: diff --git a/codespell_lib/tests/test_basic.py b/codespell_lib/tests/test_basic.py index 0127f57013..2d98f7e151 100644 --- a/codespell_lib/tests/test_basic.py +++ b/codespell_lib/tests/test_basic.py @@ -3,6 +3,7 @@ import os import os.path as op import re +import stat import subprocess import sys from collections.abc import Generator @@ -175,6 +176,40 @@ def test_basic( assert cs.main(tmp_path) == 0 +def test_write_changes_keeps_original_if_replace_fails( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An interrupted --write-changes must not empty the original file.""" + fname = tmp_path / "a.txt" + original = "this file has an abandonned word\n" + fname.write_text(original) + + def fail_replace(src: str, dst: str) -> None: + raise InterruptedError + + monkeypatch.setattr(os, "replace", fail_replace) + with pytest.raises(InterruptedError): + cs.main("-q", "16", "-w", fname) + assert fname.read_text() == original + assert list(tmp_path.glob(".codespell-*.tmp")) == [] + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX permission bits") +def test_write_changes_preserves_file_mode( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Atomic --write-changes must not reset the original file mode.""" + fname = tmp_path / "a.txt" + fname.write_text("this file has an abandonned word\n") + fname.chmod(0o640) + assert cs.main("-q", "16", "-w", fname) == 0 + assert fname.read_text() == "this file has an abandoned word\n" + assert stat.S_IMODE(fname.stat().st_mode) == 0o640 + + def test_write_changes_lists_changes( tmp_path: Path, capsys: pytest.CaptureFixture[str],