Repository: tox-dev/tox-uv

Issue:
First-class support for uv.lock?
## What's the problem this feature will solve?

Uv added a cross-platform lock format in 0.3.0. I can see this to become a standard for packages to keep CI stable.

## Describe the solution you'd like

Given how early it is on uv’s and tox-uv’s life, I could imagine tox-uv to detect the lock file automatically and instead of doing `uv pip install` it would run `uv sync`?

It [cs]ould be configurable of course, but this would seem like a great step improvement on general DX. 

## Alternative Solutions

I mean we can do it by hand or install_commands. 

## Additional context

https://docs.astral.sh/uv/concepts/projects/


Discussion:

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 43c6308..8185123 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -15,12 +15,12 @@ repos:
       - id: codespell
         additional_dependencies: ["tomli>=2.0.1"]
   - repo: https://github.com/tox-dev/tox-ini-fmt
-    rev: "1.4.0"
+    rev: "1.4.1"
     hooks:
       - id: tox-ini-fmt
         args: ["-p", "fix"]
   - repo: https://github.com/tox-dev/pyproject-fmt
-    rev: "2.2.3"
+    rev: "2.2.4"
     hooks:
       - id: pyproject-fmt
   - repo: https://github.com/astral-sh/ruff-pre-commit
@@ -30,12 +30,10 @@ repos:
       - id: ruff
         args: ["--fix", "--unsafe-fixes", "--exit-non-zero-on-fix"]
   - repo: https://github.com/rbubley/mirrors-prettier
-    rev: "v3.3.3" # Use the sha / tag you want to point at
+    rev: "v3.3.3"
     hooks:
       - id: prettier
-        additional_dependencies:
-          - prettier@3.3.3
-          - "@prettier/plugin-xml@3.4.1"
+        args: ["--print-width=120", "--prose-wrap=always"]
   - repo: meta
     hooks:
       - id: check-hooks-apply
diff --git a/README.md b/README.md
index 976bf72..35383c8 100644
--- a/README.md
+++ b/README.md
@@ -5,8 +5,18 @@
 [![check](https://github.com/tox-dev/tox-uv/actions/workflows/check.yaml/badge.svg)](https://github.com/tox-dev/tox-uv/actions/workflows/check.yaml)
 [![Downloads](https://static.pepy.tech/badge/tox-uv/month)](https://pepy.tech/project/tox-uv)
 
-**tox-uv** is a tox plugin which replaces virtualenv and pip with uv in your tox environments.
-Note that you will get both the benefits (performance) or downsides (bugs) of uv.
+**tox-uv** is a tox plugin which replaces virtualenv and pip with uv in your tox environments. Note that you will get
+both the benefits (performance) or downsides (bugs) of uv.
+
+<!--ts-->
+
+- [How to use](#how-to-use)
+- [Configuration](#configuration)
+  - [uv.lock support](#uvlock-support)
+  - [uv_seed](#uv_seed)
+  - [uv_resolution](#uv_resolution)
+  - [uv_python_preference](#uv_python_preference)
+  <!--te-->
 
 ## How to use
 
@@ -19,10 +29,55 @@ python -m tox r -e py312 # will use uv
 
 ## Configuration
 
-- `uv-venv-runner` is the ID for the tox environments [runner](https://tox.wiki/en/4.12.1/config.html#runner).
+- `uv-venv-runner` is the ID for the tox environments [runner](https://tox.wiki/en/4.12.1/config.html#runner) for
+  environments not using lock file.
+- `uv-venv-lock-runner` is the ID for the tox environments [runner](https://tox.wiki/en/4.12.1/config.html#runner) for
+  environments using `uv.lock` (note we cannot detect the presence of the `uv.lock` file to enable this because that
+  would break environments not using the lock file - such as your linter).
 - `uv-venv-pep-517` is the ID for the PEP-517 packaging environment.
 - `uv-venv-cmd-builder` is the ID for the external cmd builder.
 
+### uv.lock support
+
+If you want for a tox environment to use `uv sync` with a `uv.lock` file you need to change for that tox environment the
+`runner` to `uv-venv-lock-runner`. Furthermore, should in such environments you can use the `extras` config to instruct
+`uv` to also install the specified extras, for example:
+
+```ini
+
+[testenv:fix]
+description = run code formatter and linter (auto-fix)
+skip_install = true
+deps =
+    pre-commit-uv>=4.1.1
+commands =
+    pre-commit run --all-files --show-diff-on-failure
+
+[testenv:type]
+runner = uv-venv-lock-runner
+description = run type checker via mypy
+commands =
+    mypy {posargs:src}
+
+[testenv:dev]
+runner = uv-venv-lock-runner
+description = dev environment
+extras =
+    dev
+    test
+    type
+commands =
+    uv pip tree
+```
+
+In this example:
+
+- `fix` will use the `uv-venv-runner` and use `uv pip install` to install dependencies to the environment.
+- `type` will use the `uv-venv-lock-runner` and use `uv sync` to install dependencies to the environment without any
+  extra group.
+- `dev` will use the `uv-venv-lock-runner` and use `uv sync` to install dependencies to the environment with the `dev`,
+  `test` and `type` extra groups.
+
 ### uv_seed
 
 This flag, set on a tox environment level, controls if the created virtual environment injects pip/setuptools/wheel into
@@ -45,11 +100,8 @@ intention is to validate the lower bounds of your dependencies during test execu
 
 ### uv_python_preference
 
-This flag, set on a tox environment level, controls how uv select the Python
-interpreter.
+This flag, set on a tox environment level, controls how uv select the Python interpreter.
 
-By default, uv will attempt to use Python versions found on the system and only
-download managed interpreters when necessary. However, It's possible to adjust
-uv's Python version selection preference with the
-[python-preference](https://docs.astral.sh/uv/concepts/python-versions/#adjusting-python-version-preferences)
-option.
+By default, uv will attempt to use Python versions found on the system and only download managed interpreters when
+necessary. However, It's possible to adjust uv's Python version selection preference with the
+[python-preference](https://docs.astral.sh/uv/concepts/python-versions/#adjusting-python-version-preferences) option.
diff --git a/pyproject.toml b/pyproject.toml
index c7be88e..f0e6ca9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -41,17 +41,17 @@ dynamic = [
   "version",
 ]
 dependencies = [
-  "importlib-resources>=6.4.4; python_version<'3.9'",
+  "importlib-resources>=6.4.5; python_version<'3.9'",
   "packaging>=24.1",
-  "tox<5,>=4.18",
+  "tox<5,>=4.20",
   "typing-extensions>=4.12.2; python_version<'3.10'",
-  "uv<1,>=0.4.7",
+  "uv<1,>=0.4.12",
 ]
 optional-dependencies.testing = [
   "covdefaults>=2.3",
-  "devpi-process>=1",
+  "devpi-process>=1.0.2",
   "diff-cover>=9.2",
-  "pytest>=8.3.2",
+  "pytest>=8.3.3",
   "pytest-cov>=5",
   "pytest-mock>=3.14",
 ]
diff --git a/src/tox_uv/_installer.py b/src/tox_uv/_installer.py
index 913bd41..44a6cbd 100644
--- a/src/tox_uv/_installer.py
+++ b/src/tox_uv/_installer.py
@@ -4,25 +4,40 @@ from __future__ import annotations
 
 import logging
 from collections import defaultdict
-from typing import TYPE_CHECKING, Any, Sequence, cast
+from typing import TYPE_CHECKING, Any, Sequence
 
 from packaging.requirements import Requirement
 from packaging.utils import parse_sdist_filename, parse_wheel_filename
-from tox.config.of_type import ConfigDynamicDefinition
 from tox.config.types import Command
-from tox.execute.request import StdinSource
 from tox.tox_env.errors import Fail, Recreate
 from tox.tox_env.python.package import EditableLegacyPackage, EditablePackage, SdistPackage, WheelPackage
-from tox.tox_env.python.pip.pip_install import Pip
+from tox.tox_env.python.pip.pip_install import Pip, PythonInstallerListDependencies
 from tox.tox_env.python.pip.req_file import PythonDeps
 from uv import find_uv_bin
 
 if TYPE_CHECKING:
     from tox.config.main import Config
     from tox.tox_env.package import PathPackage
+    from tox.tox_env.python.api import Python
 
 
-class UvInstaller(Pip):
+class ReadOnlyUvInstaller(PythonInstallerListDependencies):
+    def __init__(self, tox_env: Python, with_list_deps: bool = True) -> None:  # noqa: FBT001, FBT002
+        self._with_list_deps = with_list_deps
+        super().__init__(tox_env)
+
+    def freeze_cmd(self) -> list[str]:
+        return [self.uv, "--color", "never", "pip", "freeze"]
+
+    @property
+    def uv(self) -> str:
+        return find_uv_bin()
+
+    def install(self, arguments: Any, section: str, of_type: str) -> None:  # noqa: ANN401
+        raise NotImplementedError  # not supported
+
+
+class UvInstaller(ReadOnlyUvInstaller, Pip):
     """Pip is a python installer that can install packages as defined by PEP-508 and PEP-517."""
 
     def _register_config(self) -> None:
@@ -42,13 +57,6 @@ class UvInstaller(Pip):
             desc="Define the resolution strategy for uv",
             post_process=uv_resolution_post_process,
         )
-        if self._with_list_deps:  # pragma: no branch
-            conf = cast(ConfigDynamicDefinition[Command], self._env.conf._defined["list_dependencies_command"])  # noqa: SLF001
-            conf.default = Command([self.uv, "--color", "never", "pip", "freeze"])
-
-    @property
-    def uv(self) -> str:
-        return find_uv_bin()
 
     def default_install_command(self, conf: Config, env_name: str | None) -> Command:  # noqa: ARG002
         cmd = [self.uv, "pip", "install", "{opts}", "{packages}"]
@@ -78,12 +86,6 @@ class UvInstaller(Pip):
                 install_command.pop(opts_at)
         return cmd
 
-    def installed(self) -> list[str]:
-        cmd: Command = self._env.conf["list_dependencies_command"]
-        result = self._env.execute(cmd=cmd.args, stdin=StdinSource.OFF, run_id="freeze", show=False)
-        result.assert_success()
-        return result.out.splitlines()
-
     def install(self, arguments: Any, section: str, of_type: str) -> None:  # noqa: ANN401
         if isinstance(arguments, PythonDeps):
             self._install_requirement_file(arguments, section, of_type)
@@ -138,5 +140,6 @@ class UvInstaller(Pip):
 
 
 __all__ = [
+    "ReadOnlyUvInstaller",
     "UvInstaller",
 ]
diff --git a/src/tox_uv/_run_lock.py b/src/tox_uv/_run_lock.py
new file mode 100644
index 0000000..b36ea9d
--- /dev/null
+++ b/src/tox_uv/_run_lock.py
@@ -0,0 +1,61 @@
+"""GitHub Actions integration."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Set, cast
+
+from tox.execute.request import StdinSource
+from tox.tox_env.python.runner import add_extras_to_env, add_skip_missing_interpreters_to_core
+from tox.tox_env.runner import RunToxEnv
+
+from ._installer import ReadOnlyUvInstaller
+from ._venv import UvVenv
+
+if TYPE_CHECKING:
+    from tox.tox_env.package import Package
+
+
+class UvVenvLockRunner(UvVenv, RunToxEnv):
+    InstallerClass = ReadOnlyUvInstaller
+
+    @staticmethod
+    def id() -> str:
+        return "uv-venv-lock-runner"
+
+    def _register_package_conf(self) -> bool:  # noqa: PLR6301
+        return False
+
+    @property
+    def _package_tox_env_type(self) -> str:
+        raise NotImplementedError
+
+    @property
+    def _external_pkg_tox_env_type(self) -> str:
+        raise NotImplementedError
+
+    def _build_packages(self) -> list[Package]:
+        raise NotImplementedError
+
+    def register_config(self) -> None:
+        super().register_config()
+        add_extras_to_env(self.conf)
+        add_skip_missing_interpreters_to_core(self.core, self.options)
+
+    def _setup_env(self) -> None:
+        super()._setup_env()
+        cmd = ["uv", "sync", "--frozen"]
+        for extra in cast(Set[str], sorted(self.conf["extras"])):
+            cmd.extend(("--extra", extra))
+        outcome = self.execute(cmd, stdin=StdinSource.OFF, run_id="uv-sync", show=False)
+        outcome.assert_success()
+
+    @property
+    def environment_variables(self) -> dict[str, str]:
+        env = super().environment_variables
+        env["UV_PROJECT_ENVIRONMENT"] = str(self.venv_dir)
+        return env
+
+
+__all__ = [
+    "UvVenvLockRunner",
+]
diff --git a/src/tox_uv/_venv.py b/src/tox_uv/_venv.py
index f69e8c5..f168666 100644
--- a/src/tox_uv/_venv.py
+++ b/src/tox_uv/_venv.py
@@ -18,7 +18,7 @@ from tox.tox_env.python.virtual_env.api import VirtualEnv
 from uv import find_uv_bin
 from virtualenv.discovery.py_spec import PythonSpec
 
-from ._installer import UvInstaller
+from ._installer import ReadOnlyUvInstaller, UvInstaller
 
 if sys.version_info >= (3, 10):  # pragma: no cover (py310+)
     from typing import TypeAlias
@@ -45,9 +45,11 @@ PythonPreference: TypeAlias = Literal[
 
 
 class UvVenv(Python, ABC):
+    InstallerClass: type[ReadOnlyUvInstaller] = UvInstaller
+
     def __init__(self, create_args: ToxEnvCreateArgs) -> None:
         self._executor: Execute | None = None
-        self._installer: UvInstaller | None = None
+        self._installer: ReadOnlyUvInstaller | None = None
         self._created = False
         super().__init__(create_args)
 
@@ -89,7 +91,7 @@ class UvVenv(Python, ABC):
     @property
     def installer(self) -> Installer[Any]:
         if self._installer is None:
-            self._installer = UvInstaller(self)
+            self._installer = self.InstallerClass(self)
         return self._installer
 
     @property
diff --git a/src/tox_uv/plugin.py b/src/tox_uv/plugin.py
index e7ac27e..c7574ec 100644
--- a/src/tox_uv/plugin.py
+++ b/src/tox_uv/plugin.py
@@ -9,6 +9,7 @@ from tox.plugin import impl
 
 from ._package import UvVenvCmdBuilder, UvVenvPep517Packager
 from ._run import UvVenvRunner
+from ._run_lock import UvVenvLockRunner
 
 if TYPE_CHECKING:
     from tox.tox_env.register import ToxEnvRegister
@@ -17,6 +18,7 @@ if TYPE_CHECKING:
 @impl
 def tox_register_tox_env(register: ToxEnvRegister) -> None:
     register.add_run_env(UvVenvRunner)
+    register.add_run_env(UvVenvLockRunner)
     register.add_package_env(UvVenvPep517Packager)
     register.add_package_env(UvVenvCmdBuilder)
     register._default_run_env = UvVenvRunner.id()  # noqa: SLF001
diff --git a/tox.ini b/tox.ini
index ccba69f..5fe9f50 100644
--- a/tox.ini
+++ b/tox.ini
@@ -38,7 +38,7 @@ commands =
 description = format the code base to adhere to our styles, and complain about what we cannot do automatically
 skip_install = true
 deps =
-    pre-commit-uv>=4.1.1
+    pre-commit-uv>=4.1.2
 commands =
     pre-commit run --all-files --show-diff-on-failure
 
@@ -56,7 +56,7 @@ skip_install = true
 deps =
     check-wheel-contents>=0.6
     twine>=5.1.1
-    uv>=0.4.10
+    uv>=0.4.12
 commands =
     uv build --sdist --wheel --out-dir {env_tmp_dir} .
     twine check {env_tmp_dir}{/}*


Test patch:
diff --git a/tests/test_tox_uv_lock.py b/tests/test_tox_uv_lock.py
new file mode 100644
index 0000000..ee55455
--- /dev/null
+++ b/tests/test_tox_uv_lock.py
@@ -0,0 +1,68 @@
+from __future__ import annotations
+
+import sys
+from typing import TYPE_CHECKING
+
+from uv import find_uv_bin
+
+if TYPE_CHECKING:
+    from tox.pytest import ToxProjectCreator
+
+
+def test_uv_lock_list_dependencies_command(tox_project: ToxProjectCreator) -> None:
+    project = tox_project({
+        "tox.ini": """
+    [testenv]
+    runner = uv-venv-lock-runner
+    extras =
+        type
+        dev
+    commands = python hello
+    """
+    })
+    execute_calls = project.patch_execute(lambda r: 0 if r.run_id != "venv" else None)
+    result = project.run("--list-dependencies", "-vv")
+    result.assert_success()
+
+    calls = [(i[0][0].conf.name, i[0][3].run_id, i[0][3].cmd) for i in execute_calls.call_args_list]
+    uv = find_uv_bin()
+    expected = [
+        (
+            "py",
+            "venv",
+            [uv, "venv", "-p", sys.executable, "--allow-existing", "-v", str(project.path / ".tox" / "py")],
+        ),
+        ("py", "uv-sync", ["uv", "sync", "--frozen", "--extra", "dev", "--extra", "type"]),
+        ("py", "freeze", [uv, "--color", "never", "pip", "freeze"]),
+        ("py", "commands[0]", ["python", "hello"]),
+    ]
+    assert calls == expected
+
+
+def test_uv_lock_command(tox_project: ToxProjectCreator) -> None:
+    project = tox_project({
+        "tox.ini": """
+    [testenv]
+    runner = uv-venv-lock-runner
+    extras =
+        type
+        dev
+    commands = python hello
+    """
+    })
+    execute_calls = project.patch_execute(lambda r: 0 if r.run_id != "venv" else None)
+    result = project.run("-vv")
+    result.assert_success()
+
+    calls = [(i[0][0].conf.name, i[0][3].run_id, i[0][3].cmd) for i in execute_calls.call_args_list]
+    uv = find_uv_bin()
+    expected = [
+        (
+            "py",
+            "venv",
+            [uv, "venv", "-p", sys.executable, "--allow-existing", "-v", str(project.path / ".tox" / "py")],
+        ),
+        ("py", "uv-sync", ["uv", "sync", "--frozen", "--extra", "dev", "--extra", "type"]),
+        ("py", "commands[0]", ["python", "hello"]),
+    ]
+    assert calls == expected