├── src
└── tox_uv
│ ├── py.typed
│ ├── __init__.py
│ ├── _venv_query.py
│ ├── _package_types.py
│ ├── _run.py
│ ├── _package.py
│ ├── plugin.py
│ ├── _run_lock.py
│ ├── _installer.py
│ └── _venv.py
├── tests
├── demo_pkg_workspace
│ ├── README.md
│ ├── packages
│ │ └── demo_foo
│ │ │ ├── README.md
│ │ │ ├── src
│ │ │ └── demo_foo
│ │ │ │ ├── py.typed
│ │ │ │ └── __init__.py
│ │ │ └── pyproject.toml
│ ├── src
│ │ └── demo_root
│ │ │ ├── __init__.py
│ │ │ └── main.py
│ └── pyproject.toml
├── demo_pkg_no_pyproject
│ ├── src
│ │ └── demo_pkg
│ │ │ └── __init__.py
│ ├── setup.cfg
│ └── setup.py
├── demo_pkg_setuptools
│ ├── setup.cfg
│ ├── pyproject.toml
│ └── demo_pkg_setuptools
│ │ └── __init__.py
├── demo_pkg_inline
│ ├── pyproject.toml
│ └── build.py
├── test_version.py
├── test_tox_uv_api.py
├── conftest.py
├── test_tox_uv_package.py
├── test_tox_uv_installer.py
├── test_tox_uv_lock.py
└── test_tox_uv_venv.py
├── .github
├── CODEOWNERS
├── FUNDING.yaml
├── release.yaml
├── dependabot.yaml
├── SECURITY.md
├── ISSUE_TEMPLATE
│ ├── config.yml
│ ├── bug-report.md
│ └── feature-request.md
└── workflows
│ ├── release.yaml
│ └── check.yaml
├── .gitignore
├── LICENSE
├── .pre-commit-config.yaml
├── tox.ini
├── pyproject.toml
└── README.md
/src/tox_uv/py.typed:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/demo_pkg_workspace/README.md:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @gaborbernat
2 |
--------------------------------------------------------------------------------
/.github/FUNDING.yaml:
--------------------------------------------------------------------------------
1 | tidelift: pypi/tox-uv
2 |
--------------------------------------------------------------------------------
/tests/demo_pkg_no_pyproject/src/demo_pkg/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/demo_pkg_workspace/packages/demo_foo/README.md:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/demo_pkg_workspace/src/demo_root/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/demo_pkg_workspace/packages/demo_foo/src/demo_foo/py.typed:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.github/release.yaml:
--------------------------------------------------------------------------------
1 | changelog:
2 | exclude:
3 | authors:
4 | - dependabot[bot]
5 | - pre-commit-ci[bot]
6 |
--------------------------------------------------------------------------------
/tests/demo_pkg_setuptools/setup.cfg:
--------------------------------------------------------------------------------
1 | [metadata]
2 | name = demo_pkg_setuptools
3 | version = 1.2.3
4 |
5 | [options]
6 | packages = find:
7 |
--------------------------------------------------------------------------------
/tests/demo_pkg_no_pyproject/setup.cfg:
--------------------------------------------------------------------------------
1 | [metadata]
2 | name=demo-pkg
3 | version=0.0.1
4 |
5 | [options]
6 |
7 | [bdist_wheel]
8 | universal=1
9 |
--------------------------------------------------------------------------------
/tests/demo_pkg_setuptools/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | build-backend = 'setuptools.build_meta'
3 | requires = [
4 | "setuptools>=63",
5 | ]
6 |
--------------------------------------------------------------------------------
/.github/dependabot.yaml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "github-actions"
4 | directory: "/"
5 | schedule:
6 | interval: "daily"
7 |
--------------------------------------------------------------------------------
/tests/demo_pkg_no_pyproject/setup.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from setuptools import setup
4 |
5 | setup(name="demo-pkg", package_dir={"": "src"})
6 |
--------------------------------------------------------------------------------
/tests/demo_pkg_workspace/packages/demo_foo/src/demo_foo/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 |
4 | def hello() -> str:
5 | return "Hello from demo-foo!"
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /magic
2 | .idea
3 | *.egg-info
4 | .tox/
5 | .coverage*
6 | coverage.xml
7 | .*_cache
8 | __pycache__
9 | **.pyc
10 | /build
11 | dist
12 | src/tox_uv/version.py
13 |
--------------------------------------------------------------------------------
/tests/demo_pkg_setuptools/demo_pkg_setuptools/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 |
4 | def do() -> None:
5 | print("greetings from demo_pkg_setuptools") # noqa: T201
6 |
--------------------------------------------------------------------------------
/tests/demo_pkg_workspace/src/demo_root/main.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 |
4 | def main() -> None:
5 | pass
6 |
7 |
8 | if __name__ == "__main__":
9 | main()
10 |
--------------------------------------------------------------------------------
/tests/demo_pkg_inline/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | build-backend = "build"
3 | requires = [
4 | ]
5 | backend-path = [
6 | ".",
7 | ]
8 |
9 | [tool.black]
10 | line-length = 120
11 |
--------------------------------------------------------------------------------
/src/tox_uv/__init__.py:
--------------------------------------------------------------------------------
1 | """GitHub Actions integration."""
2 |
3 | from __future__ import annotations
4 |
5 | from .version import version as __version__
6 |
7 | __all__ = [
8 | "__version__",
9 | ]
10 |
--------------------------------------------------------------------------------
/src/tox_uv/_venv_query.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import json
4 | import sys
5 | from platform import python_implementation
6 |
7 | print( # noqa: T201
8 | json.dumps({
9 | "implementation": python_implementation().lower(),
10 | "version_info": sys.version_info,
11 | "version": sys.version,
12 | "is_64": sys.maxsize > 2**32,
13 | })
14 | )
15 |
--------------------------------------------------------------------------------
/tests/test_version.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import sys
4 | from subprocess import check_output
5 |
6 |
7 | def test_version() -> None:
8 | from tox_uv import __version__ # noqa: PLC0415
9 |
10 | assert __version__
11 |
12 |
13 | def test_tox_version() -> None:
14 | output = check_output([sys.executable, "-m", "tox", "--version"], text=True)
15 | assert " with uv==" in output
16 |
--------------------------------------------------------------------------------
/.github/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Supported Versions
4 |
5 | | Version | Supported |
6 | | ------- | ------------------ |
7 | | 1.0.0 + | :white_check_mark: |
8 | | < 1.0.0 | :x: |
9 |
10 | ## Reporting a Vulnerability
11 |
12 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift
13 | will coordinate the fix and disclosure.
14 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | # Ref: https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository#configuring-the-template-chooser
2 | blank_issues_enabled: true # default
3 | contact_links:
4 | - name: 🤷💻🤦 Discussions
5 | url: https://github.com/tox-dev/tox-uv/discussions
6 | about: |
7 | Ask typical Q&A here. Please note that we cannot give support about Python packaging in general, questions about structuring projects and so on.
8 | - name: 📝 PyPA Code of Conduct
9 | url: https://www.pypa.io/en/latest/code-of-conduct/
10 | about: ❤ Be nice to other members of the community. ☮ Behave.
11 |
--------------------------------------------------------------------------------
/tests/test_tox_uv_api.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING
4 |
5 | if TYPE_CHECKING:
6 | from tox.execute import ExecuteRequest
7 | from tox.pytest import ToxProjectCreator
8 |
9 |
10 | def test_uv_list_dependencies_command(tox_project: ToxProjectCreator) -> None:
11 | project = tox_project({"tox.ini": "[testenv]\npackage=skip"})
12 | execute_calls = project.patch_execute(lambda r: 0 if "install" in r.run_id else None)
13 | result = project.run("--list-dependencies", "-vv")
14 | result.assert_success()
15 | request: ExecuteRequest = execute_calls.call_args[0][3]
16 | assert request.cmd[1:] == ["--color", "never", "pip", "freeze"]
17 |
--------------------------------------------------------------------------------
/tests/demo_pkg_workspace/packages/demo_foo/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | build-backend = "uv_build"
3 | requires = [ "uv-build>=0.8.9,<0.9" ]
4 |
5 | [project]
6 | name = "demo-foo"
7 | version = "0.1.0"
8 | description = "Add your description here"
9 | readme = "README.md"
10 | authors = [ { name = "Sorin Sbarnea", email = "sorin.sbarnea@gmail.com" } ]
11 | requires-python = ">=3.9"
12 | classifiers = [
13 | "Programming Language :: Python :: 3 :: Only",
14 | "Programming Language :: Python :: 3.9",
15 | "Programming Language :: Python :: 3.10",
16 | "Programming Language :: Python :: 3.11",
17 | "Programming Language :: Python :: 3.12",
18 | "Programming Language :: Python :: 3.13",
19 | "Programming Language :: Python :: 3.14",
20 | ]
21 | dependencies = [ "demo-root" ]
22 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug-report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ""
5 | labels: bug
6 | assignees: ""
7 | ---
8 |
9 | ## Issue
10 |
11 |
12 |
13 | ## Environment
14 |
15 | Provide at least:
16 |
17 | - OS:
18 |
19 |
20 | Output of pip list of the host Python, where tox is installed
21 |
22 | ```console
23 |
24 | ```
25 |
26 |
27 |
28 | ## Output of running tox
29 |
30 |
31 | Output of tox -rvv
32 |
33 | ```console
34 |
35 | ```
36 |
37 |
38 |
39 | ## Minimal example
40 |
41 |
42 |
43 | ```console
44 |
45 | ```
46 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature-request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an enhancement for this project
4 | title: ""
5 | labels: enhancement
6 | assignees: ""
7 | ---
8 |
9 | ## What's the problem this feature will solve?
10 |
11 |
12 |
13 | ## Describe the solution you'd like
14 |
15 |
16 |
17 |
18 |
19 | ## Alternative Solutions
20 |
21 |
23 |
24 | ## Additional context
25 |
26 |
27 |
--------------------------------------------------------------------------------
/src/tox_uv/_package_types.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING
4 |
5 | from tox.tox_env.python.package import PythonPathPackageWithDeps
6 |
7 | if TYPE_CHECKING:
8 | import pathlib
9 | from collections.abc import Sequence
10 |
11 |
12 | class UvBasePackage(PythonPathPackageWithDeps):
13 | """Package to be built and installed by uv directly."""
14 |
15 | KEY: str
16 |
17 | def __init__(self, path: pathlib.Path, extras: Sequence[str]) -> None:
18 | super().__init__(path, ())
19 | self.extras = extras
20 |
21 |
22 | class UvPackage(UvBasePackage):
23 | """Package to be built and installed by uv directly as wheel."""
24 |
25 | KEY = "uv"
26 |
27 |
28 | class UvEditablePackage(UvBasePackage):
29 | """Package to be built and installed by uv directly as editable wheel."""
30 |
31 | KEY = "uv-editable"
32 |
33 |
34 | __all__ = [
35 | "UvEditablePackage",
36 | "UvPackage",
37 | ]
38 |
--------------------------------------------------------------------------------
/tests/demo_pkg_workspace/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | build-backend = "uv_build"
3 | requires = [ "uv-build>=0.8.9,<0.9" ]
4 |
5 | [project]
6 | name = "demo-root"
7 | version = "0.1.0"
8 | description = "Add your description here"
9 | readme = "README.md"
10 | requires-python = ">=3.9"
11 | classifiers = [
12 | "Programming Language :: Python :: 3 :: Only",
13 | "Programming Language :: Python :: 3.9",
14 | "Programming Language :: Python :: 3.10",
15 | "Programming Language :: Python :: 3.11",
16 | "Programming Language :: Python :: 3.12",
17 | "Programming Language :: Python :: 3.13",
18 | "Programming Language :: Python :: 3.14",
19 | ]
20 | # typing-extensions is not really used but we include it for testing the
21 | # branch coverage for deps that are not sourced.
22 | dependencies = [ "demo-foo", "typing-extensions" ]
23 |
24 | [tool.uv.sources]
25 | demo-foo = { workspace = true }
26 | demo-root = { workspace = true }
27 |
28 | [tool.uv.workspace]
29 | members = [ "packages/*" ]
30 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Permission is hereby granted, free of charge, to any person obtaining a
2 | copy of this software and associated documentation files (the
3 | "Software"), to deal in the Software without restriction, including
4 | without limitation the rights to use, copy, modify, merge, publish,
5 | distribute, sublicense, and/or sell copies of the Software, and to
6 | permit persons to whom the Software is furnished to do so, subject to
7 | the following conditions:
8 |
9 | The above copyright notice and this permission notice shall be included
10 | in all copies or substantial portions of the Software.
11 |
12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
13 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
14 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
15 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
16 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
17 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
18 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
19 |
--------------------------------------------------------------------------------
/src/tox_uv/_run.py:
--------------------------------------------------------------------------------
1 | """GitHub Actions integration."""
2 |
3 | from __future__ import annotations
4 |
5 | from typing import TYPE_CHECKING
6 |
7 | from tox.tox_env.python.runner import PythonRun
8 |
9 | from ._package_types import UvEditablePackage, UvPackage
10 | from ._venv import UvVenv
11 |
12 | if TYPE_CHECKING:
13 | from pathlib import Path
14 |
15 |
16 | class UvVenvRunner(UvVenv, PythonRun):
17 | @staticmethod
18 | def id() -> str:
19 | return "uv-venv-runner"
20 |
21 | @property
22 | def _package_tox_env_type(self) -> str:
23 | return "uv-venv-pep-517"
24 |
25 | @property
26 | def _external_pkg_tox_env_type(self) -> str:
27 | return "uv-venv-cmd-builder" # pragma: no cover
28 |
29 | @property
30 | def default_pkg_type(self) -> str:
31 | tox_root: Path = self.core["tox_root"]
32 | if not (any((tox_root / i).exists() for i in ("pyproject.toml", "setup.py", "setup.cfg"))):
33 | return "skip"
34 | return super().default_pkg_type
35 |
36 | @property
37 | def _package_types(self) -> tuple[str, ...]:
38 | return *super()._package_types, UvPackage.KEY, UvEditablePackage.KEY
39 |
40 |
41 | __all__ = [
42 | "UvVenvRunner",
43 | ]
44 |
--------------------------------------------------------------------------------
/src/tox_uv/_package.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING
4 |
5 | from tox.tox_env.python.virtual_env.package.cmd_builder import VenvCmdBuilder
6 | from tox.tox_env.python.virtual_env.package.pyproject import Pep517VenvPackager
7 |
8 | from ._package_types import UvEditablePackage, UvPackage
9 | from ._venv import UvVenv
10 |
11 | if TYPE_CHECKING:
12 | from tox.config.sets import EnvConfigSet
13 | from tox.tox_env.package import Package
14 |
15 |
16 | class UvVenvPep517Packager(Pep517VenvPackager, UvVenv):
17 | @staticmethod
18 | def id() -> str:
19 | return "uv-venv-pep-517"
20 |
21 | def perform_packaging(self, for_env: EnvConfigSet) -> list[Package]:
22 | of_type: str = for_env["package"]
23 | if of_type == UvPackage.KEY:
24 | return [UvPackage(self.core["tox_root"], for_env["extras"])]
25 | if of_type == UvEditablePackage.KEY:
26 | return [UvEditablePackage(self.core["tox_root"], for_env["extras"])]
27 | return super().perform_packaging(for_env)
28 |
29 |
30 | class UvVenvCmdBuilder(VenvCmdBuilder, UvVenv):
31 | @staticmethod
32 | def id() -> str:
33 | return "uv-venv-cmd-builder"
34 |
35 |
36 | __all__ = [
37 | "UvVenvCmdBuilder",
38 | "UvVenvPep517Packager",
39 | ]
40 |
--------------------------------------------------------------------------------
/src/tox_uv/plugin.py:
--------------------------------------------------------------------------------
1 | """GitHub Actions integration."""
2 |
3 | from __future__ import annotations
4 |
5 | from importlib.metadata import version
6 | from typing import TYPE_CHECKING
7 |
8 | from tox.plugin import impl
9 |
10 | from ._package import UvVenvCmdBuilder, UvVenvPep517Packager
11 | from ._run import UvVenvRunner
12 | from ._run_lock import UvVenvLockRunner
13 |
14 | if TYPE_CHECKING:
15 | from tox.config.cli.parser import ToxParser
16 | from tox.tox_env.register import ToxEnvRegister
17 |
18 |
19 | @impl
20 | def tox_register_tox_env(register: ToxEnvRegister) -> None:
21 | register.add_run_env(UvVenvRunner)
22 | register.add_run_env(UvVenvLockRunner)
23 | register.add_package_env(UvVenvPep517Packager)
24 | register.add_package_env(UvVenvCmdBuilder)
25 | register._default_run_env = UvVenvRunner.id() # noqa: SLF001
26 |
27 |
28 | @impl
29 | def tox_add_option(parser: ToxParser) -> None:
30 | for key in ("run", "exec"):
31 | parser.handlers[key][0].add_argument(
32 | "--skip-uv-sync",
33 | dest="skip_uv_sync",
34 | help="skip uv sync (lock mode only)",
35 | action="store_true",
36 | )
37 |
38 |
39 | def tox_append_version_info() -> str:
40 | return f"with uv=={version('uv')}"
41 |
42 |
43 | __all__ = [
44 | "tox_register_tox_env",
45 | ]
46 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | - repo: https://github.com/pre-commit/pre-commit-hooks
3 | rev: v6.0.0
4 | hooks:
5 | - id: end-of-file-fixer
6 | - id: trailing-whitespace
7 | - repo: https://github.com/python-jsonschema/check-jsonschema
8 | rev: 0.35.0
9 | hooks:
10 | - id: check-github-workflows
11 | args: ["--verbose"]
12 | - repo: https://github.com/codespell-project/codespell
13 | rev: v2.4.1
14 | hooks:
15 | - id: codespell
16 | additional_dependencies: ["tomli>=2.3"]
17 | - repo: https://github.com/tox-dev/tox-ini-fmt
18 | rev: "1.7.1"
19 | hooks:
20 | - id: tox-ini-fmt
21 | args: ["-p", "fix"]
22 | - repo: https://github.com/tox-dev/pyproject-fmt
23 | rev: "v2.11.1"
24 | hooks:
25 | - id: pyproject-fmt
26 | - repo: https://github.com/astral-sh/ruff-pre-commit
27 | rev: "v0.14.9"
28 | hooks:
29 | - id: ruff-format
30 | alias: ruff
31 | args: ["--exit-non-zero-on-format"]
32 | - id: ruff-check
33 | alias: ruff
34 | args: ["--exit-non-zero-on-fix"]
35 | - repo: https://github.com/rbubley/mirrors-prettier
36 | rev: "v3.7.4"
37 | hooks:
38 | - id: prettier
39 | args: ["--print-width=120", "--prose-wrap=always"]
40 | - repo: meta
41 | hooks:
42 | - id: check-hooks-apply
43 | - id: check-useless-excludes
44 |
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import os
4 | from pathlib import Path
5 | from typing import TYPE_CHECKING
6 | from unittest import mock
7 |
8 | import pytest
9 |
10 | if TYPE_CHECKING:
11 | from collections.abc import Generator
12 |
13 |
14 | @pytest.fixture(autouse=True)
15 | def mock_settings_env_vars() -> Generator[None, None, None]:
16 | """Isolated testing from user's environment."""
17 | with mock.patch.dict(os.environ, {"TOX_USER_CONFIG_FILE": os.devnull}):
18 | yield
19 |
20 |
21 | @pytest.fixture(scope="session")
22 | def root() -> Path:
23 | return Path(__file__).parent
24 |
25 |
26 | @pytest.fixture(scope="session")
27 | def demo_pkg_setuptools(root: Path) -> Path:
28 | return root / "demo_pkg_setuptools"
29 |
30 |
31 | @pytest.fixture(scope="session")
32 | def demo_pkg_workspace(root: Path) -> Path:
33 | return root / "demo_pkg_workspace"
34 |
35 |
36 | @pytest.fixture(scope="session")
37 | def demo_pkg_no_pyproject(root: Path) -> Path:
38 | return root / "demo_pkg_no_pyproject"
39 |
40 |
41 | @pytest.fixture(scope="session")
42 | def demo_pkg_inline(root: Path) -> Path:
43 | return root / "demo_pkg_inline"
44 |
45 |
46 | @pytest.fixture
47 | def clear_python_preference_env_var(monkeypatch: pytest.MonkeyPatch) -> None:
48 | monkeypatch.delenv("UV_PYTHON_PREFERENCE", raising=False)
49 |
50 |
51 | pytest_plugins = [
52 | "tox.pytest",
53 | ]
54 |
--------------------------------------------------------------------------------
/.github/workflows/release.yaml:
--------------------------------------------------------------------------------
1 | name: Release to PyPI
2 | on:
3 | push:
4 | tags: ["*"]
5 |
6 | env:
7 | dists-artifact-name: python-package-distributions
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v6
14 | with:
15 | fetch-depth: 0
16 | - name: Install the latest version of uv
17 | uses: astral-sh/setup-uv@v7
18 | with:
19 | enable-cache: true
20 | cache-dependency-glob: "pyproject.toml"
21 | github-token: ${{ secrets.GITHUB_TOKEN }}
22 | - name: Build package
23 | run: uv build --python 3.14 --python-preference only-managed --sdist --wheel . --out-dir dist
24 | - name: Store the distribution packages
25 | uses: actions/upload-artifact@v6
26 | with:
27 | name: ${{ env.dists-artifact-name }}
28 | path: dist/*
29 |
30 | release:
31 | needs:
32 | - build
33 | runs-on: ubuntu-latest
34 | environment:
35 | name: release
36 | url: https://pypi.org/project/tox-uv/${{ github.ref_name }}
37 | permissions:
38 | id-token: write
39 | steps:
40 | - name: Download all the dists
41 | uses: actions/download-artifact@v7
42 | with:
43 | name: ${{ env.dists-artifact-name }}
44 | path: dist/
45 | - name: Publish to PyPI
46 | uses: pypa/gh-action-pypi-publish@v1.13.0
47 | with:
48 | attestations: true
49 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | requires =
3 | tox>=4.31
4 | tox-uv>=1.23
5 | env_list =
6 | fix
7 | 3.14
8 | 3.13
9 | 3.12
10 | 3.11
11 | 3.10
12 | type
13 | pkg_meta
14 | skip_missing_interpreters = true
15 |
16 | [testenv]
17 | description = run the unit tests with pytest under {base_python}
18 | package = wheel
19 | wheel_build_env = .pkg
20 | pass_env =
21 | DIFF_AGAINST
22 | PYTEST_*
23 | set_env =
24 | COVERAGE_FILE = {work_dir}/.coverage.{env_name}
25 | commands =
26 | python -m pytest {tty:--color=yes} {posargs: \
27 | --cov {env_site_packages_dir}{/}tox_uv --cov {tox_root}{/}tests \
28 | --cov-config=pyproject.toml --no-cov-on-fail --cov-report term-missing:skip-covered --cov-context=test \
29 | --cov-report html:{env_tmp_dir}{/}htmlcov --cov-report xml:{work_dir}{/}coverage.{env_name}.xml \
30 | --junitxml {work_dir}{/}junit.{env_name}.xml \
31 | tests}
32 | diff-cover --compare-branch {env:DIFF_AGAINST:origin/main} {work_dir}{/}coverage.{env_name}.xml --fail-under 100
33 | dependency_groups = test
34 |
35 | [testenv:fix]
36 | description = format the code base to adhere to our styles, and complain about what we cannot do automatically
37 | skip_install = true
38 | deps =
39 | pre-commit-uv>=4.2
40 | commands =
41 | pre-commit run --all-files --show-diff-on-failure
42 |
43 | [testenv:type]
44 | description = run type check on code base
45 | commands =
46 | mypy src tests
47 | dependency_groups = type
48 |
49 | [testenv:pkg_meta]
50 | description = check that the long description is valid
51 | skip_install = true
52 | commands =
53 | uv build --sdist --wheel --out-dir {env_tmp_dir} .
54 | twine check {env_tmp_dir}{/}*
55 | check-wheel-contents --no-config {env_tmp_dir}
56 | dependency_groups = pkg-meta
57 |
58 | [testenv:dev]
59 | description = generate a DEV environment
60 | package = editable
61 | commands =
62 | uv pip tree
63 | python -c 'import sys; print(sys.executable)'
64 | dependency_groups = dev
65 |
--------------------------------------------------------------------------------
/.github/workflows/check.yaml:
--------------------------------------------------------------------------------
1 | name: check
2 | on:
3 | workflow_dispatch:
4 | push:
5 | branches: ["main"]
6 | tags-ignore: ["**"]
7 | pull_request:
8 | pull_request_target:
9 | schedule:
10 | - cron: "0 8 * * *"
11 |
12 | concurrency:
13 | group: check-${{ github.ref }}
14 | cancel-in-progress: true
15 |
16 | jobs:
17 | test:
18 | runs-on: ${{ matrix.os || 'ubuntu-latest' }}
19 | name: ${{ matrix.env }}${{ matrix.suffix || ''}}
20 | strategy:
21 | fail-fast: false
22 | matrix:
23 | env:
24 | - "3.14"
25 | - "3.13"
26 | - "3.12"
27 | - "3.11"
28 | - "3.10"
29 | - type
30 | - dev
31 | - pkg_meta
32 | os:
33 | - ubuntu-24.04
34 | suffix:
35 | - ""
36 | include:
37 | - env: "3.14"
38 | os: windows-2025
39 | suffix: -windows
40 | - env: "3.14"
41 | os: macos-15
42 | suffix: -macos
43 | steps:
44 | - uses: actions/checkout@v6
45 | with:
46 | fetch-depth: 0
47 | - name: Install the latest version of uv
48 | uses: astral-sh/setup-uv@v7
49 | with:
50 | enable-cache: true
51 | cache-dependency-glob: "pyproject.toml"
52 | - name: Install tox
53 | run: uv tool install --python-preference only-managed --python 3.14 tox --with .
54 | - name: Install Python
55 | if: startsWith(matrix.env, '3.') && matrix.env != '3.14'
56 | run: uv python install --python-preference only-managed ${{ matrix.env }}
57 | - name: Setup test suite
58 | run: tox run -vv --notest --skip-missing-interpreters false -e ${{ matrix.env }}
59 | env:
60 | FORCE_COLOR: "1"
61 | UV_PYTHON_PREFERENCE: "only-managed"
62 | - name: Run test suite
63 | run: tox run --skip-pkg-install -e ${{ matrix.env }}
64 | env:
65 | FORCE_COLOR: "1"
66 | PYTEST_ADDOPTS: "-vv --durations=20"
67 | DIFF_AGAINST: HEAD
68 | UV_PYTHON_PREFERENCE: "only-managed"
69 |
--------------------------------------------------------------------------------
/tests/test_tox_uv_package.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import sys
4 | from typing import TYPE_CHECKING
5 |
6 | import pytest
7 |
8 | if TYPE_CHECKING:
9 | from pathlib import Path
10 |
11 | from tox.pytest import ToxProjectCreator
12 |
13 |
14 | def test_uv_package_skip(tox_project: ToxProjectCreator) -> None:
15 | project = tox_project({"tox.ini": "[testenv]\npackage=skip"})
16 | result = project.run("-vv")
17 | result.assert_success()
18 |
19 |
20 | def test_uv_package_use_default_from_file(tox_project: ToxProjectCreator) -> None:
21 | project = tox_project({"tox.ini": "[testenv]\npackage=skip", "pyproject.toml": ""})
22 | result = project.run("-vv")
23 | result.assert_success()
24 |
25 |
26 | @pytest.mark.parametrize("with_dash", [True, False], ids=["name_dash", "name_underscore"])
27 | @pytest.mark.parametrize("package", ["sdist", "wheel", "editable", "uv", "uv-editable"])
28 | def test_uv_package_artifact(
29 | tox_project: ToxProjectCreator, package: str, demo_pkg_inline: Path, with_dash: bool
30 | ) -> None:
31 | ini = f"[testenv]\npackage={package}"
32 | if with_dash:
33 | ini += "\n[testenv:.pkg]\nset_env = WITH_DASH = 1"
34 | project = tox_project({"tox.ini": ini}, base=demo_pkg_inline)
35 | result = project.run()
36 | result.assert_success()
37 |
38 |
39 | def test_uv_package_editable_legacy(tox_project: ToxProjectCreator, demo_pkg_setuptools: Path) -> None:
40 | ini = f"""
41 | [testenv]
42 | package=editable-legacy
43 |
44 | [testenv:.pkg]
45 | uv_seed = true
46 | {"deps = wheel" if sys.version_info >= (3, 12) else ""}
47 | """
48 | project = tox_project({"tox.ini": ini}, base=demo_pkg_setuptools)
49 | result = project.run()
50 | result.assert_success()
51 |
52 |
53 | def test_uv_package_requirements(tox_project: ToxProjectCreator) -> None:
54 | project = tox_project({"tox.ini": "[testenv]\npackage=skip\ndeps=-r demo.txt", "demo.txt": "tomli"})
55 | result = project.run("-vv")
56 | result.assert_success()
57 |
58 |
59 | def test_uv_package_workspace(tox_project: ToxProjectCreator, demo_pkg_workspace: Path) -> None:
60 | """Tests ability to install uv workspace projects."""
61 | ini = f"""
62 | [testenv]
63 |
64 | [testenv:.pkg]
65 | uv_seed = true
66 | {"deps = wheel" if sys.version_info >= (3, 12) else ""}
67 | """
68 | project = tox_project({"tox.ini": ini}, base=demo_pkg_workspace)
69 | result = project.run()
70 | result.assert_success()
71 |
72 |
73 | def test_uv_package_no_pyproject(tox_project: ToxProjectCreator, demo_pkg_no_pyproject: Path) -> None:
74 | """Tests ability to install uv workspace projects."""
75 | ini = f"""
76 | [testenv]
77 |
78 | [testenv:.pkg]
79 | uv_seed = true
80 | {"deps = wheel" if sys.version_info >= (3, 12) else ""}
81 | """
82 | project = tox_project({"tox.ini": ini}, base=demo_pkg_no_pyproject)
83 | result = project.run()
84 | result.assert_success()
85 |
--------------------------------------------------------------------------------
/tests/demo_pkg_inline/build.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import os
4 | import re
5 | import sys
6 | import tarfile
7 | from pathlib import Path
8 | from textwrap import dedent
9 | from zipfile import ZipFile
10 |
11 | name = "demo-pkg-inline" if os.environ.get("WITH_DASH") else "demo_pkg_inline"
12 | name_in_artifact = re.sub(r"[^\w\d.]+", "_", name, flags=re.UNICODE) # per PEP-427
13 | version = "1.0.0"
14 | dist_info = f"{name_in_artifact}-{version}.dist-info"
15 | module = name_in_artifact
16 | logic = f"{module}/__init__.py"
17 | plugin = f"{module}/example_plugin.py"
18 | entry_points = f"{dist_info}/entry_points.txt"
19 | metadata = f"{dist_info}/METADATA"
20 | wheel = f"{dist_info}/WHEEL"
21 | record = f"{dist_info}/RECORD"
22 | content = {
23 | logic: f"def do():\n print('greetings from {name}')",
24 | plugin: """
25 | try:
26 | from tox.plugin import impl
27 | from tox.tox_env.python.virtual_env.runner import VirtualEnvRunner
28 | from tox.tox_env.register import ToxEnvRegister
29 | except ImportError:
30 | pass
31 | else:
32 | class ExampleVirtualEnvRunner(VirtualEnvRunner):
33 | @staticmethod
34 | def id() -> str:
35 | return "example"
36 | @impl
37 | def tox_register_tox_env(register: ToxEnvRegister) -> None:
38 | register.add_run_env(ExampleVirtualEnvRunner)
39 | """,
40 | }
41 |
42 | metadata_files = {
43 | entry_points: f"""
44 | [tox]
45 | example = {module}.example_plugin""",
46 | metadata: f"""
47 | Metadata-Version: 2.1
48 | Name: {name}
49 | Version: {version}
50 | Summary: UNKNOWN
51 | Home-page: UNKNOWN
52 | Author: UNKNOWN
53 | Author-email: UNKNOWN
54 | License: UNKNOWN
55 | Platform: UNKNOWN
56 |
57 | UNKNOWN
58 | """,
59 | wheel: f"""
60 | Wheel-Version: 1.0
61 | Generator: {name}-{version}
62 | Root-Is-Purelib: true
63 | Tag: py{sys.version_info[0]}-none-any
64 | """,
65 | f"{dist_info}/top_level.txt": module,
66 | record: f"""
67 | {module}/__init__.py,,
68 | {dist_info}/METADATA,,
69 | {dist_info}/WHEEL,,
70 | {dist_info}/top_level.txt,,
71 | {dist_info}/RECORD,,
72 | """,
73 | }
74 |
75 |
76 | def build_wheel(
77 | wheel_directory: str,
78 | config_settings: dict[str, str] | None = None, # noqa: ARG001
79 | metadata_directory: str | None = None,
80 | ) -> str:
81 | base_name = f"{name_in_artifact}-{version}-py{sys.version_info[0]}-none-any.whl"
82 | path = Path(wheel_directory) / base_name
83 | with ZipFile(str(path), "w") as zip_file_handler:
84 | for arc_name, data in content.items(): # pragma: no branch
85 | zip_file_handler.writestr(arc_name, dedent(data).strip())
86 | if metadata_directory is not None:
87 | for sub_directory, _, filenames in os.walk(metadata_directory):
88 | for filename in filenames:
89 | src = str(Path(metadata_directory) / sub_directory / filename)
90 | dest = str(Path(sub_directory) / filename)
91 | zip_file_handler.write(src, dest)
92 | else:
93 | for arc_name, data in metadata_files.items():
94 | zip_file_handler.writestr(arc_name, dedent(data).strip())
95 | print(f"created wheel {path}") # noqa: T201
96 | return base_name
97 |
98 |
99 | def get_requires_for_build_wheel(config_settings: dict[str, str] | None = None) -> list[str]: # noqa: ARG001
100 | return [] # pragma: no cover # only executed in non-host pythons
101 |
102 |
103 | def build_editable(
104 | wheel_directory: str,
105 | config_settings: dict[str, str] | None = None,
106 | metadata_directory: str | None = None,
107 | ) -> str:
108 | return build_wheel(wheel_directory, config_settings, metadata_directory)
109 |
110 |
111 | def build_sdist(sdist_directory: str, config_settings: dict[str, str] | None = None) -> str: # noqa: ARG001
112 | result = f"{name_in_artifact}-{version}.tar.gz" # pragma: win32 cover
113 | with tarfile.open(str(Path(sdist_directory) / result), "w:gz") as tar: # pragma: win32 cover
114 | root = Path(__file__).parent # pragma: win32 cover
115 | tar.add(str(root / "build.py"), "build.py") # pragma: win32 cover
116 | tar.add(str(root / "pyproject.toml"), "pyproject.toml") # pragma: win32 cover
117 | return result # pragma: win32 cover
118 |
119 |
120 | def get_requires_for_build_sdist(config_settings: dict[str, str] | None = None) -> list[str]: # noqa: ARG001
121 | return [] # pragma: no cover # only executed in non-host pythons
122 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | build-backend = "hatchling.build"
3 | requires = [
4 | "hatch-vcs>=0.5",
5 | "hatchling>=1.27",
6 | ]
7 |
8 | [project]
9 | name = "tox-uv"
10 | description = "Integration of uv with tox."
11 | readme = "README.md"
12 | keywords = [
13 | "environments",
14 | "isolated",
15 | "testing",
16 | "virtual",
17 | ]
18 | license = "MIT"
19 | maintainers = [
20 | { name = "Bernát Gábor", email = "gaborjbernat@gmail.com" },
21 | ]
22 | requires-python = ">=3.10"
23 | classifiers = [
24 | "Development Status :: 5 - Production/Stable",
25 | "Intended Audience :: Developers",
26 | "Operating System :: OS Independent",
27 | "Programming Language :: Python",
28 | "Programming Language :: Python :: 3 :: Only",
29 | "Programming Language :: Python :: 3.10",
30 | "Programming Language :: Python :: 3.11",
31 | "Programming Language :: Python :: 3.12",
32 | "Programming Language :: Python :: 3.13",
33 | "Programming Language :: Python :: 3.14",
34 | "Topic :: Internet",
35 | "Topic :: Software Development :: Libraries",
36 | "Topic :: System",
37 | ]
38 | dynamic = [
39 | "version",
40 | ]
41 | dependencies = [
42 | "packaging>=25",
43 | "tomli>=2.3; python_version<'3.11'",
44 | "tox<5,>=4.31",
45 | "typing-extensions>=4.15; python_version<'3.10'",
46 | "uv<1,>=0.9.1",
47 | ]
48 | urls.Changelog = "https://github.com/tox-dev/tox-uv/releases"
49 | urls.Documentation = "https://github.com/tox-dev/tox-uv#tox-uv"
50 | urls.Homepage = "https://github.com/tox-dev/tox-uv"
51 | urls.Source = "https://github.com/tox-dev/tox-uv"
52 | urls.Tracker = "https://github.com/tox-dev/tox-uv/issues"
53 | entry-points.tox.tox-uv = "tox_uv.plugin"
54 |
55 | [dependency-groups]
56 | dev = [
57 | { include-group = "lint" },
58 | { include-group = "pkg-meta" },
59 | { include-group = "test" },
60 | { include-group = "type" },
61 | ]
62 | test = [
63 | "covdefaults>=2.3",
64 | "devpi-process>=1.0.2",
65 | "diff-cover>=9.7.1",
66 | "pytest>=8.4.2",
67 | "pytest-cov>=7",
68 | "pytest-mock>=3.15.1",
69 | ]
70 | type = [ "mypy==1.18.2", "types-setuptools>=80.9.0.20250822", { include-group = "test" } ]
71 | lint = [ "pre-commit-uv>=4.2" ]
72 | pkg-meta = [ "check-wheel-contents>=0.6.3", "twine>=6.2", "uv>=0.9.1" ]
73 |
74 | [tool.hatch]
75 | build.hooks.vcs.version-file = "src/tox_uv/version.py"
76 | build.targets.sdist.include = [
77 | "/src",
78 | "/tests",
79 | ]
80 | version.source = "vcs"
81 |
82 | [tool.ruff]
83 | line-length = 120
84 | fix = true
85 | unsafe-fixes = true
86 | format.preview = true
87 | format.docstring-code-line-length = 100
88 | format.docstring-code-format = true
89 | lint.select = [
90 | "ALL",
91 | ]
92 | lint.ignore = [
93 | "COM812", # Conflict with formatter
94 | "CPY", # No copyright statements
95 | "D", # no documentation for now
96 | "D203", # `one-blank-line-before-class` (D203) and `no-blank-line-before-class` (D211) are incompatible
97 | "D205", # 1 blank line required between summary line and description
98 | "D212", # `multi-line-summary-first-line` (D212) and `multi-line-summary-second-line` (D213) are incompatible
99 | "D301", # Use `r"""` if any backslashes in a docstring
100 | "D401", # First line of docstring should be in imperative mood
101 | "DOC201", # no support for sphinx
102 | "ISC001", # Conflict with formatter
103 | "S104", # Possible binding to all interface
104 | ]
105 | lint.per-file-ignores."tests/**/*.py" = [
106 | "D", # don't care about documentation in tests
107 | "FBT", # don't care about booleans as positional arguments in tests
108 | "INP001", # no implicit namespace
109 | "PLC2701", # private import is fine
110 | "PLR2004", # Magic value used in comparison, consider replacing with a constant variable
111 | "S", # no safety concerns
112 | "S101", # asserts allowed in tests...
113 | ]
114 | lint.isort = { known-first-party = [
115 | "tox_uv",
116 | "tests",
117 | ], required-imports = [
118 | "from __future__ import annotations",
119 | ] }
120 | lint.preview = true
121 |
122 | [tool.codespell]
123 | builtin = "clear,usage,en-GB_to_en-US"
124 | write-changes = true
125 | count = true
126 |
127 | [tool.pytest.ini_options]
128 | norecursedirs = "tests/data/*"
129 | verbosity_assertions = 2
130 |
131 | [tool.coverage]
132 | html.show_contexts = true
133 | html.skip_covered = false
134 | paths.source = [
135 | "src",
136 | ".tox/*/lib/*/site-packages",
137 | ".tox\\*\\Lib\\site-packages",
138 | "**/src",
139 | "**\\src",
140 | ]
141 | paths.other = [
142 | ".",
143 | "*/tox_uv",
144 | "*\\tox_uv",
145 | ]
146 | report.omit = [
147 | "src/tox_uv/_venv_query.py",
148 | ]
149 | report.fail_under = 100
150 | run.parallel = true
151 | run.patch = [ "subprocess" ]
152 | run.plugins = [
153 | "covdefaults",
154 | ]
155 |
156 | [tool.mypy]
157 | python_version = "3.12"
158 | show_error_codes = true
159 | strict = true
160 | overrides = [
161 | { module = [
162 | "virtualenv.*",
163 | "uv.*",
164 | ], ignore_missing_imports = true },
165 | ]
166 |
--------------------------------------------------------------------------------
/src/tox_uv/_run_lock.py:
--------------------------------------------------------------------------------
1 | """GitHub Actions integration."""
2 |
3 | from __future__ import annotations
4 |
5 | import sys
6 | from pathlib import Path
7 | from typing import TYPE_CHECKING, Literal, cast
8 |
9 | from tox.execute.request import StdinSource
10 | from tox.report import HandledError
11 | from tox.tox_env.python.package import SdistPackage, WheelPackage
12 | from tox.tox_env.python.runner import add_extras_to_env, add_skip_missing_interpreters_to_core
13 | from tox.tox_env.runner import RunToxEnv
14 |
15 | from ._venv import UvVenv
16 |
17 | if sys.version_info >= (3, 11): # pragma: no cover (py311+)
18 | import tomllib
19 | else: # pragma: no cover (py311+)
20 | import tomli as tomllib
21 |
22 | if TYPE_CHECKING:
23 | from tox.tox_env.package import Package
24 |
25 |
26 | class UvVenvLockRunner(UvVenv, RunToxEnv):
27 | @staticmethod
28 | def id() -> str:
29 | return "uv-venv-lock-runner"
30 |
31 | def _register_package_conf(self) -> bool: # noqa: PLR6301
32 | return False
33 |
34 | @property
35 | def _package_tox_env_type(self) -> str:
36 | raise NotImplementedError
37 |
38 | @property
39 | def _external_pkg_tox_env_type(self) -> str:
40 | raise NotImplementedError
41 |
42 | def _build_packages(self) -> list[Package]:
43 | raise NotImplementedError
44 |
45 | def register_config(self) -> None:
46 | super().register_config()
47 | add_extras_to_env(self.conf)
48 | self.conf.add_config(
49 | keys=["dependency_groups"],
50 | of_type=set[str],
51 | default=set(),
52 | desc="dependency groups to install of the target package",
53 | )
54 | self.conf.add_config(
55 | keys=["no_default_groups"],
56 | of_type=bool,
57 | default=lambda _, __: bool(self.conf["dependency_groups"]),
58 | desc="Install default groups or not",
59 | )
60 | self.conf.add_config(
61 | keys=["uv_sync_flags"],
62 | of_type=list[str],
63 | default=[],
64 | desc="Additional flags to pass to uv sync (for flags not configurable via environment variables)",
65 | )
66 | self.conf.add_config(
67 | keys=["uv_sync_locked"],
68 | of_type=bool,
69 | default=True,
70 | desc="When set to 'false', it will remove `--locked` argument from 'uv sync' implicit arguments.",
71 | )
72 | self.conf.add_config( # type: ignore[call-overload]
73 | keys=["package"],
74 | of_type=Literal["editable", "wheel", "skip"],
75 | default="editable",
76 | desc="How should the package be installed",
77 | )
78 | add_skip_missing_interpreters_to_core(self.core, self.options)
79 |
80 | def _setup_env(self) -> None: # noqa: C901,PLR0912
81 | super()._setup_env()
82 | install_pkg = getattr(self.options, "install_pkg", None)
83 | if not getattr(self.options, "skip_uv_sync", False):
84 | cmd = [
85 | "uv",
86 | "sync",
87 | ]
88 | if self.conf["uv_sync_locked"]:
89 | cmd.append("--locked")
90 | if self.conf["uv_python_preference"] != "none":
91 | cmd.extend(("--python-preference", self.conf["uv_python_preference"]))
92 | if self.conf["uv_resolution"]:
93 | cmd.extend(("--resolution", self.conf["uv_resolution"]))
94 | for extra in cast("set[str]", sorted(self.conf["extras"])):
95 | cmd.extend(("--extra", extra))
96 | groups = sorted(self.conf["dependency_groups"])
97 | if self.conf["no_default_groups"]:
98 | cmd.append("--no-default-groups")
99 | package = self.conf["package"]
100 | if install_pkg is not None or package == "skip":
101 | cmd.append("--no-install-project")
102 | if self.options.verbosity > 3: # noqa: PLR2004
103 | cmd.append("-v")
104 | if package == "wheel":
105 | # need the package name here but we don't have the packaging infrastructure -> read from pyproject.toml
106 | project_file = self.core["tox_root"] / "pyproject.toml"
107 | name = None
108 | if project_file.exists():
109 | with project_file.open("rb") as file_handler:
110 | raw = tomllib.load(file_handler)
111 | name = raw.get("project", {}).get("name")
112 | if name is None:
113 | msg = "Could not detect project name"
114 | raise HandledError(msg)
115 | cmd.extend(("--no-editable", "--reinstall-package", name))
116 | for group in groups:
117 | cmd.extend(("--group", group))
118 | cmd.extend(self.conf["uv_sync_flags"])
119 | cmd.extend(("-p", self.env_version_spec()))
120 |
121 | show = self.options.verbosity > 2 # noqa: PLR2004
122 | outcome = self.execute(cmd, stdin=StdinSource.OFF, run_id="uv-sync", show=show)
123 | outcome.assert_success()
124 | if install_pkg is not None:
125 | path = Path(install_pkg)
126 | pkg = (WheelPackage if path.suffix == ".whl" else SdistPackage)(path, deps=[])
127 | self._install([pkg], "install-pkg", of_type="external")
128 |
129 | @property
130 | def environment_variables(self) -> dict[str, str]:
131 | env = super().environment_variables
132 | env["UV_PROJECT_ENVIRONMENT"] = str(self.venv_dir)
133 | return env
134 |
135 |
136 | __all__ = [
137 | "UvVenvLockRunner",
138 | ]
139 |
--------------------------------------------------------------------------------
/tests/test_tox_uv_installer.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import sys
4 | from typing import TYPE_CHECKING
5 |
6 | import pytest
7 |
8 | if TYPE_CHECKING:
9 | from tox.pytest import ToxProjectCreator
10 |
11 |
12 | def test_uv_install_in_ci_list(tox_project: ToxProjectCreator, monkeypatch: pytest.MonkeyPatch) -> None:
13 | monkeypatch.setenv("CI", "1")
14 | project = tox_project({"tox.ini": "[testenv]\ndeps = tomli\npackage=skip"})
15 | result = project.run()
16 | result.assert_success()
17 | report = {i.split("=")[0] for i in result.out.splitlines()[-3][4:].split(",")}
18 | assert report == {"tomli"}
19 |
20 |
21 | def test_uv_install_in_ci_seed(tox_project: ToxProjectCreator, monkeypatch: pytest.MonkeyPatch) -> None:
22 | monkeypatch.setenv("CI", "1")
23 | project = tox_project({"tox.ini": "[testenv]\npackage=skip\nuv_seed = true"})
24 | result = project.run()
25 | result.assert_success()
26 | report = {i.split("=")[0] for i in result.out.splitlines()[-3][4:].split(",")}
27 | if sys.version_info >= (3, 12): # pragma: >=3.12 cover
28 | assert report == {"pip"}
29 | else: # pragma: <3.12 cover
30 | assert report == {"pip", "setuptools", "wheel"}
31 |
32 |
33 | def test_uv_install_with_pre(tox_project: ToxProjectCreator) -> None:
34 | project = tox_project({"tox.ini": "[testenv]\ndeps = tomli\npip_pre = true\npackage=skip"})
35 | result = project.run("-vv")
36 | result.assert_success()
37 |
38 |
39 | def test_uv_install_with_pre_custom_install_cmd(tox_project: ToxProjectCreator) -> None:
40 | project = tox_project({
41 | "tox.ini": """
42 | [testenv]
43 | deps = tomli
44 | pip_pre = true
45 | package = skip
46 | install_command = uv pip install {packages}
47 | """
48 | })
49 | result = project.run("-vv")
50 | result.assert_success()
51 |
52 |
53 | def test_uv_install_without_pre_custom_install_cmd(tox_project: ToxProjectCreator) -> None:
54 | project = tox_project({
55 | "tox.ini": """
56 | [testenv]
57 | deps = tomli
58 | package = skip
59 | install_command = uv pip install {packages}
60 | """
61 | })
62 | result = project.run("-vv")
63 | result.assert_success()
64 |
65 |
66 | @pytest.mark.parametrize("strategy", ["highest", "lowest", "lowest-direct"])
67 | def test_uv_install_with_resolution_strategy(tox_project: ToxProjectCreator, strategy: str) -> None:
68 | project = tox_project({"tox.ini": f"[testenv]\ndeps = tomli>=2.0.1\npackage = skip\nuv_resolution = {strategy}"})
69 | execute_calls = project.patch_execute(lambda r: 0 if "install" in r.run_id else None)
70 |
71 | result = project.run("-vv")
72 | result.assert_success()
73 |
74 | assert execute_calls.call_args[0][3].cmd[2:] == ["install", "--resolution", strategy, "tomli>=2.0.1", "-v"]
75 |
76 |
77 | def test_uv_install_with_invalid_resolution_strategy(tox_project: ToxProjectCreator) -> None:
78 | project = tox_project({"tox.ini": "[testenv]\ndeps = tomli>=2.0.1\npackage = skip\nuv_resolution = invalid"})
79 |
80 | result = project.run("-vv")
81 | result.assert_failed(code=1)
82 |
83 | assert "Invalid value for uv_resolution: 'invalid'." in result.out
84 |
85 |
86 | def test_uv_install_with_resolution_strategy_custom_install_cmd(tox_project: ToxProjectCreator) -> None:
87 | project = tox_project({
88 | "tox.ini": """
89 | [testenv]
90 | deps = tomli>=2.0.1
91 | package = skip
92 | uv_resolution = lowest-direct
93 | install_command = uv pip install {packages}
94 | """
95 | })
96 | execute_calls = project.patch_execute(lambda r: 0 if "install" in r.run_id else None)
97 |
98 | result = project.run("-vv")
99 | result.assert_success()
100 |
101 | assert execute_calls.call_args[0][3].cmd[2:] == ["install", "tomli>=2.0.1", "--resolution", "lowest-direct"]
102 |
103 |
104 | def test_uv_install_with_resolution_strategy_and_pip_pre(tox_project: ToxProjectCreator) -> None:
105 | project = tox_project({
106 | "tox.ini": """
107 | [testenv]
108 | deps = tomli>=2.0.1
109 | package = skip
110 | uv_resolution = lowest-direct
111 | pip_pre = true
112 | """
113 | })
114 | execute_calls = project.patch_execute(lambda r: 0 if "install" in r.run_id else None)
115 | result = project.run("-vv")
116 | result.assert_success()
117 | assert execute_calls.call_args[0][3].cmd[2:] == [
118 | "install",
119 | "--prerelease",
120 | "allow",
121 | "--resolution",
122 | "lowest-direct",
123 | "tomli>=2.0.1",
124 | "-v",
125 | ]
126 |
127 |
128 | def test_uv_install_broken_venv(tox_project: ToxProjectCreator) -> None:
129 | """Tests ability to detect that a venv a with broken symlink to python interpreter is recreated."""
130 | project = tox_project({
131 | "tox.ini": """
132 | [testenv]
133 | skip_install = true
134 | install = false
135 | commands = {env_python} --version
136 | """
137 | })
138 | result = project.run("run", "-v")
139 | result.assert_success()
140 | assert "recreate env because existing venv is broken" not in result.out
141 | # break the environment
142 | if sys.platform != "win32": # pragma: win32 no cover
143 | bin_dir = project.path / ".tox" / "py" / "bin"
144 | executables = ("python", "python3")
145 | else: # pragma: win32 cover
146 | bin_dir = project.path / ".tox" / "py" / "Scripts"
147 | executables = ("python.exe", "pythonw.exe")
148 | bin_dir.mkdir(parents=True, exist_ok=True)
149 | for filename in executables:
150 | path = bin_dir / filename
151 | path.unlink(missing_ok=True)
152 | path.symlink_to("/broken-location")
153 | # run again and ensure we did run the repair bits
154 | result = project.run("run", "-v")
155 | result.assert_success()
156 | assert "recreate env because existing venv is broken" in result.out
157 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # tox-uv
2 |
3 | [](https://badge.fury.io/py/tox-uv)
4 | [](https://pypi.python.org/pypi/tox-uv/)
5 | [](https://github.com/tox-dev/tox-uv/actions/workflows/check.yaml)
6 | [](https://pepy.tech/project/tox-uv)
7 |
8 | **tox-uv** is a `tox` plugin, which replaces `virtualenv` and pip with `uv` in your `tox` environments. Note that you
9 | will get both the benefits (performance) or downsides (bugs) of `uv`.
10 |
11 |
12 |
13 | - [How to use](#how-to-use)
14 | - [tox environment types provided](#tox-environment-types-provided)
15 | - [uv.lock support](#uvlock-support)
16 | - [package](#package)
17 | - [extras](#extras)
18 | - [no_default_groups](#no_default_groups)
19 | - [dependency_groups](#dependency_groups)
20 | - [uv_sync_flags](#uv_sync_flags)
21 | - [uv_sync_locked](#uv_sync_locked)
22 | - [External package support](#external-package-support)
23 | - [Environment creation](#environment-creation)
24 | - [uv_seed](#uv_seed)
25 | - [uv_python_preference](#uv_python_preference)
26 | - [Package installation](#package-installation)
27 | - [uv_resolution](#uv_resolution)
28 |
29 |
30 |
31 | ## How to use
32 |
33 | Install `tox-uv` into the environment of your tox, and it will replace `virtualenv` and `pip` for all runs:
34 |
35 | ```bash
36 | uv tool install tox --with tox-uv # use uv to install
37 | tox --version # validate you are using the installed tox
38 | tox r -e py312 # will use uv
39 | tox --runner virtualenv r -e py312 # will use virtualenv+pip
40 | ```
41 |
42 | ## tox environment types provided
43 |
44 | This package will provide the following new tox environments:
45 |
46 | - `uv-venv-runner` is the ID for the tox environments [runner](https://tox.wiki/en/4.12.1/config.html#runner) for
47 | environments not using a lock file.
48 | - `uv-venv-lock-runner` is the ID for the tox environments [runner](https://tox.wiki/en/4.12.1/config.html#runner) for
49 | environments using `uv.lock` (note we can’t detect the presence of the `uv.lock` file to enable this because that
50 | would break environments not using the lock file - such as your linter).
51 | - `uv-venv-pep-517` is the ID for the PEP-517 packaging environment.
52 | - `uv-venv-cmd-builder` is the ID for the external cmd builder.
53 |
54 | ## uv.lock support
55 |
56 | 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
57 | `runner` to `uv-venv-lock-runner`. Furthermore, should in such environments you use the `extras` config to instruct `uv`
58 | to install the specified extras, for example (this example is for the `tox.ini`, for other formats see the documentation
59 | [here](https://tox.wiki/en/latest/config.html#discovery-and-file-types)):
60 |
61 | ```ini
62 |
63 | [testenv:fix]
64 | description = run code formatter and linter (auto-fix)
65 | skip_install = true
66 | deps =
67 | pre-commit-uv>=4.1.1
68 | commands =
69 | pre-commit run --all-files --show-diff-on-failure
70 |
71 | [testenv:type]
72 | runner = uv-venv-lock-runner
73 | description = run type checker via mypy
74 | commands =
75 | mypy {posargs:src}
76 |
77 | [testenv:dev]
78 | runner = uv-venv-lock-runner
79 | description = dev environment
80 | extras =
81 | dev
82 | test
83 | type
84 | commands =
85 | uv pip tree
86 | ```
87 |
88 | In this example:
89 |
90 | - `fix` will use the `uv-venv-runner` and use `uv pip install` to install dependencies to the environment.
91 | - `type` will use the `uv-venv-lock-runner` and use `uv sync` to install dependencies to the environment without any
92 | extra group.
93 | - `dev` will use the `uv-venv-lock-runner` and use `uv sync` to install dependencies to the environment with the `dev`,
94 | `test` and `type` extra groups.
95 |
96 | Note that when using `uv-venv-lock-runner`, _all_ dependencies will come from the lock file, controlled by `extras`.
97 | Therefore, options like `deps` are ignored (and all others
98 | [enumerated here](https://tox.wiki/en/stable/config.html#python-run) as Python run flags).
99 |
100 | ### `package`
101 |
102 | How to install the source tree package, must be one of:
103 |
104 | - `skip`,
105 | - `wheel`,
106 | - `editable` (default),
107 | - `uv` (use uv to install the project, rather than build wheel via `tox`),
108 | - `uv-editable` (use uv to install the project in editable mode, rather than build wheel via `tox`).
109 |
110 | You should use the latter two in case you need to use any non-standard features of `uv`, such as `tool.uv.sources`.
111 |
112 | ### `extras`
113 |
114 | A list of string that selects, which extra groups you want to install with `uv sync`. By default, it is empty.
115 |
116 | ### `no_default_groups`
117 |
118 | A boolean flag to toggle installation of the `uv`
119 | [default development groups](https://docs.astral.sh/uv/concepts/projects/dependencies/#default-groups). By default, it
120 | will be `true` if the `dependency_groups` is not empty and `false` otherwise.
121 |
122 | ### `dependency_groups`
123 |
124 | Specify [PEP 735 – Dependency Groups](https://peps.python.org/pep-0735/) to install.
125 |
126 | ### `uv_sync_flags`
127 |
128 | A list of strings, containing additional flags to pass to uv sync (useful because some flags are not configurable via
129 | environment variables). For example, if you want to install the package in non editable mode and keep extra packages
130 | installed into the environment you can do:
131 |
132 | ```ini
133 | uv_sync_flags = --no-editable, --inexact
134 | ```
135 |
136 | ### `uv_sync_locked`
137 |
138 | By default tox-uv will call `uv sync` with `--locked` argument, which is incompatible with other arguments like
139 | `--prerelease` or `--upgrade ` that you might want to add to `uv_sync_flags` for some test scenarios. You can set this
140 | to `false` to avoid such conflicts.
141 |
142 | ### External package support
143 |
144 | Should tox be invoked with the [`--installpkg`](https://tox.wiki/en/stable/cli_interface.html#tox-run---installpkg) flag
145 | (the argument **must** be either a wheel or source distribution) the sync operation will run with `--no-install-project`
146 | and `uv pip install` will be used afterward to install the provided package.
147 |
148 | ## Environment creation
149 |
150 | We use `uv venv` to create virtual environments. This process can be configured with the following options:
151 |
152 | ### `uv_seed`
153 |
154 | This flag, set on a tox environment level, controls if the created virtual environment injects `pip`, `setuptools` and
155 | `wheel` into the created virtual environment or not. By default, it is off. You will need to set this if you have a
156 | project that uses the old legacy-editable mode, or your project doesn’t support the `pyproject.toml` powered isolated
157 | build model.
158 |
159 | ### `uv_python_preference`
160 |
161 | This flag, set on a tox environment level, controls how `uv` select the Python interpreter.
162 |
163 | By default, `uv` will attempt to use Python versions found on the system and only download managed interpreters when
164 | necessary. However, It is possible to adjust `uv`'s Python version selection preference with the
165 | [python-preference](https://docs.astral.sh/uv/concepts/python-versions/#adjusting-python-version-preferences) option.
166 |
167 | ### `system_site_packages` (`sitepackages`)
168 |
169 | Create virtual environments that also have access to globally installed packages. Note the default value may be
170 | overwritten by the VIRTUALENV_SYSTEM_SITE_PACKAGES environment variable. This flag works the same way as the one from
171 | [tox native virtualenv implementation](https://tox.wiki/en/latest/config.html#system_site_packages).
172 |
173 | ## Package installation
174 |
175 | We use `uv pip` to install packages into the virtual environment. The behavior of this can be configured via the
176 | following options:
177 |
178 | ### `uv_resolution`
179 |
180 | This flag, set on a tox environment level, informs `uv` of the desired [resolution strategy]:
181 |
182 | - `highest` - (default) selects the highest version of a package satisfying the constraints.
183 | - `lowest` - install the **lowest** compatible versions for all dependencies, both **direct** and **transitive**.
184 | - `lowest-direct` - opt for the **lowest** compatible versions for all **direct** dependencies, while using the
185 | **latest** compatible versions for all **transitive** dependencies.
186 |
187 | This is an `uv` specific feature that may be used as an alternative to frozen constraints for test environments if the
188 | intention is to validate the lower bounds of your dependencies during test executions.
189 |
190 | [resolution strategy]: https://github.com/astral-sh/uv/blob/0.1.20/README.md#resolution-strategy
191 |
--------------------------------------------------------------------------------
/src/tox_uv/_installer.py:
--------------------------------------------------------------------------------
1 | """GitHub Actions integration."""
2 |
3 | from __future__ import annotations
4 |
5 | import logging
6 | import sys
7 | from collections import defaultdict
8 | from collections.abc import Sequence
9 | from functools import cached_property
10 | from itertools import chain
11 | from typing import TYPE_CHECKING, Any, Final
12 |
13 | if sys.version_info >= (3, 11): # pragma: no cover (py311+)
14 | import tomllib
15 | else: # pragma: no cover (py311+)
16 | import tomli as tomllib
17 | from packaging.requirements import Requirement
18 | from packaging.utils import parse_sdist_filename, parse_wheel_filename
19 | from tox.config.types import Command
20 | from tox.tox_env.errors import Fail, Recreate
21 | from tox.tox_env.python.package import EditableLegacyPackage, EditablePackage, SdistPackage, WheelPackage
22 | from tox.tox_env.python.pip.pip_install import Pip
23 | from tox.tox_env.python.pip.req_file import PythonDeps
24 | from uv import find_uv_bin
25 |
26 | from ._package_types import UvEditablePackage, UvPackage
27 |
28 | if TYPE_CHECKING:
29 | from tox.config.main import Config
30 | from tox.tox_env.package import PathPackage
31 | from tox.tox_env.python.api import Python
32 |
33 |
34 | _LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
35 |
36 |
37 | class UvInstaller(Pip):
38 | """Pip is a python installer that can install packages as defined by PEP-508 and PEP-517."""
39 |
40 | def __init__(self, tox_env: Python, with_list_deps: bool = True) -> None: # noqa: FBT001, FBT002
41 | self._with_list_deps = with_list_deps
42 | super().__init__(tox_env)
43 |
44 | def freeze_cmd(self) -> list[str]:
45 | return [self.uv, "--color", "never", "pip", "freeze"]
46 |
47 | @property
48 | def uv(self) -> str:
49 | return find_uv_bin()
50 |
51 | def _register_config(self) -> None:
52 | super()._register_config()
53 |
54 | def uv_resolution_post_process(value: str) -> str:
55 | valid_opts = {"highest", "lowest", "lowest-direct"}
56 | if value and value not in valid_opts:
57 | msg = f"Invalid value for uv_resolution: {value!r}. Valid options are: {', '.join(valid_opts)}."
58 | raise Fail(msg)
59 | return value
60 |
61 | self._env.conf.add_config(
62 | keys=["uv_resolution"],
63 | of_type=str,
64 | default="",
65 | desc="Define the resolution strategy for uv",
66 | post_process=uv_resolution_post_process,
67 | )
68 |
69 | def default_install_command(self, conf: Config, env_name: str | None) -> Command: # noqa: ARG002
70 | cmd = [self.uv, "pip", "install", "{opts}", "{packages}"]
71 | if self._env.options.verbosity > 3: # noqa: PLR2004
72 | cmd.append("-v")
73 | return Command(cmd)
74 |
75 | def post_process_install_command(self, cmd: Command) -> Command:
76 | install_command = cmd.args
77 | pip_pre: bool = self._env.conf["pip_pre"]
78 | uv_resolution: str = self._env.conf["uv_resolution"]
79 | try:
80 | opts_at = install_command.index("{opts}")
81 | except ValueError:
82 | if pip_pre:
83 | install_command.extend(("--prerelease", "allow"))
84 | if uv_resolution:
85 | install_command.extend(("--resolution", uv_resolution))
86 | else:
87 | opts: list[str] = []
88 | if pip_pre:
89 | opts.extend(("--prerelease", "allow"))
90 | if uv_resolution:
91 | opts.extend(("--resolution", uv_resolution))
92 | install_command[opts_at : opts_at + 1] = opts
93 | return cmd
94 |
95 | def install(self, arguments: Any, section: str, of_type: str) -> None: # noqa: ANN401
96 | # can happen if the original python was upgraded to a newer version and
97 | # the symlinks become orphan.
98 | if not self._env.env_python().resolve().is_file():
99 | msg = "existing venv is broken"
100 | raise Recreate(msg)
101 |
102 | if isinstance(arguments, PythonDeps):
103 | self._install_requirement_file(arguments, section, of_type)
104 | elif isinstance(arguments, Sequence): # pragma: no branch
105 | self._install_list_of_deps(arguments, section, of_type)
106 | else: # pragma: no cover
107 | _LOGGER.warning("uv cannot install %r", arguments) # pragma: no cover
108 | raise SystemExit(1) # pragma: no cover
109 |
110 | @cached_property
111 | def _sourced_pkg_names(self) -> set[str]:
112 | pyproject_file = self._env.conf._conf.src_path.parent / "pyproject.toml" # noqa: SLF001
113 | if not pyproject_file.exists(): # pragma: no cover
114 | return set()
115 | with pyproject_file.open("rb") as file_handler:
116 | pyproject = tomllib.load(file_handler)
117 |
118 | sources = pyproject.get("tool", {}).get("uv", {}).get("sources", {})
119 | return {key for key, val in sources.items() if val.get("workspace", False)}
120 |
121 | def _install_list_of_deps( # noqa: C901, PLR0912
122 | self,
123 | arguments: Sequence[
124 | Requirement | WheelPackage | SdistPackage | EditableLegacyPackage | EditablePackage | PathPackage
125 | ],
126 | section: str,
127 | of_type: str,
128 | ) -> None:
129 | groups: dict[str, list[str]] = defaultdict(list)
130 | for arg in arguments:
131 | if isinstance(arg, Requirement): # pragma: no branch
132 | groups["req"].append(str(arg)) # pragma: no cover
133 | elif isinstance(arg, (WheelPackage, SdistPackage, EditablePackage)):
134 | for pkg in arg.deps:
135 | if (
136 | isinstance(pkg, Requirement)
137 | and pkg.name in self._sourced_pkg_names
138 | and "." not in groups["uv_editable"]
139 | ):
140 | groups["uv_editable"].append(".")
141 | continue
142 | groups["req"].append(str(pkg))
143 | parser = parse_sdist_filename if isinstance(arg, SdistPackage) else parse_wheel_filename
144 | name, *_ = parser(arg.path.name)
145 | groups["pkg"].append(f"{name}@{arg.path}")
146 | elif isinstance(arg, EditableLegacyPackage):
147 | groups["req"].extend(str(pkg) for pkg in arg.deps)
148 | groups["dev_pkg"].append(str(arg.path))
149 | elif isinstance(arg, UvPackage):
150 | extras_suffix = f"[{','.join(arg.extras)}]" if arg.extras else ""
151 | groups["uv"].append(f"{arg.path}{extras_suffix}")
152 | elif isinstance(arg, UvEditablePackage):
153 | extras_suffix = f"[{','.join(arg.extras)}]" if arg.extras else ""
154 | groups["uv_editable"].append(f"{arg.path}{extras_suffix}")
155 | else: # pragma: no branch
156 | _LOGGER.warning("uv install %r", arg) # pragma: no cover
157 | raise SystemExit(1) # pragma: no cover
158 | req_of_type = f"{of_type}_deps" if groups["pkg"] or groups["dev_pkg"] else of_type
159 | for value in groups.values():
160 | value.sort()
161 | with self._env.cache.compare(groups["req"], section, req_of_type) as (eq, old):
162 | if not eq: # pragma: no branch
163 | miss = sorted(set(old or []) - set(groups["req"]))
164 | if miss: # no way yet to know what to uninstall here (transitive dependencies?) # pragma: no branch
165 | msg = f"dependencies removed: {', '.join(str(i) for i in miss)}" # pragma: no cover
166 | raise Recreate(msg) # pragma: no branch # pragma: no cover
167 | new_deps = sorted(set(groups["req"]) - set(old or []))
168 | if new_deps: # pragma: no branch
169 | self._execute_installer(new_deps, req_of_type)
170 | install_args = ["--reinstall"]
171 | if groups["uv"]:
172 | self._execute_installer(install_args + groups["uv"], of_type)
173 | if groups["uv_editable"]:
174 | requirements = list(chain.from_iterable(("-e", entry) for entry in groups["uv_editable"]))
175 | self._execute_installer(install_args + requirements, of_type)
176 | install_args.append("--no-deps")
177 | if groups["pkg"]:
178 | self._execute_installer(install_args + groups["pkg"], of_type)
179 | if groups["dev_pkg"]:
180 | for entry in groups["dev_pkg"]:
181 | install_args.extend(("-e", str(entry)))
182 | self._execute_installer(install_args, of_type)
183 |
184 |
185 | __all__ = [
186 | "UvInstaller",
187 | ]
188 |
--------------------------------------------------------------------------------
/src/tox_uv/_venv.py:
--------------------------------------------------------------------------------
1 | """GitHub Actions integration."""
2 |
3 | from __future__ import annotations
4 |
5 | import json
6 | import logging
7 | import os
8 | import sys
9 | import sysconfig
10 | from abc import ABC
11 | from functools import cached_property
12 | from importlib.resources import as_file, files
13 | from pathlib import Path
14 | from platform import python_implementation
15 | from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias, cast
16 |
17 | from tox.config.loader.str_convert import StrConvert
18 | from tox.execute.local_sub_process import LocalSubProcessExecutor
19 | from tox.execute.request import StdinSource
20 | from tox.tox_env.errors import Skip
21 | from tox.tox_env.python.api import Python, PythonInfo, VersionInfo
22 | from tox.tox_env.python.virtual_env.api import VirtualEnv
23 | from uv import find_uv_bin
24 | from virtualenv.discovery.py_spec import PythonSpec
25 |
26 | from ._installer import UvInstaller
27 |
28 | if TYPE_CHECKING:
29 | from tox.execute.api import Execute
30 | from tox.tox_env.api import ToxEnvCreateArgs
31 | from tox.tox_env.installer import Installer
32 |
33 |
34 | PythonPreference: TypeAlias = Literal[
35 | "none",
36 | "only-managed",
37 | "managed",
38 | "system",
39 | "only-system",
40 | ]
41 | _LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
42 |
43 |
44 | class UvVenv(Python, ABC):
45 | def __init__(self, create_args: ToxEnvCreateArgs) -> None:
46 | self._executor: Execute | None = None
47 | self._installer: UvInstaller | None = None
48 | self._created = False
49 | self._displayed_uv_constraint_warning = False
50 | super().__init__(create_args)
51 |
52 | def register_config(self) -> None:
53 | super().register_config()
54 | self.conf.add_config(
55 | keys=["uv_seed"],
56 | of_type=bool,
57 | default=False,
58 | desc="add seed packages to the created venv",
59 | )
60 | self.conf.add_config(
61 | keys=["system_site_packages", "sitepackages"],
62 | of_type=bool,
63 | default=lambda conf, name: StrConvert().to_bool( # noqa: ARG005
64 | self.environment_variables.get("VIRTUALENV_SYSTEM_SITE_PACKAGES", "False"),
65 | ),
66 | desc="create virtual environments that also have access to globally installed packages.",
67 | )
68 |
69 | def uv_python_preference_default(conf: object, name: object) -> str: # noqa: ARG001
70 | return (
71 | "none"
72 | if {"UV_NO_MANAGED_PYTHON", "UV_MANAGED_PYTHON"} & set(os.environ)
73 | else os.environ.get("UV_PYTHON_PREFERENCE", "system")
74 | )
75 |
76 | def uv_python_preference_post_process(value: str | None) -> str:
77 | if value is not None:
78 | return value.lower()
79 | return "system"
80 |
81 | # The cast(...) might seems superfluous but removing it makes mypy crash. The problem isy on tox typing side.
82 | self.conf.add_config(
83 | keys=["uv_python_preference"],
84 | of_type=cast("type[PythonPreference | None]", PythonPreference | None), # type: ignore[valid-type]
85 | # use os.environ here instead of self.environment_variables as this value is needed to create the virtual
86 | # environment, if environment variables use env_site_packages_dir we would run into a chicken-egg problem.
87 | default=uv_python_preference_default,
88 | desc=(
89 | "Whether to prefer using Python installations that are already"
90 | " present on the system, or those that are downloaded and"
91 | " installed by uv [possible values: none, only-managed, installed,"
92 | " managed, system, only-system]. Use none to use uv's"
93 | " default. Our default value is 'system', while uv's default"
94 | " value is 'managed' because we prefer using same python"
95 | " interpreters with all tox environments and avoid accidental"
96 | " downloading of other interpreters."
97 | ),
98 | post_process=uv_python_preference_post_process,
99 | )
100 |
101 | def python_cache(self) -> dict[str, Any]:
102 | result = super().python_cache()
103 | result["seed"] = self.conf["uv_seed"]
104 | if self.conf["uv_python_preference"] != "none":
105 | result["python_preference"] = self.conf["uv_python_preference"]
106 | result["venv"] = str(self.venv_dir.relative_to(self.env_dir))
107 | return result
108 |
109 | @property
110 | def executor(self) -> Execute:
111 | if self._executor is None:
112 | self._executor = LocalSubProcessExecutor(self.options.is_colored)
113 | return self._executor
114 |
115 | @property
116 | def installer(self) -> Installer[Any]:
117 | if self._installer is None:
118 | self._installer = UvInstaller(self)
119 | return self._installer
120 |
121 | @property
122 | def runs_on_platform(self) -> str:
123 | return sys.platform
124 |
125 | def _get_python(self, base_python: list[str]) -> PythonInfo | None: # noqa: PLR6301
126 | for base in base_python: # pragma: no branch
127 | if base == sys.executable:
128 | version_info = sys.version_info
129 | return PythonInfo(
130 | implementation=python_implementation(),
131 | version_info=VersionInfo(
132 | major=version_info.major,
133 | minor=version_info.minor,
134 | micro=version_info.micro,
135 | releaselevel=version_info.releaselevel,
136 | serial=version_info.serial,
137 | ),
138 | version=sys.version,
139 | is_64=sys.maxsize > 2**32,
140 | platform=sys.platform,
141 | extra={},
142 | free_threaded=sysconfig.get_config_var("Py_GIL_DISABLED") == 1,
143 | )
144 | base_path = Path(base)
145 | if base_path.is_absolute(): # pragma: win32 no cover
146 | info = VirtualEnv.get_virtualenv_py_info(base_path)
147 | return PythonInfo(
148 | implementation=info.implementation,
149 | version_info=VersionInfo(*info.version_info),
150 | version=info.version,
151 | is_64=info.architecture == 64, # noqa: PLR2004
152 | platform=info.platform,
153 | extra={"executable": base},
154 | free_threaded=info.free_threaded,
155 | )
156 | spec = PythonSpec.from_string_spec(base)
157 | return PythonInfo(
158 | implementation=spec.implementation or "CPython",
159 | version_info=VersionInfo(
160 | major=spec.major,
161 | minor=spec.minor,
162 | micro=spec.micro,
163 | releaselevel="",
164 | serial=0,
165 | ),
166 | version=str(spec),
167 | is_64=spec.architecture == 64, # noqa: PLR2004
168 | platform=sys.platform,
169 | extra={"architecture": spec.architecture},
170 | free_threaded=spec.free_threaded,
171 | )
172 |
173 | return None # pragma: no cover
174 |
175 | @classmethod
176 | def python_spec_for_path(cls, path: Path) -> PythonSpec:
177 | """
178 | Get the spec for an absolute path to a Python executable.
179 |
180 | :param path: the path investigated
181 | :return: the found spec
182 | """
183 | return VirtualEnv.python_spec_for_path(path) # pragma: win32 no cover
184 |
185 | @property
186 | def uv(self) -> str:
187 | return find_uv_bin()
188 |
189 | @property
190 | def venv_dir(self) -> Path:
191 | return cast("Path", self.conf["env_dir"])
192 |
193 | @property
194 | def environment_variables(self) -> dict[str, str]:
195 | env = super().environment_variables
196 | env.pop("UV_PYTHON", None) # UV_PYTHON takes precedence over VIRTUAL_ENV
197 | env["VIRTUAL_ENV"] = str(self.venv_dir)
198 | if "UV_CONSTRAINT" not in env and not self._displayed_uv_constraint_warning:
199 | for pip_var in ("PIP_CONSTRAINT", "PIP_CONSTRAINTS"):
200 | if pip_var in env:
201 | _LOGGER.warning(
202 | "Found %s defined, you may want to also define UV_CONSTRAINT to match pip behavior.", pip_var
203 | )
204 | self._displayed_uv_constraint_warning = True
205 | break
206 | return env
207 |
208 | def _default_pass_env(self) -> list[str]:
209 | env = super()._default_pass_env()
210 | env.append("UV_*") # accept uv env vars
211 | if sys.platform == "darwin": # pragma: darwin cover
212 | env.append("MACOSX_DEPLOYMENT_TARGET") # needed for macOS binary builds
213 | env.append("PKG_CONFIG_PATH") # needed for binary builds
214 | return env
215 |
216 | def create_python_env(self) -> None:
217 | version_spec = self.env_version_spec()
218 |
219 | cmd: list[str] = [self.uv, "venv", "-p", version_spec, "--allow-existing"]
220 | if self.options.verbosity > 3: # noqa: PLR2004
221 | cmd.append("-v")
222 | if self.conf["uv_seed"]:
223 | cmd.append("--seed")
224 | if self.conf["system_site_packages"]:
225 | cmd.append("--system-site-packages")
226 | if self.conf["uv_python_preference"] != "none":
227 | cmd.extend(["--python-preference", self.conf["uv_python_preference"]])
228 | cmd.append(str(self.venv_dir))
229 | outcome = self.execute(cmd, stdin=StdinSource.OFF, run_id="venv", show=None)
230 |
231 | if self.core["skip_missing_interpreters"] and outcome.exit_code in {1, 2}:
232 | msg = f"could not find python interpreter with spec(s): {version_spec}"
233 | raise Skip(msg)
234 |
235 | outcome.assert_success()
236 | self._created = True
237 |
238 | @property
239 | def _allow_externals(self) -> list[str]:
240 | result = super()._allow_externals
241 | result.append(self.uv)
242 | return result
243 |
244 | def prepend_env_var_path(self) -> list[Path]:
245 | return [self.env_bin_dir(), Path(self.uv).parent]
246 |
247 | def env_bin_dir(self) -> Path:
248 | if sys.platform == "win32": # pragma: win32 cover
249 | return self.venv_dir / "Scripts"
250 | else: # pragma: win32 no cover # noqa: RET505
251 | return self.venv_dir / "bin"
252 |
253 | def env_python(self) -> Path:
254 | suffix = ".exe" if sys.platform == "win32" else ""
255 | return self.env_bin_dir() / f"python{suffix}"
256 |
257 | def env_site_package_dir(self) -> Path: # pragma: win32 no cover
258 | if sys.platform == "win32": # pragma: win32 cover
259 | return self.venv_dir / "Lib" / "site-packages"
260 | py = self._py_info
261 | impl = "pypy" if py.implementation == "pypy" else "python"
262 | return self.venv_dir / "lib" / f"{impl}{py.version_dot}" / "site-packages"
263 |
264 | def env_version_spec(self) -> str:
265 | base = self.base_python.version_info
266 | imp = self.base_python.impl_lower
267 | executable = self.base_python.extra.get("executable")
268 | architecture = self.base_python.extra.get("architecture")
269 | free_threaded = self.base_python.free_threaded
270 | if executable: # pragma: win32 no cover
271 | version_spec = str(executable)
272 | elif (
273 | architecture is None
274 | and (base.major, base.minor) == sys.version_info[:2]
275 | and (sys.implementation.name.lower() == imp)
276 | and ((sysconfig.get_config_var("Py_GIL_DISABLED") == 1) == free_threaded)
277 | ):
278 | version_spec = sys.executable
279 | else:
280 | uv_imp = imp or ""
281 | free_threaded_tag = "+freethreaded" if free_threaded else ""
282 | if not base.major: # pragma: win32 no cover
283 | version_spec = f"{uv_imp}"
284 | elif not base.minor:
285 | version_spec = f"{uv_imp}{base.major}{free_threaded_tag}"
286 | elif architecture is not None and self.base_python.platform == "win32":
287 | uv_arch = {32: "x86", 64: "x86_64"}[architecture]
288 | version_spec = f"{uv_imp}-{base.major}.{base.minor}{free_threaded_tag}-windows-{uv_arch}-none"
289 | else:
290 | version_spec = f"{uv_imp}{base.major}.{base.minor}{free_threaded_tag}"
291 | return version_spec
292 |
293 | @cached_property
294 | def _py_info(self) -> PythonInfo: # pragma: win32 no cover
295 | if not self._created and not self.env_python().exists(): # called during config, no environment setup
296 | self.create_python_env()
297 | if not self._paths:
298 | self._paths = self.prepend_env_var_path()
299 | with as_file(files("tox_uv") / "_venv_query.py") as filename:
300 | cmd = [str(self.env_python()), str(filename)]
301 | outcome = self.execute(cmd, stdin=StdinSource.OFF, run_id="venv-query", show=False)
302 | outcome.assert_success()
303 | res = json.loads(outcome.out)
304 | return PythonInfo(
305 | implementation=res["implementation"],
306 | version_info=VersionInfo(
307 | major=res["version_info"][0],
308 | minor=res["version_info"][1],
309 | micro=res["version_info"][2],
310 | releaselevel=res["version_info"][3],
311 | serial=res["version_info"][4],
312 | ),
313 | version=res["version"],
314 | is_64=res["is_64"],
315 | platform=sys.platform,
316 | extra={},
317 | )
318 |
319 |
320 | __all__ = [
321 | "UvVenv",
322 | ]
323 |
--------------------------------------------------------------------------------
/tests/test_tox_uv_lock.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import sys
4 | from typing import TYPE_CHECKING
5 |
6 | import pytest
7 | from uv import find_uv_bin
8 |
9 | if TYPE_CHECKING:
10 | from tox.pytest import ToxProjectCreator
11 |
12 |
13 | @pytest.mark.usefixtures("clear_python_preference_env_var")
14 | def test_uv_lock_list_dependencies_command(tox_project: ToxProjectCreator) -> None:
15 | project = tox_project({
16 | "tox.ini": """
17 | [testenv]
18 | runner = uv-venv-lock-runner
19 | extras =
20 | type
21 | dev
22 | commands = python hello
23 | """
24 | })
25 | execute_calls = project.patch_execute(lambda r: 0 if r.run_id != "venv" else None)
26 | result = project.run("--list-dependencies", "-vv")
27 | result.assert_success()
28 |
29 | calls = [(i[0][0].conf.name, i[0][3].run_id, i[0][3].cmd) for i in execute_calls.call_args_list]
30 | uv = find_uv_bin()
31 | expected = [
32 | (
33 | "py",
34 | "venv",
35 | [
36 | uv,
37 | "venv",
38 | "-p",
39 | sys.executable,
40 | "--allow-existing",
41 | "-v",
42 | "--python-preference",
43 | "system",
44 | str(project.path / ".tox" / "py"),
45 | ],
46 | ),
47 | (
48 | "py",
49 | "uv-sync",
50 | [
51 | "uv",
52 | "sync",
53 | "--locked",
54 | "--python-preference",
55 | "system",
56 | "--extra",
57 | "dev",
58 | "--extra",
59 | "type",
60 | "-v",
61 | "-p",
62 | sys.executable,
63 | ],
64 | ),
65 | ("py", "freeze", [uv, "--color", "never", "pip", "freeze"]),
66 | ("py", "commands[0]", ["python", "hello"]),
67 | ]
68 | assert len(calls) == len(expected)
69 | for i in range(len(calls)):
70 | assert calls[i] == expected[i]
71 |
72 |
73 | @pytest.mark.usefixtures("clear_python_preference_env_var")
74 | @pytest.mark.parametrize("verbose", ["", "-v", "-vv", "-vvv"])
75 | def test_uv_lock_command(tox_project: ToxProjectCreator, verbose: str) -> None:
76 | project = tox_project({
77 | "tox.ini": """
78 | [testenv]
79 | runner = uv-venv-lock-runner
80 | extras =
81 | type
82 | dev
83 | commands = python hello
84 | """
85 | })
86 | execute_calls = project.patch_execute(lambda r: 0 if r.run_id != "venv" else None)
87 | result = project.run(*[verbose] if verbose else [])
88 | result.assert_success()
89 |
90 | calls = [(i[0][0].conf.name, i[0][3].run_id, i[0][3].cmd) for i in execute_calls.call_args_list]
91 | uv = find_uv_bin()
92 | v_args = ["-v"] if verbose not in {"", "-v"} else []
93 | expected = [
94 | (
95 | "py",
96 | "venv",
97 | [
98 | uv,
99 | "venv",
100 | "-p",
101 | sys.executable,
102 | "--allow-existing",
103 | *v_args,
104 | "--python-preference",
105 | "system",
106 | str(project.path / ".tox" / "py"),
107 | ],
108 | ),
109 | (
110 | "py",
111 | "uv-sync",
112 | [
113 | "uv",
114 | "sync",
115 | "--locked",
116 | "--python-preference",
117 | "system",
118 | "--extra",
119 | "dev",
120 | "--extra",
121 | "type",
122 | *v_args,
123 | "-p",
124 | sys.executable,
125 | ],
126 | ),
127 | ("py", "commands[0]", ["python", "hello"]),
128 | ]
129 | assert calls == expected
130 | show_uv_output = execute_calls.call_args_list[1].args[4]
131 | assert show_uv_output is (bool(verbose))
132 |
133 |
134 | @pytest.mark.usefixtures("clear_python_preference_env_var")
135 | def test_uv_lock_with_default_groups(tox_project: ToxProjectCreator) -> None:
136 | project = tox_project({
137 | "tox.ini": """
138 | [testenv]
139 | runner = uv-venv-lock-runner
140 | no_default_groups = False
141 | """
142 | })
143 | execute_calls = project.patch_execute(lambda r: 0 if r.run_id != "venv" else None)
144 | result = project.run("-vv")
145 | result.assert_success()
146 |
147 | calls = [(i[0][0].conf.name, i[0][3].run_id, i[0][3].cmd) for i in execute_calls.call_args_list]
148 | uv = find_uv_bin()
149 | expected = [
150 | (
151 | "py",
152 | "venv",
153 | [
154 | uv,
155 | "venv",
156 | "-p",
157 | sys.executable,
158 | "--allow-existing",
159 | "-v",
160 | "--python-preference",
161 | "system",
162 | str(project.path / ".tox" / "py"),
163 | ],
164 | ),
165 | ("py", "uv-sync", ["uv", "sync", "--locked", "--python-preference", "system", "-v", "-p", sys.executable]),
166 | ]
167 | assert calls == expected
168 |
169 |
170 | @pytest.mark.usefixtures("clear_python_preference_env_var")
171 | @pytest.mark.parametrize(
172 | "name",
173 | [
174 | "tox_uv-1.12.2-py3-none-any.whl",
175 | "tox_uv-1.12.2.tar.gz",
176 | ],
177 | )
178 | def test_uv_lock_with_install_pkg(tox_project: ToxProjectCreator, name: str) -> None:
179 | project = tox_project({
180 | "tox.ini": """
181 | [testenv]
182 | runner = uv-venv-lock-runner
183 | """
184 | })
185 | execute_calls = project.patch_execute(lambda r: 0 if r.run_id != "venv" else None)
186 | wheel = project.path / name
187 | wheel.write_text("")
188 | result = project.run("-vv", "run", "--installpkg", str(wheel))
189 | result.assert_success()
190 |
191 | calls = [(i[0][0].conf.name, i[0][3].run_id, i[0][3].cmd) for i in execute_calls.call_args_list]
192 | uv = find_uv_bin()
193 | expected = [
194 | (
195 | "py",
196 | "venv",
197 | [
198 | uv,
199 | "venv",
200 | "-p",
201 | sys.executable,
202 | "--allow-existing",
203 | "-v",
204 | "--python-preference",
205 | "system",
206 | str(project.path / ".tox" / "py"),
207 | ],
208 | ),
209 | (
210 | "py",
211 | "uv-sync",
212 | [
213 | "uv",
214 | "sync",
215 | "--locked",
216 | "--python-preference",
217 | "system",
218 | "--no-install-project",
219 | "-v",
220 | "-p",
221 | sys.executable,
222 | ],
223 | ),
224 | (
225 | "py",
226 | "install_external",
227 | [uv, "pip", "install", "--reinstall", "--no-deps", f"tox-uv@{wheel}", "-v"],
228 | ),
229 | ]
230 | assert calls == expected
231 |
232 |
233 | @pytest.mark.usefixtures("clear_python_preference_env_var")
234 | @pytest.mark.parametrize("uv_sync_locked", [True, False])
235 | def test_uv_sync_extra_flags(tox_project: ToxProjectCreator, uv_sync_locked: bool) -> None:
236 | uv_sync_locked_str = str(uv_sync_locked).lower()
237 | project = tox_project({
238 | "tox.ini": f"""
239 | [testenv]
240 | runner = uv-venv-lock-runner
241 | no_default_groups = false
242 | uv_sync_flags = --no-editable, --inexact
243 | uv_sync_locked = {uv_sync_locked_str}
244 | commands = python hello
245 | """
246 | })
247 | execute_calls = project.patch_execute(lambda r: 0 if r.run_id != "venv" else None)
248 | result = project.run()
249 | result.assert_success()
250 |
251 | calls = [(i[0][0].conf.name, i[0][3].run_id, i[0][3].cmd) for i in execute_calls.call_args_list]
252 | uv = find_uv_bin()
253 |
254 | expected = [
255 | (
256 | "py",
257 | "venv",
258 | [
259 | uv,
260 | "venv",
261 | "-p",
262 | sys.executable,
263 | "--allow-existing",
264 | "--python-preference",
265 | "system",
266 | str(project.path / ".tox" / "py"),
267 | ],
268 | ),
269 | (
270 | "py",
271 | "uv-sync",
272 | [
273 | "uv",
274 | "sync",
275 | *(["--locked"] if uv_sync_locked else []),
276 | "--python-preference",
277 | "system",
278 | "--no-editable",
279 | "--inexact",
280 | "-p",
281 | sys.executable,
282 | ],
283 | ),
284 | ("py", "commands[0]", ["python", "hello"]),
285 | ]
286 | assert calls == expected
287 |
288 |
289 | @pytest.mark.usefixtures("clear_python_preference_env_var")
290 | def test_uv_sync_extra_flags_toml(tox_project: ToxProjectCreator) -> None:
291 | project = tox_project({
292 | "tox.toml": """
293 | [env_run_base]
294 | runner = "uv-venv-lock-runner"
295 | no_default_groups = false
296 | uv_sync_flags = ["--no-editable", "--inexact"]
297 | commands = [["python", "hello"]]
298 | """
299 | })
300 | execute_calls = project.patch_execute(lambda r: 0 if r.run_id != "venv" else None)
301 | result = project.run()
302 | result.assert_success()
303 |
304 | calls = [(i[0][0].conf.name, i[0][3].run_id, i[0][3].cmd) for i in execute_calls.call_args_list]
305 | uv = find_uv_bin()
306 |
307 | expected = [
308 | (
309 | "py",
310 | "venv",
311 | [
312 | uv,
313 | "venv",
314 | "-p",
315 | sys.executable,
316 | "--allow-existing",
317 | "--python-preference",
318 | "system",
319 | str(project.path / ".tox" / "py"),
320 | ],
321 | ),
322 | (
323 | "py",
324 | "uv-sync",
325 | [
326 | "uv",
327 | "sync",
328 | "--locked",
329 | "--python-preference",
330 | "system",
331 | "--no-editable",
332 | "--inexact",
333 | "-p",
334 | sys.executable,
335 | ],
336 | ),
337 | ("py", "commands[0]", ["python", "hello"]),
338 | ]
339 | assert calls == expected
340 |
341 |
342 | @pytest.mark.usefixtures("clear_python_preference_env_var")
343 | def test_uv_sync_dependency_groups(tox_project: ToxProjectCreator) -> None:
344 | project = tox_project({
345 | "tox.toml": """
346 | [env_run_base]
347 | runner = "uv-venv-lock-runner"
348 | with_dev = true
349 | dependency_groups = ["test", "type"]
350 | commands = [["python", "hello"]]
351 | """
352 | })
353 | execute_calls = project.patch_execute(lambda r: 0 if r.run_id != "venv" else None)
354 | result = project.run()
355 | result.assert_success()
356 |
357 | calls = [(i[0][0].conf.name, i[0][3].run_id, i[0][3].cmd) for i in execute_calls.call_args_list]
358 | uv = find_uv_bin()
359 |
360 | expected = [
361 | (
362 | "py",
363 | "venv",
364 | [
365 | uv,
366 | "venv",
367 | "-p",
368 | sys.executable,
369 | "--allow-existing",
370 | "--python-preference",
371 | "system",
372 | str(project.path / ".tox" / "py"),
373 | ],
374 | ),
375 | (
376 | "py",
377 | "uv-sync",
378 | [
379 | "uv",
380 | "sync",
381 | "--locked",
382 | "--python-preference",
383 | "system",
384 | "--no-default-groups",
385 | "--group",
386 | "test",
387 | "--group",
388 | "type",
389 | "-p",
390 | sys.executable,
391 | ],
392 | ),
393 | ("py", "commands[0]", ["python", "hello"]),
394 | ]
395 | assert calls == expected
396 |
397 |
398 | @pytest.mark.parametrize(
399 | ("uv_python_preference", "injected"),
400 | [
401 | pytest.param("none", [], id="on"),
402 | pytest.param("system", ["--python-preference", "system"], id="off"),
403 | ],
404 | )
405 | def test_uv_sync_uv_python_preference(
406 | tox_project: ToxProjectCreator, uv_python_preference: str, injected: list[str]
407 | ) -> None:
408 | project = tox_project({
409 | "tox.toml": f"""
410 | [env_run_base]
411 | runner = "uv-venv-lock-runner"
412 | with_dev = true
413 | dependency_groups = ["test", "type"]
414 | commands = [["python", "hello"]]
415 | uv_python_preference = "{uv_python_preference}"
416 | """
417 | })
418 | execute_calls = project.patch_execute(lambda r: 0 if r.run_id != "venv" else None)
419 | result = project.run()
420 | result.assert_success()
421 |
422 | calls = [(i[0][0].conf.name, i[0][3].run_id, i[0][3].cmd) for i in execute_calls.call_args_list]
423 | uv = find_uv_bin()
424 |
425 | expected = [
426 | (
427 | "py",
428 | "venv",
429 | [
430 | uv,
431 | "venv",
432 | "-p",
433 | sys.executable,
434 | "--allow-existing",
435 | *injected,
436 | str(project.path / ".tox" / "py"),
437 | ],
438 | ),
439 | (
440 | "py",
441 | "uv-sync",
442 | [
443 | "uv",
444 | "sync",
445 | "--locked",
446 | *injected,
447 | "--no-default-groups",
448 | "--group",
449 | "test",
450 | "--group",
451 | "type",
452 | "-p",
453 | sys.executable,
454 | ],
455 | ),
456 | ("py", "commands[0]", ["python", "hello"]),
457 | ]
458 | assert calls == expected
459 |
460 |
461 | def test_skip_uv_sync(tox_project: ToxProjectCreator, monkeypatch: pytest.MonkeyPatch) -> None:
462 | monkeypatch.delenv("UV_PYTHON_PREFERENCE", raising=False)
463 | project = tox_project({
464 | "tox.toml": """
465 | [env_run_base]
466 | runner = "uv-venv-lock-runner"
467 | commands = [["python", "hello"]]
468 | """
469 | })
470 | execute_calls = project.patch_execute(lambda r: 0 if r.run_id != "venv" else None)
471 | result = project.run("run", "--skip-uv-sync")
472 | result.assert_success()
473 |
474 | calls = [(i[0][0].conf.name, i[0][3].run_id, i[0][3].cmd) for i in execute_calls.call_args_list]
475 | uv = find_uv_bin()
476 |
477 | expected = [
478 | (
479 | "py",
480 | "venv",
481 | [
482 | uv,
483 | "venv",
484 | "-p",
485 | sys.executable,
486 | "--allow-existing",
487 | "--python-preference",
488 | "system",
489 | str(project.path / ".tox" / "py"),
490 | ],
491 | ),
492 | ("py", "commands[0]", ["python", "hello"]),
493 | ]
494 | assert calls == expected
495 |
496 |
497 | def test_uv_package_wheel(tox_project: ToxProjectCreator, monkeypatch: pytest.MonkeyPatch) -> None:
498 | monkeypatch.delenv("UV_PYTHON_PREFERENCE", raising=False)
499 | project = tox_project({
500 | "tox.toml": """
501 | [env_run_base]
502 | runner = "uv-venv-lock-runner"
503 | package = "wheel"
504 | """,
505 | "pyproject.toml": """
506 | [project]
507 | name = "demo"
508 | """,
509 | })
510 | execute_calls = project.patch_execute(lambda r: 0 if r.run_id != "venv" else None)
511 | result = project.run("run", "--notest")
512 | result.assert_success()
513 |
514 | calls = [(i[0][0].conf.name, i[0][3].run_id, i[0][3].cmd) for i in execute_calls.call_args_list]
515 | uv = find_uv_bin()
516 |
517 | expected = [
518 | (
519 | "py",
520 | "venv",
521 | [
522 | uv,
523 | "venv",
524 | "-p",
525 | sys.executable,
526 | "--allow-existing",
527 | "--python-preference",
528 | "system",
529 | str(project.path / ".tox" / "py"),
530 | ],
531 | ),
532 | (
533 | "py",
534 | "uv-sync",
535 | [
536 | "uv",
537 | "sync",
538 | "--locked",
539 | "--python-preference",
540 | "system",
541 | "--no-editable",
542 | "--reinstall-package",
543 | "demo",
544 | "-p",
545 | sys.executable,
546 | ],
547 | ),
548 | ]
549 | assert calls == expected
550 |
551 |
552 | def test_uv_package_wheel_no_pyproject(tox_project: ToxProjectCreator, monkeypatch: pytest.MonkeyPatch) -> None:
553 | monkeypatch.delenv("UV_PYTHON_PREFERENCE", raising=False)
554 | project = tox_project({
555 | "tox.toml": """
556 | [env_run_base]
557 | runner = "uv-venv-lock-runner"
558 | package = "wheel"
559 | """,
560 | })
561 | project.patch_execute(lambda r: 0 if r.run_id != "venv" else None)
562 |
563 | result = project.run("run", "--notest")
564 |
565 | result.assert_failed()
566 |
567 |
568 | def test_skip_uv_package_skip(tox_project: ToxProjectCreator, monkeypatch: pytest.MonkeyPatch) -> None:
569 | monkeypatch.delenv("UV_PYTHON_PREFERENCE", raising=False)
570 | project = tox_project({
571 | "tox.toml": """
572 | [env_run_base]
573 | runner = "uv-venv-lock-runner"
574 | package = "skip"
575 | """
576 | })
577 | execute_calls = project.patch_execute(lambda r: 0 if r.run_id != "venv" else None)
578 | result = project.run("run", "--notest")
579 | result.assert_success()
580 |
581 | calls = [(i[0][0].conf.name, i[0][3].run_id, i[0][3].cmd) for i in execute_calls.call_args_list]
582 | uv = find_uv_bin()
583 |
584 | expected = [
585 | (
586 | "py",
587 | "venv",
588 | [
589 | uv,
590 | "venv",
591 | "-p",
592 | sys.executable,
593 | "--allow-existing",
594 | "--python-preference",
595 | "system",
596 | str(project.path / ".tox" / "py"),
597 | ],
598 | ),
599 | (
600 | "py",
601 | "uv-sync",
602 | [
603 | "uv",
604 | "sync",
605 | "--locked",
606 | "--python-preference",
607 | "system",
608 | "--no-install-project",
609 | "-p",
610 | sys.executable,
611 | ],
612 | ),
613 | ]
614 | assert calls == expected
615 |
616 |
617 | @pytest.mark.usefixtures("clear_python_preference_env_var")
618 | def test_uv_lock_ith_resolution(tox_project: ToxProjectCreator) -> None:
619 | project = tox_project({
620 | "tox.ini": """
621 | [testenv]
622 | runner = uv-venv-lock-runner
623 | uv_resolution = highest
624 | """
625 | })
626 | execute_calls = project.patch_execute(lambda r: 0 if r.run_id != "venv" else None)
627 | result = project.run("run", "--notest")
628 | result.assert_success()
629 |
630 | calls = [(i[0][0].conf.name, i[0][3].run_id, i[0][3].cmd) for i in execute_calls.call_args_list]
631 | uv = find_uv_bin()
632 | expected = [
633 | (
634 | "py",
635 | "venv",
636 | [
637 | uv,
638 | "venv",
639 | "-p",
640 | sys.executable,
641 | "--allow-existing",
642 | "--python-preference",
643 | "system",
644 | str(project.path / ".tox" / "py"),
645 | ],
646 | ),
647 | (
648 | "py",
649 | "uv-sync",
650 | [
651 | "uv",
652 | "sync",
653 | "--locked",
654 | "--python-preference",
655 | "system",
656 | "--resolution",
657 | "highest",
658 | "-p",
659 | sys.executable,
660 | ],
661 | ),
662 | ]
663 | assert len(calls) == len(expected)
664 | for i in range(len(calls)):
665 | assert calls[i] == expected[i]
666 |
--------------------------------------------------------------------------------
/tests/test_tox_uv_venv.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import importlib.util
4 | import os
5 | import os.path
6 | import pathlib
7 | import platform
8 | import subprocess
9 | import sys
10 | from configparser import ConfigParser
11 | from importlib.metadata import version
12 | from typing import TYPE_CHECKING, get_args
13 | from unittest import mock
14 |
15 | import pytest
16 | import tox.tox_env.errors
17 | from tox.tox_env.python.api import PythonInfo, VersionInfo
18 |
19 | from tox_uv._venv import PythonPreference, UvVenv
20 |
21 | if TYPE_CHECKING:
22 | from tox.pytest import ToxProjectCreator
23 |
24 |
25 | def test_uv_venv_self(tox_project: ToxProjectCreator) -> None:
26 | project = tox_project({"tox.ini": "[testenv]\npackage=skip"})
27 | result = project.run("-vv")
28 | result.assert_success()
29 |
30 |
31 | def test_uv_venv_pass_env(tox_project: ToxProjectCreator) -> None:
32 | project = tox_project({"tox.ini": "[testenv]\npackage=skip"})
33 | result = project.run("c", "-k", "pass_env")
34 | result.assert_success()
35 |
36 | parser = ConfigParser()
37 | parser.read_string(result.out)
38 | pass_through = set(parser["testenv:py"]["pass_env"].splitlines())
39 |
40 | if sys.platform == "darwin": # pragma: darwin cover
41 | assert "MACOSX_DEPLOYMENT_TARGET" in pass_through
42 | assert "UV_*" in pass_through
43 | assert "PKG_CONFIG_PATH" in pass_through
44 |
45 |
46 | @pytest.mark.usefixtures("clear_python_preference_env_var")
47 | def test_uv_venv_preference_system_by_default(tox_project: ToxProjectCreator) -> None:
48 | project = tox_project({"tox.ini": "[testenv]"})
49 |
50 | result = project.run("c", "-k", "uv_python_preference")
51 | result.assert_success()
52 |
53 | parser = ConfigParser()
54 | parser.read_string(result.out)
55 | got = parser["testenv:py"]["uv_python_preference"]
56 |
57 | assert got == "system"
58 |
59 |
60 | @pytest.mark.usefixtures("clear_python_preference_env_var")
61 | @pytest.mark.parametrize("env_var", ["UV_NO_MANAGED_PYTHON", "UV_MANAGED_PYTHON"])
62 | def test_uv_venv_preference_not_set_if_uv_no_managed_python(
63 | tox_project: ToxProjectCreator, monkeypatch: pytest.MonkeyPatch, env_var: str
64 | ) -> None:
65 | # --(no-)managed-python cannot be used together with --python-preference
66 | project = tox_project({"tox.ini": "[testenv]"})
67 | monkeypatch.setenv(env_var, "True")
68 |
69 | result = project.run("c", "-k", "uv_python_preference")
70 | result.assert_success()
71 |
72 | parser = ConfigParser()
73 | parser.read_string(result.out)
74 | got = parser["testenv:py"]
75 |
76 | assert got.get("uv_python_preference") == "none"
77 |
78 |
79 | def test_uv_venv_preference_override_via_env_var(
80 | tox_project: ToxProjectCreator, monkeypatch: pytest.MonkeyPatch
81 | ) -> None:
82 | project = tox_project({"tox.ini": "[testenv]"})
83 | monkeypatch.setenv("UV_PYTHON_PREFERENCE", "only-managed")
84 |
85 | result = project.run("c", "-k", "uv_python_preference")
86 | result.assert_success()
87 |
88 | parser = ConfigParser()
89 | parser.read_string(result.out)
90 | got = parser["testenv:py"]["uv_python_preference"]
91 |
92 | assert got == "only-managed"
93 |
94 |
95 | def test_uv_venv_preference_override_via_env_var_and_set_env_depends_on_py(
96 | tox_project: ToxProjectCreator, monkeypatch: pytest.MonkeyPatch
97 | ) -> None:
98 | project = tox_project({"tox.ini": "[testenv]\nset_env=A={env_site_packages_dir}"})
99 | monkeypatch.setenv("UV_PYTHON_PREFERENCE", "only-managed")
100 |
101 | result = project.run("c", "-k", "set_env")
102 | result.assert_success()
103 |
104 | assert str(project.path) in result.out
105 |
106 |
107 | def test_uv_venv_spec(tox_project: ToxProjectCreator) -> None:
108 | ver = sys.version_info
109 | project = tox_project({"tox.ini": f"[testenv]\npackage=skip\nbase_python={ver.major}.{ver.minor}"})
110 | result = project.run("-vv")
111 | result.assert_success()
112 |
113 |
114 | def test_uv_venv_spec_major_only(tox_project: ToxProjectCreator) -> None:
115 | ver = sys.version_info
116 | project = tox_project({"tox.ini": f"[testenv]\npackage=skip\nbase_python={ver.major}"})
117 | result = project.run("-vv")
118 | result.assert_success()
119 |
120 |
121 | @pytest.mark.xfail(
122 | sys.platform == "win32",
123 | reason="Bug https://github.com/tox-dev/tox-uv/issues/193 https://github.com/astral-sh/uv/issues/14239",
124 | )
125 | @pytest.mark.parametrize(
126 | ("pypy", "expected_uv_pypy"),
127 | [
128 | ("pypy", "pypy"),
129 | ("pypy9", "pypy9"),
130 | ("pypy999", "pypy9.99"),
131 | ("pypy9.99", "pypy9.99"),
132 | ],
133 | )
134 | def test_uv_venv_spec_pypy(
135 | capfd: pytest.CaptureFixture[str],
136 | tox_project: ToxProjectCreator,
137 | pypy: str,
138 | expected_uv_pypy: str,
139 | ) -> None:
140 | """Validate that major and minor versions are correctly applied to implementations.
141 |
142 | This test prevents a regression that occurred when the testenv name was "pypy":
143 | the uv runner was asked to use "pypyNone" as the Python version.
144 |
145 | The test is dependent on what PyPy interpreters are installed on the system;
146 | if any PyPy is available then the "pypy" value will not raise a Skip exception,
147 | and STDOUT will be captured in `result.out`.
148 |
149 | However, it is expected that no system will have PyPy v9.x installed,
150 | so STDOUT must be read from `capfd` after the Skip exception is caught.
151 |
152 | Since it is unknown whether any PyPy interpreter will be installed,
153 | the `else` block's branch coverage is disabled.
154 | """
155 |
156 | project = tox_project({"tox.ini": f"[tox]\nenv_list = {pypy}"})
157 | try:
158 | result = project.run("config", "-vv")
159 | except tox.tox_env.errors.Skip: # pragma: win32 no cover
160 | stdout, _ = capfd.readouterr()
161 | else: # pragma: no cover (PyPy might not be available on the system)
162 | stdout = result.out
163 | assert "pypyNone" not in stdout
164 | assert f"-p {expected_uv_pypy} " in stdout
165 |
166 |
167 | @pytest.mark.parametrize(
168 | ("implementation", "expected_implementation", "expected_name"),
169 | [
170 | ("", "cpython", "cpython"),
171 | ("py", "cpython", "cpython"),
172 | ("pypy", "pypy", "pypy"),
173 | ],
174 | )
175 | def test_uv_venv_spec_full_implementation(
176 | tox_project: ToxProjectCreator,
177 | implementation: str,
178 | expected_implementation: str,
179 | expected_name: str,
180 | ) -> None:
181 | """Validate that Python implementations are explicitly passed to uv's `-p` argument.
182 |
183 | This test ensures that uv searches for the target Python implementation and version,
184 | even if another implementation -- with the same language version --
185 | is found on the path first.
186 |
187 | This prevents a regression to a bug that occurred when PyPy 3.10 was on the PATH
188 | and tox was invoked with `tox -e py3.10`:
189 | uv was invoked with `-p 3.10` and found PyPy 3.10, not CPython 3.10.
190 | """
191 |
192 | project = tox_project({})
193 | result = project.run("run", "-vve", f"{implementation}9.99")
194 |
195 | # Verify that uv was invoked with the full Python implementation and version.
196 | assert f" -p {expected_implementation}9.99 " in result.out
197 |
198 | # Verify that uv interpreted the `-p` argument as a Python spec, not an executable.
199 | # This confirms that tox-uv is passing recognizable, usable `-p` arguments to uv.
200 | assert f"no interpreter found for {expected_name} 9.99" in result.err.lower()
201 |
202 |
203 | def test_uv_venv_system_site_packages(tox_project: ToxProjectCreator) -> None:
204 | project = tox_project({"tox.ini": "[testenv]\npackage=skip\nsystem_site_packages=true"})
205 | result = project.run("-vv")
206 | result.assert_success()
207 |
208 |
209 | @pytest.fixture
210 | def other_interpreter_exe() -> pathlib.Path: # pragma: no cover
211 | """Returns an interpreter executable path that is not the exact same as `sys.executable`.
212 |
213 | Necessary because `sys.executable` gets short-circuited when used as `base_python`."""
214 |
215 | exe = pathlib.Path(sys.executable)
216 | base_python: pathlib.Path | None = None
217 | if exe.name in {"python", "python3"}:
218 | # python -> pythonX.Y
219 | ver = sys.version_info
220 | base_python = exe.with_name(f"python{ver.major}.{ver.minor}")
221 | elif exe.name[-1].isdigit():
222 | # python X.Y -> python
223 | base_python = exe.with_name(exe.stem[:-1])
224 | elif exe.suffix == ".exe":
225 | # python.exe <-> pythonw.exe
226 | base_python = (
227 | exe.with_name(exe.stem[:-1] + ".exe") if exe.stem.endswith("w") else exe.with_name(exe.stem + "w.exe")
228 | )
229 | if not base_python or not base_python.is_file():
230 | pytest.fail("Tried to pick a base_python that is not sys.executable, but failed.")
231 | return base_python
232 |
233 |
234 | @pytest.mark.xfail(
235 | sys.platform == "win32",
236 | reason="Bug https://github.com/tox-dev/tox-uv/issues/193 https://github.com/astral-sh/uv/issues/14239",
237 | )
238 | def test_uv_venv_spec_abs_path(
239 | tox_project: ToxProjectCreator, other_interpreter_exe: pathlib.Path
240 | ) -> None: # pragma: win32 no cover
241 | project = tox_project({"tox.ini": f"[testenv]\npackage=skip\nbase_python={other_interpreter_exe}"})
242 | result = project.run("-vv")
243 | result.assert_success()
244 |
245 |
246 | @pytest.mark.xfail(
247 | sys.platform == "win32",
248 | reason="Bug https://github.com/tox-dev/tox-uv/issues/193 https://github.com/astral-sh/uv/issues/14239",
249 | )
250 | def test_uv_venv_spec_abs_path_conflict_ver(
251 | tox_project: ToxProjectCreator, other_interpreter_exe: pathlib.Path
252 | ) -> None: # pragma: win32 no cover
253 | # py27 is long gone, but still matches the testenv capture regex, so we know it will fail
254 | project = tox_project({"tox.ini": f"[testenv:py27]\npackage=skip\nbase_python={other_interpreter_exe}"})
255 | result = project.run("-vv", "-e", "py27")
256 | result.assert_failed()
257 | assert f"failed with env name py27 conflicting with base python {other_interpreter_exe}" in result.out
258 |
259 |
260 | @pytest.mark.xfail(
261 | sys.platform == "win32",
262 | reason="Bug https://github.com/tox-dev/tox-uv/issues/193 https://github.com/astral-sh/uv/issues/14239",
263 | )
264 | def test_uv_venv_spec_abs_path_conflict_impl(
265 | tox_project: ToxProjectCreator, other_interpreter_exe: pathlib.Path
266 | ) -> None: # pragma: win32 no cover
267 | env = "pypy" if platform.python_implementation() == "CPython" else "cpython"
268 | project = tox_project({"tox.ini": f"[testenv:{env}]\npackage=skip\nbase_python={other_interpreter_exe}"})
269 | result = project.run("-vv", "-e", env)
270 | result.assert_failed()
271 | assert f"failed with env name {env} conflicting with base python {other_interpreter_exe}" in result.out
272 |
273 |
274 | def test_uv_venv_na(tox_project: ToxProjectCreator) -> None:
275 | # skip_missing_interpreters is true by default
276 | project = tox_project({"tox.ini": "[testenv]\npackage=skip\nbase_python=1.0"})
277 | result = project.run("-vv")
278 |
279 | # When a Python interpreter is missing in a pytest environment, project.run
280 | # return code is equal to -1
281 | result.assert_failed(code=-1)
282 |
283 |
284 | def test_uv_venv_na_uv_072(tox_project: ToxProjectCreator) -> None:
285 | # Test uv==0.7.2
286 | # skip_missing_interpreters is true by default
287 | project = tox_project({"tox.ini": "[testenv]\npackage=skip\nbase_python=1.0\nrequires=uv==0.7.2"})
288 | result = project.run("-vv")
289 |
290 | # When a Python interpreter is missing in a pytest environment, project.run
291 | # return code is equal to -1
292 | result.assert_failed(code=-1)
293 |
294 |
295 | def test_uv_venv_skip_missing_interpreters_fail(tox_project: ToxProjectCreator) -> None:
296 | project = tox_project({
297 | "tox.ini": "[tox]\nskip_missing_interpreters=false\n[testenv]\npackage=skip\nbase_python=1.0"
298 | })
299 | result = project.run("-vv")
300 | result.assert_failed(code=2)
301 |
302 |
303 | def test_uv_venv_skip_missing_interpreters_pass(tox_project: ToxProjectCreator) -> None:
304 | project = tox_project({
305 | "tox.ini": "[tox]\nskip_missing_interpreters=true\n[testenv]\npackage=skip\nbase_python=1.0"
306 | })
307 | result = project.run("-vv")
308 | # When a Python interpreter is missing in a pytest environment, project.run
309 | # return code is equal to -1
310 | result.assert_failed(code=-1)
311 |
312 |
313 | def test_uv_venv_platform_check(tox_project: ToxProjectCreator) -> None:
314 | project = tox_project({"tox.ini": f"[testenv]\nplatform={sys.platform}\npackage=skip"})
315 | result = project.run("-vv")
316 | result.assert_success()
317 |
318 |
319 | def test_uv_env_bin_dir(tox_project: ToxProjectCreator) -> None:
320 | project = tox_project({"tox.ini": "[testenv]\npackage=skip\ncommands=python -c 'print(\"{env_bin_dir}\")'"})
321 | result = project.run("-vv")
322 | result.assert_success()
323 |
324 | env_bin_dir = str(project.path / ".tox" / "py" / ("Scripts" if sys.platform == "win32" else "bin"))
325 | assert env_bin_dir in result.out
326 |
327 |
328 | def test_uv_env_has_access_to_plugin_uv(tox_project: ToxProjectCreator) -> None:
329 | project = tox_project({"tox.ini": "[testenv]\npackage=skip\ncommands=uv --version"})
330 | result = project.run()
331 |
332 | result.assert_success()
333 | ver = version("uv")
334 | assert f"uv {ver}" in result.out
335 |
336 |
337 | def test_uv_env_python(tox_project: ToxProjectCreator) -> None:
338 | project = tox_project({"tox.ini": "[testenv]\npackage=skip\ncommands=python -c 'print(\"{env_python}\")'"})
339 | result = project.run("-vv")
340 | result.assert_success()
341 |
342 | exe = "python.exe" if sys.platform == "win32" else "python"
343 | env_bin_dir = str(project.path / ".tox" / "py" / ("Scripts" if sys.platform == "win32" else "bin") / exe)
344 | assert env_bin_dir in result.out
345 |
346 |
347 | @pytest.mark.parametrize(
348 | "preference",
349 | get_args(PythonPreference),
350 | )
351 | def test_uv_env_python_preference(
352 | tox_project: ToxProjectCreator,
353 | *,
354 | preference: str,
355 | ) -> None:
356 | project = tox_project({
357 | "tox.ini": (
358 | "[testenv]\n"
359 | "package=skip\n"
360 | f"uv_python_preference={preference}\n"
361 | "commands=python -c 'print(\"{env_python}\")'"
362 | )
363 | })
364 | result = project.run("-vv")
365 | result.assert_success()
366 |
367 | exe = "python.exe" if sys.platform == "win32" else "python"
368 | env_bin_dir = str(project.path / ".tox" / "py" / ("Scripts" if sys.platform == "win32" else "bin") / exe)
369 | assert env_bin_dir in result.out
370 |
371 |
372 | @pytest.mark.parametrize(
373 | "env",
374 | ["3.10", "3.10-onlymanaged"],
375 | )
376 | def test_uv_env_python_preference_complex(
377 | tox_project: ToxProjectCreator,
378 | *,
379 | env: str,
380 | ) -> None:
381 | project = tox_project({
382 | "tox.ini": (
383 | "[tox]\n"
384 | "env_list =\n"
385 | " 3.10\n"
386 | "[testenv]\n"
387 | "package=skip\n"
388 | "uv_python_preference=\n"
389 | " onlymanaged: only-managed\n"
390 | "commands=python -c 'print(\"{env_python}\")'"
391 | )
392 | })
393 | result = project.run("-vv", "-e", env)
394 | result.assert_success()
395 |
396 | exe = "python.exe" if sys.platform == "win32" else "python"
397 | env_bin_dir = str(project.path / ".tox" / env / ("Scripts" if sys.platform == "win32" else "bin") / exe)
398 | assert env_bin_dir in result.out
399 |
400 |
401 | def test_uv_env_site_package_dir_run(tox_project: ToxProjectCreator) -> None:
402 | project = tox_project({"tox.ini": "[testenv]\npackage=skip\ncommands=python -c 'print(\"{envsitepackagesdir}\")'"})
403 | result = project.run("-vv")
404 | result.assert_success()
405 |
406 | env_dir = project.path / ".tox" / "py"
407 | ver = sys.version_info
408 | if sys.platform == "win32": # pragma: win32 cover
409 | path = str(env_dir / "Lib" / "site-packages")
410 | else: # pragma: win32 no cover
411 | impl = "pypy" if sys.implementation.name.lower() == "pypy" else "python"
412 | path = str(env_dir / "lib" / f"{impl}{ver.major}.{ver.minor}" / "site-packages")
413 | assert path in result.out
414 |
415 |
416 | def test_uv_env_site_package_dir_conf(tox_project: ToxProjectCreator) -> None:
417 | project = tox_project({"tox.ini": "[testenv]\npackage=skip\ncommands={envsitepackagesdir}"})
418 | result = project.run("c", "-e", "py", "-k", "commands")
419 | result.assert_success()
420 |
421 | env_dir = project.path / ".tox" / "py"
422 | ver = sys.version_info
423 | if sys.platform == "win32": # pragma: win32 cover
424 | path = str(env_dir / "Lib" / "site-packages")
425 | else: # pragma: win32 no cover
426 | impl = "pypy" if sys.implementation.name.lower() == "pypy" else "python"
427 | path = str(env_dir / "lib" / f"{impl}{ver.major}.{ver.minor}" / "site-packages")
428 | assert path in result.out
429 |
430 |
431 | def test_uv_env_python_not_in_path(tox_project: ToxProjectCreator) -> None:
432 | # Make sure there is no pythonX.Y in the search path
433 | ver = sys.version_info
434 | exe_ext = ".exe" if sys.platform == "win32" else ""
435 | python_exe = f"python{ver.major}.{ver.minor}{exe_ext}"
436 | env = dict(os.environ)
437 | env["PATH"] = os.path.pathsep.join(
438 | path for path in env["PATH"].split(os.path.pathsep) if not (pathlib.Path(path) / python_exe).is_file()
439 | )
440 |
441 | # Make sure the Python interpreter can find our Tox module
442 | tox_spec = importlib.util.find_spec("tox")
443 | assert tox_spec is not None
444 | tox_lines = subprocess.check_output(
445 | [sys.executable, "-c", "import tox; print(tox.__file__);"], encoding="UTF-8", env=env
446 | ).splitlines()
447 | assert tox_lines == [tox_spec.origin]
448 |
449 | # Now use that Python interpreter to run Tox
450 | project = tox_project({"tox.ini": "[testenv]\npackage=skip\ncommands=python -c 'print(\"{env_python}\")'"})
451 | tox_ini = project.path / "tox.ini"
452 | assert tox_ini.is_file()
453 | subprocess.check_call([sys.executable, "-m", "tox", "-c", tox_ini], env=env)
454 |
455 |
456 | def test_uv_python_set(tox_project: ToxProjectCreator, monkeypatch: pytest.MonkeyPatch) -> None:
457 | monkeypatch.setenv("UV_PYTHON", sys.executable)
458 | project = tox_project({
459 | "tox.ini": "[testenv]\npackage=skip\ndeps=setuptools\ncommands=python -c 'import setuptools'"
460 | })
461 | result = project.run("-vv")
462 | result.assert_success()
463 |
464 |
465 | def test_uv_pip_constraints(tox_project: ToxProjectCreator) -> None:
466 | project = tox_project({
467 | "tox.ini": f"""
468 | [testenv]
469 | package=skip
470 | setenv=
471 | PIP_CONSTRAINTS={os.devnull}
472 | commands=python --version
473 | """
474 | })
475 | result = project.run()
476 | result.assert_success()
477 | assert (
478 | result.out.count(
479 | "Found PIP_CONSTRAINTS defined, you may want to also define UV_CONSTRAINT to match pip behavior."
480 | )
481 | == 1
482 | ), "Warning should be found once and only once in output."
483 |
484 |
485 | def test_uv_pip_constraints_no(tox_project: ToxProjectCreator) -> None:
486 | project = tox_project({
487 | "tox.ini": f"""
488 | [testenv]
489 | package=skip
490 | setenv=
491 | PIP_CONSTRAINTS={os.devnull}
492 | UV_CONSTRAINT={os.devnull}
493 | commands=python --version
494 | """
495 | })
496 | result = project.run()
497 | result.assert_success()
498 | assert (
499 | "Found PIP_CONSTRAINTS defined, you may want to also define UV_CONSTRAINT to match pip behavior."
500 | not in result.out
501 | )
502 |
503 |
504 | class _TestUvVenv(UvVenv):
505 | @staticmethod
506 | def id() -> str:
507 | return "uv-venv-test" # pragma: no cover
508 |
509 | def set_base_python(self, python_info: PythonInfo) -> None:
510 | self._base_python_searched = True
511 | self._base_python = python_info
512 |
513 | def get_python_info(self, base_python: str) -> PythonInfo | None:
514 | return self._get_python([base_python])
515 |
516 |
517 | @pytest.mark.parametrize(
518 | ("base_python", "architecture"), [("python3.11", None), ("python3.11-32", 32), ("python3.11-64", 64)]
519 | )
520 | def test_get_python_architecture(base_python: str, architecture: int | None) -> None:
521 | uv_venv = _TestUvVenv(create_args=mock.Mock())
522 | python_info = uv_venv.get_python_info(base_python)
523 | assert python_info is not None
524 | assert python_info.extra["architecture"] == architecture
525 |
526 |
527 | @pytest.mark.parametrize(("base_python", "is_free_threaded"), [("py313", False), ("py313t", True)])
528 | def test_get_python_free_threaded(base_python: str, is_free_threaded: int | None) -> None:
529 | uv_venv = _TestUvVenv(create_args=mock.Mock())
530 | python_info = uv_venv.get_python_info(base_python)
531 | assert python_info is not None
532 | assert python_info.free_threaded == is_free_threaded
533 |
534 |
535 | def test_env_version_spec_no_architecture() -> None:
536 | uv_venv = _TestUvVenv(create_args=mock.MagicMock())
537 | python_info = PythonInfo(
538 | implementation="cpython",
539 | version_info=VersionInfo(
540 | major=3,
541 | minor=11,
542 | micro=9,
543 | releaselevel="",
544 | serial=0,
545 | ),
546 | version="",
547 | is_64=True,
548 | platform="win32",
549 | extra={"architecture": None},
550 | )
551 | uv_venv.set_base_python(python_info)
552 | with mock.patch("sys.version_info", (0, 0, 0)): # prevent picking sys.executable
553 | assert uv_venv.env_version_spec() == "cpython3.11"
554 |
555 |
556 | @pytest.mark.parametrize("architecture", [32, 64])
557 | def test_env_version_spec_architecture_configured(architecture: int) -> None:
558 | uv_venv = _TestUvVenv(create_args=mock.MagicMock())
559 | python_info = PythonInfo(
560 | implementation="cpython",
561 | version_info=VersionInfo(
562 | major=3,
563 | minor=11,
564 | micro=9,
565 | releaselevel="",
566 | serial=0,
567 | ),
568 | version="",
569 | is_64=architecture == 64,
570 | platform="win32",
571 | extra={"architecture": architecture},
572 | )
573 | uv_venv.set_base_python(python_info)
574 | uv_arch = {32: "x86", 64: "x86_64"}[architecture]
575 | assert uv_venv.env_version_spec() == f"cpython-3.11-windows-{uv_arch}-none"
576 |
577 |
578 | @pytest.mark.skipif(sys.platform != "win32", reason="architecture configuration only on Windows")
579 | def test_env_version_spec_architecture_configured_overwrite_sys_exe() -> None: # pragma: win32 cover
580 | uv_venv = _TestUvVenv(create_args=mock.MagicMock())
581 | (major, minor) = sys.version_info[:2]
582 | python_info = PythonInfo(
583 | implementation="cpython",
584 | version_info=VersionInfo(
585 | major=major,
586 | minor=minor,
587 | micro=0,
588 | releaselevel="",
589 | serial=0,
590 | ),
591 | version="",
592 | is_64=False,
593 | platform="win32",
594 | extra={"architecture": 32},
595 | )
596 | uv_venv.set_base_python(python_info)
597 | assert uv_venv.env_version_spec() == f"cpython-{major}.{minor}-windows-x86-none"
598 |
599 |
600 | def test_env_version_spec_free_threaded() -> None:
601 | uv_venv = _TestUvVenv(create_args=mock.MagicMock())
602 | python_info = PythonInfo(
603 | implementation="cpython",
604 | version_info=VersionInfo(
605 | major=3,
606 | minor=13,
607 | micro=3,
608 | releaselevel="",
609 | serial=0,
610 | ),
611 | version="",
612 | is_64=True,
613 | platform="win32",
614 | extra={"architecture": None},
615 | free_threaded=True,
616 | )
617 | uv_venv.set_base_python(python_info)
618 | with mock.patch("sys.version_info", (0, 0, 0)): # prevent picking sys.executable
619 | assert uv_venv.env_version_spec() == "cpython3.13+freethreaded"
620 |
--------------------------------------------------------------------------------