Repository navigation
mutmut breaks on tests with fixture directories with broken symlinks #582
Copy link
Copy link
Open
Description
So let's say you have this project (that's a minified version of course)
% tre
.
├── Makefile
├── README.md
├── pyproject.toml
├── src
│ └── example
│ ├── __init__.py
│ └── core.py
├── tests
│ ├── fixtures
│ │ └── broken-link
│ └── test_core.py
└── uv.lock
Directories: 4, Files: 7, Symbolic Links: 1, Lines: 0
And broken-link is, well, a broken link, since my project works with files, links specifically, and I need to ensure that it works on broken links.
% file tests/fixtures/broken-link
tests/fixtures/broken-link: broken symbolic link to missing-target
[project]
name = "mutmut-broken-symlink-mre"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
[dependency-groups]
dev = [
"mutmut==3.8.0",
"pytest>=8.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/example"]
[tool.mutmut]
source_paths = ["src"]
only_mutate = ["src/example/core.py"]
pytest_add_cli_args_test_selection = ["tests/test_core.py"]Tests and implementation themselves don't really matter, but since mutmut copies the whole thing, it follows symlinks and then seem to fall apart.
uv run mutmut run
⠋ Generating mutants
⠙ Generating mutants
⠹ Generating mutants
⠸ Generating mutants
⠼ Generating mutants
Traceback (most recent call last):
File "/private/tmp/mutmut-broken-symlink-mre/.venv/bin/mutmut", line 10, in <module>
sys.exit(cli())
~~~^^
File "/private/tmp/mutmut-broken-symlink-mre/.venv/lib/python3.14/site-packages/click/core.py", line 1631, in __call__
return self.main(*args, **kwargs)
~~~~~~~~~^^^^^^^^^^^^^^^^^
File "/private/tmp/mutmut-broken-symlink-mre/.venv/lib/python3.14/site-packages/click/core.py", line 1552, in main
rv = self.invoke(ctx)
File "/private/tmp/mutmut-broken-symlink-mre/.venv/lib/python3.14/site-packages/click/core.py", line 2032, in invoke
return _process_result(sub_ctx.command.invoke(sub_ctx))
~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
File "/private/tmp/mutmut-broken-symlink-mre/.venv/lib/python3.14/site-packages/click/core.py", line 1415, in invoke
return ctx.invoke(self.callback, **ctx.params)
~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/private/tmp/mutmut-broken-symlink-mre/.venv/lib/python3.14/site-packages/click/core.py", line 910, in invoke
return callback(*args, **kwargs)
File "/private/tmp/mutmut-broken-symlink-mre/.venv/lib/python3.14/site-packages/mutmut/__main__.py", line 941, in run
_run(mutant_names, max_children)
~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/private/tmp/mutmut-broken-symlink-mre/.venv/lib/python3.14/site-packages/mutmut/__main__.py", line 957, in _run
copy_also_copy_files()
~~~~~~~~~~~~~~~~~~~~^^
File "/private/tmp/mutmut-broken-symlink-mre/.venv/lib/python3.14/site-packages/mutmut/utils/file_utils.py", line 77, in copy_also_copy_files
shutil.copytree(path, destination, dirs_exist_ok=True)
~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/<redacted>/.local/share/uv/python/cpython-3.14.4-macos-aarch64-none/lib/python3.14/shutil.py", line 654, in copytree
return _copytree(entries=entries, src=src, dst=dst, symlinks=symlinks,
ignore=ignore, copy_function=copy_function,
ignore_dangling_symlinks=ignore_dangling_symlinks,
dirs_exist_ok=dirs_exist_ok)
File "/<redacted>/.local/share/uv/python/cpython-3.14.4-macos-aarch64-none/lib/python3.14/shutil.py", line 608, in _copytree
raise Error(errors)
shutil.Error: [('tests/fixtures/broken-link', 'mutants/tests/fixtures/broken-link', "[Errno 2] No such file or directory: 'tests/fixtures/broken-link'")]
make: *** [mutant] Error 1Expected behaviour
I'd expect it to just leave my links the way they are, as my tests need them.
I know for some uses it might be fine to filter out, but it wouldn't work that well in my case.
Context
Potentially related to #486, but with how special symlinks are, I decided they deserve their own issue
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels
Activity
Ishaan400 commentedon Oct 10, 2026
I reproduced this on current main (3.8.0). There are two crash paths, depending on where the broken symlink lives:
also_copydirectory:copy_also_copy_files()callsshutil.copytree(), which follows symlinks by default, so a dangling symlink raisesshutil.Error.copy_src_dir()callsshutil.copy2()on the walked file, which follows the link and raisesFileNotFoundError.I compared three approaches on Python 3.10 and 3.13, using a fixture containing a dangling symlink and a valid relative symlink (
../../shared/real.txt).copytree(..., symlinks=True): Preserves dangling symlinks, but valid relative symlinks are copied as links and can dangle insidemutants/, causing a regression.copytree(..., ignore_dangling_symlinks=True): Can omit dangling links and valid relative links that should be preserved. The target check uses the current working directory rather than the symlink's directory.copy_function: Recreates dangling symlinks as links and usescopy2otherwise, preserving the existing behavior for valid links.I'd suggest the third approach: a small helper used as the
copy_functionfor bothcopytreecalls and in place ofcopy2incopy_src_dir(). The helper also needs to handle a dangling symlink left by a previous run; otherwise, the secondmutmut runcan fail withFileExistsError.With this change and regression tests for both crash paths, valid relative symlinks, and repeated runs, the tests pass locally. The full suite passes as well (456 passed, 4 skipped).
Would this approach be acceptable? If so, I'm happy to open a PR.
boxed commentedon Oct 10, 2026
I wonder about the performance implications for this. If it's negligible then that would seem a clear win. If it's not, maybe this should be behind an option. Either way a PR would be welcome.
Ishaan400 commentedon Oct 11, 2026
Thanks @boxed! I've opened #595 with the fix.
On performance: the change only adds a few
stat/lstatcalls per copied file, with no extra data I/O. In my measurements that's about 10 µs per file in the worst case (an in-memory filesystem). On real projects on disk, the difference was under 20 ms permutmut runand within run-to-run noise, so I didn't add an option. The numbers and how I measured them are in the PR description. Based on these measurements, I believe the overhead is small enough that an option isn't necessary.