diff --git a/.copier-answers.yml b/.copier-answers.yml index 3ea0a16..a90906b 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,5 +1,5 @@ # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY -_commit: v0.23.0 +_commit: v0.23.1 _src_path: gh:tsvikas/python-template cli_framework: cyclopts format_tool: black diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index 44c33b8..ee53853 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -48,7 +48,7 @@ jobs: id-token: write steps: - name: Download package distributions - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v6 with: name: Packages path: dist/ @@ -70,7 +70,7 @@ jobs: id-token: write steps: - name: Download package distributions - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v6 with: name: Packages path: dist/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1900d6e..b1b4d7f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -57,7 +57,6 @@ repos: - id: debug-statements - id: name-tests-test args: [--pytest-test-first] - exclude: ^tests/test_precommit/dummy_module.py$ - id: requirements-txt-fixer # Symlinks - id: check-symlinks diff --git a/CHANGELOG.md b/CHANGELOG.md index 2141346..d6454f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Trigger the hook also on changes of the pyproject.toml file - Migrate CLI framework from typer to cyclopts - Fix file write permission errors to return exit code 123 instead of raising unhandled exception +- Preserve original line endings (CRLF/LF) when processing pre-commit config files ## v0.4.0 diff --git a/justfile b/justfile index 804a9a3..a9d0282 100644 --- a/justfile +++ b/justfile @@ -40,6 +40,16 @@ update-deps: && list-outdated-deps && exit 1; \ } +# Audit dependencies +audit-deps: + uv run --all-extras --all-groups --with pip-audit pip-audit --skip-editable \ + --ignore-vuln GHSA-4xh5-x5gv-qwph + # pip-audit ignored vuln: + # GHSA-4xh5-x5gv-qwph: + # vuln is in pip, which is not a pinned requirwement + # vuln is fixed in recent python versions + # see https://github.com/pypa/pip/issues/13607 + ### code quality ### @@ -70,13 +80,6 @@ lint: uv run ruff check uv run dmypy run uv run --all-extras --all-groups --with deptry deptry src/ - uv run --all-extras --all-groups --with pip-audit pip-audit --skip-editable \ - --ignore-vuln GHSA-4xh5-x5gv-qwph - # pip-audit ignored vuln: - # GHSA-4xh5-x5gv-qwph: - # vuln is in pip, which is not a pinned requirwement - # vuln is fixed in recent python versions - # see https://github.com/pypa/pip/issues/13607 uv run pre-commit run --all-files # Run Pylint (slow, not used in other tasks) diff --git a/src/sync_with_uv/cli.py b/src/sync_with_uv/cli.py index e26566b..aafe003 100644 --- a/src/sync_with_uv/cli.py +++ b/src/sync_with_uv/cli.py @@ -86,7 +86,9 @@ def process_precommit( # noqa: PLR0913 try: user_repo_mappings, user_version_mappings = load_user_mappings() uv_data = load_uv_lock(uv_lock_filename) - precommit_text = precommit_filename.read_text(encoding="utf-8") + # note that the next line can be simplified in Python>=3.13 using + # read_text with newline="" + precommit_text = precommit_filename.read_bytes().decode(encoding="utf-8") fixed_text, changes = process_precommit_text( precommit_text, uv_data, user_repo_mappings, user_version_mappings ) @@ -98,7 +100,7 @@ def process_precommit( # noqa: PLR0913 _print_diff(precommit_text, fixed_text, precommit_filename, color=color) # update the file if not diff and not check: - precommit_filename.write_text(fixed_text, encoding="utf-8") + precommit_filename.write_text(fixed_text, encoding="utf-8", newline="") # print summary if verbose or not quiet: _print_summary(changes, dry_mode=diff or check) diff --git a/src/sync_with_uv/sync_with_uv.py b/src/sync_with_uv/sync_with_uv.py index 44eada3..6624864 100644 --- a/src/sync_with_uv/sync_with_uv.py +++ b/src/sync_with_uv/sync_with_uv.py @@ -54,15 +54,15 @@ def process_precommit_text( - repo URLs to False when no package mapping exists """ # NOTE: this only works if the 'repo' is the first key of the element - repo_header_re = re.compile(r"\s*-\s*repo\s*:\s*(\S*).*") - repo_rev_re = re.compile(r"\s*rev\s*:\s*(\S*).*") - lines = precommit_text.split("\n") + repo_header_re = re.compile(r"^\s*-\s*repo\s*:\s*(\S*).*$") + repo_rev_re = re.compile(r"^\s*rev\s*:\s*(\S*).*$") + lines = precommit_text.splitlines(keepends=True) new_lines: list[str] = [] repo_url: str | None = None package: str | None = None changes: dict[str, bool | tuple[str, str]] = {} for line in lines: - if repo_header := repo_header_re.fullmatch(line): + if repo_header := repo_header_re.match(line): repo_url = repo_header.group(1) package = repo_to_package(repo_url, user_repo_mappings) if not package: @@ -70,9 +70,7 @@ def process_precommit_text( changes[repo_url] = False elif package not in uv_data: changes[package] = False - elif ( - package and package in uv_data and (repo_rev := repo_rev_re.fullmatch(line)) - ): + elif package and package in uv_data and (repo_rev := repo_rev_re.match(line)): assert repo_url is not None # noqa: S101 current_version = repo_rev.group(1) version_template = repo_to_version_template(repo_url, user_version_mappings) @@ -90,4 +88,4 @@ def process_precommit_text( continue # don't add the line twice new_lines.append(line) - return "\n".join(new_lines), changes + return "".join(new_lines), changes diff --git a/tests/test_cli.py b/tests/test_cli.py index b528848..07c8325 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -357,3 +357,50 @@ def test_cli_missing_precommit_config( assert exc_info.value.code == 1 captured = capsys.readouterr() assert "does not exist" in captured.err + + +@pytest.mark.parametrize( + "line_ending", + ["\n", "\r\n", "\r"], + ids=["LF", "CRLF", "CR"], +) +def test_cli_preserves_line_endings_when_writing( + tmp_path: Path, line_ending: str, capsys: pytest.CaptureFixture[str] +) -> None: + """Test CLI preserves line endings when writing files (issue #24).""" + # Create uv.lock with package version + uv_lock_file = tmp_path / "uv.lock" + uv_lock_file.write_text( + """ +[[package]] +name = "black" +version = "24.0.0" +""" + ) + + # Create pre-commit config with specific line endings + precommit_file = tmp_path / ".pre-commit-config.yaml" + precommit_content = line_ending.join( + [ + "repos:", + "- repo: https://github.com/psf/black-pre-commit-mirror", + " rev: 23.11.0", + " hooks:", + " - id: black", + ] + ) + # Write with binary mode to ensure exact line endings + precommit_content_bytes = precommit_content.encode("utf-8") + precommit_file.write_bytes(precommit_content_bytes) + assert precommit_content_bytes == precommit_file.read_bytes() + + # Run CLI (version is already correct, so no changes needed) + with pytest.raises(SystemExit) as exc_info: + app(["-p", str(precommit_file), "-u", str(uv_lock_file)]) + assert exc_info.value.code == 0 + assert "All done" in capsys.readouterr().err + + assert ( + precommit_content_bytes.replace(b"23.11.0", b"24.0.0") + == precommit_file.read_bytes() + ) diff --git a/tests/test_precommit.py b/tests/test_precommit.py index 9cf22d0..4e256f1 100644 --- a/tests/test_precommit.py +++ b/tests/test_precommit.py @@ -2,6 +2,8 @@ import textwrap from pathlib import Path +import pytest + for GIT_BIN in [ Path("/usr/bin/git"), Path(r"C:\Program Files\Git\bin\git.exe"), @@ -13,10 +15,11 @@ raise RuntimeError("Could not find git binary") # noqa: TRY003 -def test_precommit_hook(datadir: Path) -> None: - repo_dir = datadir - - # initialize git repo +@pytest.fixture +def repo_with_precommit(tmp_path: Path) -> Path: + ruff = ("v0.0.200", "0.1.0") + repo_dir = tmp_path / "repo" + repo_dir.mkdir() subprocess.run([GIT_BIN, "init"], cwd=repo_dir, check=True) subprocess.run( [GIT_BIN, "config", "user.name", "Test User"], cwd=repo_dir, check=True @@ -24,25 +27,48 @@ def test_precommit_hook(datadir: Path) -> None: subprocess.run( [GIT_BIN, "config", "user.email", "test@example.com"], cwd=repo_dir, check=True ) + repo_dir.joinpath(".pre-commit-config.yaml").write_text("repos:\n") + repo_dir.joinpath("uv.lock").write_text("version = 1\nrequires = []\n") subprocess.run(["pre-commit", "install"], cwd=repo_dir, check=True) # noqa: S607 - + if ruff: + repo_dir.joinpath("dummy_module.py").write_text('print("Hello, world!")\n') + with repo_dir.joinpath("pyproject.toml").open("a") as f: + f.write('[tool.ruff]\ntarget-version = "py311"\n') + with repo_dir.joinpath("uv.lock").open("a") as f: + f.write(f'[[package]]\nname = "ruff"\nversion = "{ruff[1]}"\n') + with repo_dir.joinpath(".pre-commit-config.yaml").open("a") as f: + f.write( + " - repo: https://github.com/astral-sh/ruff-pre-commit\n" + f" rev: {ruff[0]}\n" + " hooks:\n" + " - id: ruff\n" + ) # stage and commit without sync-with-uv subprocess.run([GIT_BIN, "add", "."], cwd=repo_dir, check=True) subprocess.run([GIT_BIN, "commit", "-m", "old hooks"], cwd=repo_dir, check=True) - # add sync-with-uv - hook_config = textwrap.dedent( + return repo_dir + + +THIS_REPO_HOOKS = ( + textwrap.dedent( """\ - repo: local hooks: """ ) - hook_config += textwrap.indent( + + textwrap.indent( Path(__file__).parents[1].joinpath(".pre-commit-hooks.yaml").read_text(), " " ) - hook_config = textwrap.indent(hook_config, " ") +) + + +def test_precommit_hook(repo_with_precommit: Path) -> None: + repo_dir = repo_with_precommit + + # add sync-with-uv with repo_dir.joinpath(".pre-commit-config.yaml").open("a") as f: - f.write(hook_config) + f.write(textwrap.indent(THIS_REPO_HOOKS, " ")) # commit and fail subprocess.run([GIT_BIN, "add", "."], cwd=repo_dir, check=True, capture_output=True) @@ -58,17 +84,69 @@ def test_precommit_hook(datadir: Path) -> None: # check the updated .pre-commit-config.yaml updated_config_path = repo_dir / ".pre-commit-config.yaml" updated_config = updated_config_path.read_text() - expected_config = textwrap.dedent( - """\ - repos: + expected_config = textwrap.indent( + textwrap.dedent( + """\ - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.1.0 hooks: - id: ruff """ + ), + " ", ) assert expected_config in updated_config # commit and succeed subprocess.run([GIT_BIN, "add", "."], cwd=repo_dir, check=True) subprocess.run([GIT_BIN, "commit", "-m", "new hooks"], cwd=repo_dir, check=True) + + +@pytest.mark.parametrize("line_ending", ["crlf", "lf"]) +def test_precommit_hook_with_line_ending_fix( + repo_with_precommit: Path, line_ending: str +) -> None: + repo_dir = repo_with_precommit + + mixed_lines_ending_hook = textwrap.dedent( + f"""\ + - repo: https://github.com/pre-commit/pre-commit-hooks\r + rev: v6.0.0 + hooks: + - id: mixed-line-ending + args: [--fix={line_ending}] + """ + ) + # add mixed-line-ending + with repo_dir.joinpath(".pre-commit-config.yaml").open("a", newline="") as f: + f.write(textwrap.indent(mixed_lines_ending_hook, " ")) + # commit and fail + subprocess.run([GIT_BIN, "add", "."], cwd=repo_dir, check=True, capture_output=True) + commit_process = subprocess.run( # noqa: PLW1510 + [GIT_BIN, "commit", "-m", "failing commit - mixed line endings"], + cwd=repo_dir, + capture_output=True, + text=True, + ) + assert commit_process.returncode == 1 + assert ".....Failed" in commit_process.stderr + # commit and succeed + subprocess.run([GIT_BIN, "add", "."], cwd=repo_dir, check=True) + subprocess.run([GIT_BIN, "commit", "-m", "new hooks"], cwd=repo_dir, check=True) + + # add sync-with-uv + with repo_dir.joinpath(".pre-commit-config.yaml").open("a") as f: + f.write(textwrap.indent(THIS_REPO_HOOKS, " ")) + # commit and fail + subprocess.run([GIT_BIN, "add", "."], cwd=repo_dir, check=True, capture_output=True) + commit_process = subprocess.run( # noqa: PLW1510 + [GIT_BIN, "commit", "-m", "failing commit - ruff version"], + cwd=repo_dir, + capture_output=True, + text=True, + ) + assert commit_process.returncode == 1 + assert ".....Failed" in commit_process.stderr + # commit and succeed + subprocess.run([GIT_BIN, "add", "."], cwd=repo_dir, check=True) + subprocess.run([GIT_BIN, "commit", "-m", "new hooks"], cwd=repo_dir, check=True) diff --git a/tests/test_precommit/.pre-commit-config.yaml b/tests/test_precommit/.pre-commit-config.yaml deleted file mode 100644 index 01ea77e..0000000 --- a/tests/test_precommit/.pre-commit-config.yaml +++ /dev/null @@ -1,5 +0,0 @@ -repos: - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.0.200 - hooks: - - id: ruff diff --git a/tests/test_precommit/dummy_module.py b/tests/test_precommit/dummy_module.py deleted file mode 100644 index f7cf60e..0000000 --- a/tests/test_precommit/dummy_module.py +++ /dev/null @@ -1 +0,0 @@ -print("Hello, world!") diff --git a/tests/test_precommit/pyproject.toml b/tests/test_precommit/pyproject.toml deleted file mode 100644 index 8faf5ed..0000000 --- a/tests/test_precommit/pyproject.toml +++ /dev/null @@ -1,2 +0,0 @@ -[tool.ruff] -target-version = "py311" diff --git a/tests/test_precommit/uv.lock b/tests/test_precommit/uv.lock deleted file mode 100644 index cea6359..0000000 --- a/tests/test_precommit/uv.lock +++ /dev/null @@ -1,5 +0,0 @@ -version = 2 -requires = [] -[[package]] -name = "ruff" -version = "0.1.0" diff --git a/tests/test_sync.py b/tests/test_sync.py index b54fbf4..dfa7617 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -301,3 +301,65 @@ def test_process_precommit_text_with_user_mappings() -> None: "custom-tool": ("v1.0.0", "v2.1.0"), "black": ("23.9.1", "23.11.0"), } + + +@pytest.mark.parametrize( + "line_ending", + ["\n", "\r\n", "\r"], + ids=["LF", "CRLF", "CR"], +) +def test_process_precommit_text_preserves_line_endings_no_version_change( + line_ending: str, +) -> None: + """Test that line endings are preserved when version is already correct.""" + precommit_text = line_ending.join( + [ + "repos:", + "- repo: https://github.com/psf/black-pre-commit-mirror", + " rev: 23.11.0", + " hooks:", + " - id: black", + "- repo: https://github.com/unchanged/unchanged", + " rev: 1.2.3", + " hooks:", + " - id: unchanged", + ] + ) + # Package exists in uv.lock but version is already correct + uv_data = {"black": "23.11.0"} + + result, _changes = process_precommit_text(precommit_text, uv_data) + + # Result should be identical to input when version is already correct + assert result == precommit_text + + +@pytest.mark.parametrize( + "line_ending", + ["\n", "\r\n", "\r"], + ids=["LF", "CRLF", "CR"], +) +def test_process_precommit_text_preserves_line_endings( + line_ending: str, +) -> None: + """Test that line endings are preserved and the version is updated in the output.""" + precommit_text = line_ending.join( + [ + "repos:", + "- repo: https://github.com/psf/black-pre-commit-mirror", + " rev: 23.11.0", + " hooks:", + " - id: black", + "- repo: https://github.com/unchanged/unchanged", + " rev: 1.2.3", + " hooks:", + " - id: unchanged", + ] + ) + # Package exists in uv.lock but version is already correct + uv_data = {"black": "24.0.0"} + + result, _changes = process_precommit_text(precommit_text, uv_data) + + # Result should be identical to input when version is already correct + assert result == precommit_text.replace("23.11.0", "24.0.0")