Skip to content

mutmut breaks on tests with fixture directories with broken symlinks #582

Open

Description

@juliancoffee

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 1

Expected 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

Activity

  1. Ishaan400 commented on Oct 10, 2026

    @Ishaan400
    Contributor

    I reproduced this on current main (3.8.0). There are two crash paths, depending on where the broken symlink lives:

    • Under a test or also_copy directory: copy_also_copy_files() calls shutil.copytree(), which follows symlinks by default, so a dangling symlink raises shutil.Error.
    • Under a source path: copy_src_dir() calls shutil.copy2() on the walked file, which follows the link and raises FileNotFoundError.

    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 inside mutants/, 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.
    • Custom copy_function: Recreates dangling symlinks as links and uses copy2 otherwise, preserving the existing behavior for valid links.

    I'd suggest the third approach: a small helper used as the copy_function for both copytree calls and in place of copy2 in copy_src_dir(). The helper also needs to handle a dangling symlink left by a previous run; otherwise, the second mutmut run can fail with FileExistsError.

    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.

  2. boxed commented on Oct 10, 2026

    @boxed
    Owner

    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.

  3. Ishaan400 commented on Oct 11, 2026

    @Ishaan400
    Contributor

    Thanks @boxed! I've opened #595 with the fix.

    On performance: the change only adds a few stat/lstat calls 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 per mutmut run and 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions