Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
@@ -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
~~~~~~~~~~

Expand Down
21 changes: 18 additions & 3 deletions src/mutmut/utils/file_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
92 changes: 92 additions & 0 deletions tests/utils/test_file_utils.py
Original file line number Diff line number Diff line change
@@ -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"