From fde8f3a7e370c19aab5f2151f1a2cfaa9bd71b16 Mon Sep 17 00:00:00 2001 From: ParamChordiya Date: Tue, 30 Jun 2026 21:59:14 -0500 Subject: [PATCH 1/3] Write formatted files atomically to avoid corruption on write failure format_file_in_place() previously opened the target file for writing directly, which truncates it immediately. If the write then failed partway through (e.g. disk full), the original file was left wiped or corrupted with no way to recover it. Now the reformatted contents are written to a temporary file in the same directory, fsync'd, and atomically swapped in with os.replace(). On any failure the temporary file is discarded and the original is left completely untouched. Symlinked targets are written through to their resolved real path so the symlink itself is preserved rather than being replaced by a regular file. Fixes #2479 --- CHANGES.md | 4 ++++ src/black/__init__.py | 26 +++++++++++++++++++++-- tests/test_black.py | 48 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 8a4c7456a6a..8a3855407ba 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,10 @@ - Add support for NO_COLOR environment variable to disable ANSI output (#5129) - No spurious target version warning when runtime version is included in a --target-version flag (#5167) +- Fix in-place formatting truncating or corrupting the source file if writing the + reformatted contents fails partway through, e.g. because the disk is full. Black now + writes to a temporary file in the same directory and atomically replaces the + original once the write has fully succeeded (#5207) ### Highlights diff --git a/src/black/__init__.py b/src/black/__init__.py index e4a0edda879..f2a361c78f1 100644 --- a/src/black/__init__.py +++ b/src/black/__init__.py @@ -1,8 +1,11 @@ import io import json +import os import platform import re +import shutil import sys +import tempfile import tokenize import traceback from collections.abc import ( @@ -998,8 +1001,27 @@ def format_file_in_place( dst_contents = header.decode(encoding) + dst_contents if write_back == WriteBack.YES: - with open(src, "w", encoding=encoding, newline=newline) as f: - f.write(dst_contents) + # Write to a temporary file in the same directory (so it's on the same + # filesystem) and atomically replace the target with it. This avoids + # leaving the file truncated or corrupted if writing fails partway + # through, e.g. because the disk is full (see issue #2479). + # If `src` is itself a symlink, write through to its resolved target + # instead of replacing the symlink with a regular file. + real_src = src.resolve() if src.is_symlink() else src + fd, tmp_file = tempfile.mkstemp( + dir=real_src.parent, prefix=f".{real_src.name}.", suffix=".tmp" + ) + tmp_path = Path(tmp_file) + try: + with os.fdopen(fd, "w", encoding=encoding, newline=newline) as f: + f.write(dst_contents) + f.flush() + os.fsync(f.fileno()) + shutil.copymode(real_src, tmp_path) + os.replace(tmp_path, real_src) + except BaseException: + tmp_path.unlink(missing_ok=True) + raise elif write_back in (WriteBack.DIFF, WriteBack.COLOR_DIFF): now = datetime.now(timezone.utc) src_name = f"{src}\t{then}" diff --git a/tests/test_black.py b/tests/test_black.py index c958acd86ba..8eb12600a2e 100644 --- a/tests/test_black.py +++ b/tests/test_black.py @@ -170,6 +170,54 @@ def test_one_empty_line_ff(self) -> None: os.unlink(tmp_file) self.assertFormatEqual(expected, actual) + def test_ff_preserves_file_on_write_failure(self) -> None: + # Regression test for #2479: if writing the reformatted contents fails + # partway through (e.g. because the disk is full), the original file + # must be left untouched instead of being truncated or corrupted. + source = "print('hello')\n" + with TemporaryDirectory() as workspace: + tmp_file = Path(workspace) / "source.py" + tmp_file.write_text(source, encoding="utf-8") + with patch( + "black.os.fsync", side_effect=OSError("No space left on device") + ): + with self.assertRaises(OSError): + ff(tmp_file, write_back=black.WriteBack.YES) + self.assertEqual(tmp_file.read_text(encoding="utf-8"), source) + # No leftover temporary file should remain in the directory. + leftover = [p for p in Path(workspace).iterdir() if p != tmp_file] + self.assertEqual(leftover, []) + + def test_ff_preserves_permissions(self) -> None: + if system() == "Windows": + return + + with TemporaryDirectory() as workspace: + tmp_file = Path(workspace) / "source.py" + tmp_file.write_text("print('hello' )\n", encoding="utf-8") + tmp_file.chmod(0o640) + self.assertTrue(ff(tmp_file, write_back=black.WriteBack.YES)) + self.assertEqual(tmp_file.stat().st_mode & 0o777, 0o640) + + def test_ff_writes_through_symlink(self) -> None: + # Regression test: formatting a file via a symlinked path must write + # through to the real target, not replace the symlink with a regular + # file (which atomic replace-by-rename would otherwise do). + with TemporaryDirectory() as workspace: + workspace_path = Path(workspace) + real_file = workspace_path / "real.py" + real_file.write_text("print('hello' )\n", encoding="utf-8") + symlink = workspace_path / "link.py" + try: + symlink.symlink_to(real_file) + except (OSError, NotImplementedError) as e: + self.skipTest(f"Can't create symlinks: {e}") + + self.assertTrue(ff(symlink, write_back=black.WriteBack.YES)) + self.assertTrue(symlink.is_symlink()) + self.assertEqual(os.readlink(symlink), str(real_file)) + self.assertEqual(real_file.read_text(encoding="utf-8"), 'print("hello")\n') + def test_piping(self) -> None: _, source, expected = read_data_from_file( PROJECT_ROOT / "src/black/__init__.py" From 3edca9c0484075e086b77c68cc398f6dbf194367 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 03:02:28 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- CHANGES.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 8a3855407ba..bc651406d74 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,8 +10,8 @@ --target-version flag (#5167) - Fix in-place formatting truncating or corrupting the source file if writing the reformatted contents fails partway through, e.g. because the disk is full. Black now - writes to a temporary file in the same directory and atomically replaces the - original once the write has fully succeeded (#5207) + writes to a temporary file in the same directory and atomically replaces the original + once the write has fully succeeded (#5207) ### Highlights From bf04569b0131e883cd8a1b0f8aa0a2a187b1cb29 Mon Sep 17 00:00:00 2001 From: ParamChordiya Date: Tue, 30 Jun 2026 22:22:48 -0500 Subject: [PATCH 3/3] Fix symlink test on Windows os.readlink() on Windows returns the extended-length \\?\ path form, which doesn't match the plain path string passed to symlink_to(), failing the assertion on every Windows CI job even though the actual fix worked correctly (the symlink was preserved and pointed at the right file). Compare resolved paths instead, which normalizes both sides the same way on every platform. --- tests/test_black.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_black.py b/tests/test_black.py index 8eb12600a2e..9d3e060d687 100644 --- a/tests/test_black.py +++ b/tests/test_black.py @@ -215,7 +215,9 @@ def test_ff_writes_through_symlink(self) -> None: self.assertTrue(ff(symlink, write_back=black.WriteBack.YES)) self.assertTrue(symlink.is_symlink()) - self.assertEqual(os.readlink(symlink), str(real_file)) + # Compare resolved paths rather than the raw readlink() target, since + # Windows may normalize it to an extended-length \\?\ path. + self.assertEqual(symlink.resolve(), real_file.resolve()) self.assertEqual(real_file.read_text(encoding="utf-8"), 'print("hello")\n') def test_piping(self) -> None: