Skip to content

Copy lockfile (uv.lock/poetry.lock/…) alongside pyproject.toml into the mutants tree #529

Description

@ejfine

Summary

mutmut auto-copies pyproject.toml (plus tests/, setup.cfg, test*.py) into the mutants/ tree, but not the adjacent lockfile. When a project's type_check_command or test command uses a resolver wrapper (uv run …, poetry run …), that subprocess runs in the mutants/ cwd, finds the copied pyproject.toml with no lock beside it, and re-resolves dependencies from the version constraints. The resulting mutants/.venv can differ from the project's locked/installed environment, silently breaking type-checking or tests inside mutmut.

mutmut already copies the dependency spec (pyproject.toml); copying the matching resolution (the lockfile) is the natural completeness fix. Adding also_copy = ["uv.lock"] works around it today, confirming the missing lockfile is the cause.

Scope: affects projects that resolve deps via uv/poetry and wrap their type-check/test command in uv run/poetry run. Projects using a bare command inherit the active environment and are unaffected.

Why wrap the command in uv run at all?

For a project packaged as an installable distribution (e.g. [tool.uv] package = true) whose code imports itself by package name (from mypkg import …), the type checker must resolve mypkg to the mutant copy under mutants/src, not the original source. uv run executed in the mutants/ cwd reinstalls the project editable against the mutant tree, giving the checker one consistent copy.

A bare pyrefly check instead inherits the parent environment, where mypkg is editable-installed pointing at the original src/. The checker then sees two copies of every class — the originals (via the installed package) and the mutants (the files under check) — and reports spurious "X is not assignable to Y" errors across the two trees, on non-mutant lines, which aborts mutmut. So uv run/poetry run is the correct command for installed-package projects; it is not gratuitous. (Projects that merely put src/ on the path have no editable install to conflict, so the bare command shown in the docs works for them.)

Root cause

mutmut/configuration.py (~line 125) hardcodes the copy list with pyproject.toml but no lockfile:

also_copy=[Path(y) for y in s("also_copy", [])]
+ [
    Path("tests/"),
    Path("test/"),
    Path("setup.cfg"),
    Path("pyproject.toml"),
]
+ list(Path(".").glob("test*.py")),

Suggested fix

Add lockfiles to the same hardcoded list — missing ones are already skipped by the existence check in copy_also_copy_files, exactly like setup.cfg:

also_copy=[Path(y) for y in s("also_copy", [])]
+ [
    Path("tests/"),
    Path("test/"),
    Path("setup.cfg"),
    Path("pyproject.toml"),
    Path("uv.lock"),
    Path("poetry.lock"),
    Path("Pipfile.lock"),
    Path("pdm.lock"),
]
+ list(Path(".").glob("test*.py")),

Copying only when present (the existing copy_also_copy_files guard) means no behavior change for projects without a lockfile.

Environment

  • mutmut 3.6.0
  • Python 3.13

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