diff --git a/HISTORY.rst b/HISTORY.rst index f94da6bc..de98d403 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,6 +1,11 @@ Changelog --------- +Unreleased +~~~~~~~~~~ + +* Fix ``mutmut run`` crashing when a source path or an ``also_copy`` directory contains a dangling symlink. Dangling symlinks are now recreated as symlinks in ``mutants/``; valid symlinks are still copied as the files they point to (`#582`) + 3.8.0 ~~~~~~~~~~ diff --git a/src/mutmut/utils/file_utils.py b/src/mutmut/utils/file_utils.py index 82ef9498..d8d162e2 100644 --- a/src/mutmut/utils/file_utils.py +++ b/src/mutmut/utils/file_utils.py @@ -23,6 +23,21 @@ def change_cwd(path: Path | str) -> Iterator[None]: os.chdir(old_cwd) +def _copy_keeping_dangling_symlinks(src: str, dst: str, *, follow_symlinks: bool = True) -> str: + # Copy files and valid symlinks as before (following the link), but recreate a + # dangling symlink as a symlink instead of failing: projects may test against them. + if os.path.islink(src) and not os.path.exists(src): + if os.path.lexists(dst): + # left over from a previous run; replace it like copy2 overwrites a file + os.unlink(dst) + os.symlink(os.readlink(src), dst) + return dst + if os.path.islink(dst): + # a link kept by a previous run whose target exists now: copy2 would write through it + os.unlink(dst) + return str(shutil.copy2(src, dst, follow_symlinks=follow_symlinks)) + + def walk_all_files() -> Iterator[tuple[str, str]]: for path in config().source_paths: if not isdir(path): @@ -56,11 +71,11 @@ def copy_src_dir() -> None: continue if isdir(source_path): - shutil.copytree(source_path, target_path) + shutil.copytree(source_path, target_path, copy_function=_copy_keeping_dangling_symlinks) else: target_path.parent.mkdir(exist_ok=True, parents=True) # copy mtime, so we later know that when source_mtime == target_mtime, the file is not (yet) mutated. - shutil.copy2(source_path, target_path) + _copy_keeping_dangling_symlinks(str(source_path), str(target_path)) def copy_also_copy_files() -> None: @@ -74,7 +89,7 @@ def copy_also_copy_files() -> None: if path.is_file(): shutil.copy2(path, destination) else: - shutil.copytree(path, destination, dirs_exist_ok=True) + shutil.copytree(path, destination, dirs_exist_ok=True, copy_function=_copy_keeping_dangling_symlinks) def setup_source_paths() -> None: diff --git a/tests/utils/test_file_utils.py b/tests/utils/test_file_utils.py new file mode 100644 index 00000000..cb684360 --- /dev/null +++ b/tests/utils/test_file_utils.py @@ -0,0 +1,92 @@ +import os +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import mutmut.utils.file_utils as file_utils +from mutmut.utils.file_utils import copy_also_copy_files +from mutmut.utils.file_utils import copy_src_dir + + +@pytest.fixture +def project(tmp_path, monkeypatch): + """A project with a dangling link and a valid relative link under both a test dir and a source dir. + + Regression tests for #582: mutmut crashed when copying a dangling symlink into ``mutants/``. + """ + monkeypatch.chdir(tmp_path) + (tmp_path / "shared").mkdir() + (tmp_path / "shared" / "real.txt").write_text("data\n") + for folder in ("tests/fixtures", "src/pkg"): + (tmp_path / folder).mkdir(parents=True) + os.symlink("missing-target", tmp_path / folder / "dangling") + os.symlink("../../shared/real.txt", tmp_path / folder / "rel_valid") + (tmp_path / "src" / "pkg" / "__init__.py").write_text("") + monkeypatch.setattr( + file_utils, "config", lambda: SimpleNamespace(source_paths=[Path("src")], also_copy=[Path("tests")]) + ) + return tmp_path + + +def _assert_dangling_link_kept(path: Path) -> None: + assert path.is_symlink() + assert os.readlink(path) == "missing-target" + assert not path.exists() + + +def _assert_copied_as_file(path: Path) -> None: + # Valid links are followed, so the copy keeps working inside mutants/ even when + # the relative target lives outside the copied directory. + assert not path.is_symlink() + assert path.read_text() == "data\n" + + +def test_copy_also_copy_files_keeps_dangling_symlink(project): + copy_also_copy_files() + + _assert_dangling_link_kept(project / "mutants" / "tests" / "fixtures" / "dangling") + _assert_copied_as_file(project / "mutants" / "tests" / "fixtures" / "rel_valid") + + +def test_copy_src_dir_keeps_dangling_symlink(project): + copy_src_dir() + + _assert_dangling_link_kept(project / "mutants" / "src" / "pkg" / "dangling") + _assert_copied_as_file(project / "mutants" / "src" / "pkg" / "rel_valid") + + +def test_copies_can_be_repeated_with_dangling_symlinks(project): + # mutmut copies on every run; a dangling link left in mutants/ must not break the next run. + for _ in range(2): + copy_also_copy_files() + copy_src_dir() + + _assert_dangling_link_kept(project / "mutants" / "tests" / "fixtures" / "dangling") + _assert_dangling_link_kept(project / "mutants" / "src" / "pkg" / "dangling") + + +@pytest.mark.parametrize("absolute", [False, True], ids=["relative", "absolute"]) +def test_dangling_symlink_whose_target_appears_is_copied_on_the_next_run(project, absolute): + target = project / "tests" / "fixtures" / "later.txt" + os.symlink(target if absolute else "later.txt", project / "tests" / "fixtures" / "becomes_valid") + copy_also_copy_files() + + target.write_text("data\n") + copy_also_copy_files() + + _assert_copied_as_file(project / "mutants" / "tests" / "fixtures" / "becomes_valid") + assert target.read_text() == "data\n" + + +def test_valid_symlink_that_becomes_dangling_is_kept_on_the_next_run(project): + copy_also_copy_files() + copy_src_dir() + + (project / "shared" / "real.txt").unlink() + copy_also_copy_files() + copy_src_dir() # skips targets that already exist, but must not fail + + link = project / "mutants" / "tests" / "fixtures" / "rel_valid" + assert link.is_symlink() + assert os.readlink(link) == "../../shared/real.txt"