From 0ee281d6bb512350877a706ddc4e5ced93024209 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Wed, 14 Oct 2026 11:51:16 +0200 Subject: [PATCH 1/4] fix: end every written file with a newline Whole-file rewrites come back from the API without their final newline, so a unit-test script that tests on a linter's final-newline rule (RuboCop Layout/TrailingEmptyLines, ESLint eol-last, Ruff W292) would fail every fix round. --- file_utils.py | 15 ++++++++++ tests/test_file_utils.py | 60 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/file_utils.py b/file_utils.py index b67ed9c8..3238ad5b 100644 --- a/file_utils.py +++ b/file_utils.py @@ -142,7 +142,20 @@ def get_existing_files_content(build_folder, existing_files): return existing_files_content +def ensure_final_newline(content: str) -> str: + """Ends non-empty text with a newline, as linters expect (e.g. RuboCop, ESLint eol-last, Ruff W292).""" + if content and not content.endswith("\n"): + return content + "\n" + return content + + def store_response_files(target_folder, response_files, existing_files): + """Writes the files returned by the API to target_folder. None content deletes the file. + + Every written file ends with a newline - whole-file rewrites come back from the API without one. The + added newline is also written back into response_files, so callers that diff response_files against + the folder afterwards see exactly what is on disk. + """ for file_name in response_files: full_file_name = os.path.join(target_folder, file_name) @@ -156,6 +169,8 @@ def store_response_files(target_folder, response_files, existing_files): continue + response_files[file_name] = ensure_final_newline(response_files[file_name]) + os.makedirs(os.path.dirname(full_file_name), exist_ok=True) with open(full_file_name, "w", encoding="utf-8") as f: diff --git a/tests/test_file_utils.py b/tests/test_file_utils.py index 6b306792..c36ad81f 100644 --- a/tests/test_file_utils.py +++ b/tests/test_file_utils.py @@ -3,7 +3,8 @@ import pytest -from file_utils import load_linked_resources, store_response_files +from diff_utils import get_code_diff +from file_utils import get_existing_files_content, load_linked_resources, store_response_files from plain2code_exceptions import UnsupportedBase64Content, UnsupportedResourceType from plain2code_utils import MAX_BASE64_BLOB_LENGTH @@ -84,9 +85,64 @@ def test_load_linked_resources_real_base64_image_raises(template_dir): def test_store_response_files_writes_unicode_as_utf8(template_dir): # Content with a non-cp1252 character (📍 U+1F4CD) must be written as UTF-8 # regardless of the platform's default text encoding (e.g. cp1252 on Windows). - content = "Location 📍 marker" + content = "Location 📍 marker\n" store_response_files(template_dir, {"notes.md": content}, []) file_path = os.path.join(template_dir, "notes.md") with open(file_path, "rb") as f: assert f.read().decode("utf-8") == content + + +def _read_bytes(folder, file_name): + with open(os.path.join(folder, file_name), "rb") as f: + return f.read().decode("utf-8") + + +@pytest.mark.parametrize( + "content, expected", + [ + ("class Foo\nend", "class Foo\nend\n"), + ("class Foo\nend\n", "class Foo\nend\n"), + ("class Foo\nend\n\n", "class Foo\nend\n\n"), + ("class Foo\r\nend\r\n", "class Foo\r\nend\r\n"), + ("", ""), + ], +) +def test_store_response_files_ends_every_text_file_with_a_newline(template_dir, content, expected): + # Linters gate on a final newline (RuboCop Layout/TrailingEmptyLines, ESLint eol-last, Ruff W292), so a + # whole-file rewrite that comes back without one must not reach the disk that way. + store_response_files(template_dir, {"app/foo.rb": content}, []) + + assert _read_bytes(template_dir, "app/foo.rb") == expected + + +def test_store_response_files_hands_back_the_content_it_wrote(template_dir): + # Callers diff the response files against the build folder after storing them (fix journal, conformance + # fixes handed to the unit tests fixer), so the response files must match what is on disk. + response_files = {"app/foo.rb": "class Foo\nend", "app/bar.rb": "class Bar\nend\n"} + + store_response_files(template_dir, response_files, []) + + assert response_files == {"app/foo.rb": "class Foo\nend\n", "app/bar.rb": "class Bar\nend\n"} + + +def test_rewriting_a_file_without_its_final_newline_does_not_change_it(template_dir): + existing_files = store_response_files(template_dir, {"app/foo.rb": "class Foo\nend\n"}, []) + existing_files_content = get_existing_files_content(template_dir, existing_files) + + rewrite = {"app/foo.rb": "class Foo\nend"} + store_response_files(template_dir, rewrite, existing_files) + + assert get_existing_files_content(template_dir, existing_files) == existing_files_content + assert get_code_diff(rewrite, existing_files_content) == {} + + +def test_store_response_files_still_deletes_a_file_whose_content_is_none(template_dir): + existing_files = store_response_files(template_dir, {"app/foo.rb": "class Foo\nend\n"}, []) + + response_files = {"app/foo.rb": None} + store_response_files(template_dir, response_files, existing_files) + + assert not os.path.exists(os.path.join(template_dir, "app/foo.rb")) + assert existing_files == [] + assert response_files == {"app/foo.rb": None} From 6d59a5c223eef6dea6486d262c5abbda3b8f8710 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Thu, 15 Oct 2026 10:06:12 +0200 Subject: [PATCH 2/4] fix: write files without newline translation Text mode turned each \n into \r\n on native Windows, so the file on disk differed from response_files and content already ending in \r\n was written as \r\r\n. store_response_files now writes with newline="" so the bytes on disk are exactly the normalized content on every platform. --- file_utils.py | 5 +++-- tests/test_file_utils.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/file_utils.py b/file_utils.py index 3238ad5b..c86bfdd9 100644 --- a/file_utils.py +++ b/file_utils.py @@ -154,7 +154,8 @@ def store_response_files(target_folder, response_files, existing_files): Every written file ends with a newline - whole-file rewrites come back from the API without one. The added newline is also written back into response_files, so callers that diff response_files against - the folder afterwards see exactly what is on disk. + the folder afterwards see exactly what is on disk. Content is written byte-for-byte, without newline + translation, so the disk matches response_files on every platform. """ for file_name in response_files: full_file_name = os.path.join(target_folder, file_name) @@ -173,7 +174,7 @@ def store_response_files(target_folder, response_files, existing_files): os.makedirs(os.path.dirname(full_file_name), exist_ok=True) - with open(full_file_name, "w", encoding="utf-8") as f: + with open(full_file_name, "w", encoding="utf-8", newline="") as f: f.write(response_files[file_name]) if file_name not in existing_files: diff --git a/tests/test_file_utils.py b/tests/test_file_utils.py index c36ad81f..af8ac7e3 100644 --- a/tests/test_file_utils.py +++ b/tests/test_file_utils.py @@ -1,8 +1,10 @@ import os import tempfile +import _pyio import pytest +import file_utils from diff_utils import get_code_diff from file_utils import get_existing_files_content, load_linked_resources, store_response_files from plain2code_exceptions import UnsupportedBase64Content, UnsupportedResourceType @@ -146,3 +148,19 @@ def test_store_response_files_still_deletes_a_file_whose_content_is_none(templat assert not os.path.exists(os.path.join(template_dir, "app/foo.rb")) assert existing_files == [] assert response_files == {"app/foo.rb": None} + + +def test_store_response_files_writes_the_same_bytes_where_text_mode_writes_crlf(template_dir, monkeypatch): + """Files hold exactly the normalized content even where text mode writes CRLF line endings (native Windows).""" + # Python's reference io (_pyio) takes its line separator from os.linesep, so here it translates like Windows. + monkeypatch.setattr(os, "linesep", "\r\n") + monkeypatch.setattr(file_utils, "open", _pyio.open, raising=False) + with _pyio.open(os.path.join(template_dir, "probe.txt"), "w") as probe: + probe.write("\n") + assert _read_bytes(template_dir, "probe.txt") == "\r\n" + + response_files = {"app/foo.rb": "class Foo\nend", "app/bar.rb": "class Bar\r\nend\r\n"} + store_response_files(template_dir, response_files, []) + + assert _read_bytes(template_dir, "app/foo.rb") == response_files["app/foo.rb"] == "class Foo\nend\n" + assert _read_bytes(template_dir, "app/bar.rb") == response_files["app/bar.rb"] == "class Bar\r\nend\r\n" From 1d997c9b49ac268cb4709b2935ddd1bfa38340f7 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Thu, 15 Oct 2026 10:07:11 +0200 Subject: [PATCH 3/4] docs: document the functions the newline fix touches --- tests/test_file_utils.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/test_file_utils.py b/tests/test_file_utils.py index af8ac7e3..eafc0c95 100644 --- a/tests/test_file_utils.py +++ b/tests/test_file_utils.py @@ -85,8 +85,7 @@ def test_load_linked_resources_real_base64_image_raises(template_dir): def test_store_response_files_writes_unicode_as_utf8(template_dir): - # Content with a non-cp1252 character (📍 U+1F4CD) must be written as UTF-8 - # regardless of the platform's default text encoding (e.g. cp1252 on Windows). + """A non-cp1252 character (📍 U+1F4CD) is written as UTF-8, whatever the platform's default encoding.""" content = "Location 📍 marker\n" store_response_files(template_dir, {"notes.md": content}, []) @@ -96,6 +95,7 @@ def test_store_response_files_writes_unicode_as_utf8(template_dir): def _read_bytes(folder, file_name): + """Returns a file's content exactly as stored on disk, without newline translation.""" with open(os.path.join(folder, file_name), "rb") as f: return f.read().decode("utf-8") @@ -111,16 +111,20 @@ def _read_bytes(folder, file_name): ], ) def test_store_response_files_ends_every_text_file_with_a_newline(template_dir, content, expected): - # Linters gate on a final newline (RuboCop Layout/TrailingEmptyLines, ESLint eol-last, Ruff W292), so a - # whole-file rewrite that comes back without one must not reach the disk that way. + """A missing final newline is added, as linters such as RuboCop, ESLint and Ruff require. + + An existing final newline is not doubled, and empty content stays empty. + """ store_response_files(template_dir, {"app/foo.rb": content}, []) assert _read_bytes(template_dir, "app/foo.rb") == expected def test_store_response_files_hands_back_the_content_it_wrote(template_dir): - # Callers diff the response files against the build folder after storing them (fix journal, conformance - # fixes handed to the unit tests fixer), so the response files must match what is on disk. + """response_files holds the content as written, so diffs taken after storing match the disk. + + The fix journal and the conformance fixes handed to the unit tests fixer take such diffs. + """ response_files = {"app/foo.rb": "class Foo\nend", "app/bar.rb": "class Bar\nend\n"} store_response_files(template_dir, response_files, []) @@ -129,6 +133,7 @@ def test_store_response_files_hands_back_the_content_it_wrote(template_dir): def test_rewriting_a_file_without_its_final_newline_does_not_change_it(template_dir): + """A rewrite that drops only the final newline leaves the file byte-identical and produces no diff.""" existing_files = store_response_files(template_dir, {"app/foo.rb": "class Foo\nend\n"}, []) existing_files_content = get_existing_files_content(template_dir, existing_files) @@ -140,6 +145,7 @@ def test_rewriting_a_file_without_its_final_newline_does_not_change_it(template_ def test_store_response_files_still_deletes_a_file_whose_content_is_none(template_dir): + """None content still deletes the file and removes it from existing_files, and is not turned into a newline.""" existing_files = store_response_files(template_dir, {"app/foo.rb": "class Foo\nend\n"}, []) response_files = {"app/foo.rb": None} From 92215301789ae44fd0f66b6f79ac9ac79ca0db84 Mon Sep 17 00:00:00 2001 From: Goran Dodig Date: Thu, 15 Oct 2026 10:09:50 +0200 Subject: [PATCH 4/4] test: import _pyio inside the test that uses it --- tests/test_file_utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_file_utils.py b/tests/test_file_utils.py index eafc0c95..28b77862 100644 --- a/tests/test_file_utils.py +++ b/tests/test_file_utils.py @@ -1,7 +1,6 @@ import os import tempfile -import _pyio import pytest import file_utils @@ -158,6 +157,9 @@ def test_store_response_files_still_deletes_a_file_whose_content_is_none(templat def test_store_response_files_writes_the_same_bytes_where_text_mode_writes_crlf(template_dir, monkeypatch): """Files hold exactly the normalized content even where text mode writes CRLF line endings (native Windows).""" + # Imported here: isort 8 and isort 9 disagree on which section a top-level `import _pyio` belongs to. + import _pyio + # Python's reference io (_pyio) takes its line separator from os.linesep, so here it translates like Windows. monkeypatch.setattr(os, "linesep", "\r\n") monkeypatch.setattr(file_utils, "open", _pyio.open, raising=False)