├── 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 | [![PyPI version](https://badge.fury.io/py/tox-uv.svg)](https://badge.fury.io/py/tox-uv) 4 | [![PyPI Supported Python Versions](https://img.shields.io/pypi/pyversions/tox-uv.svg)](https://pypi.python.org/pypi/tox-uv/) 5 | [![check](https://github.com/tox-dev/tox-uv/actions/workflows/check.yaml/badge.svg)](https://github.com/tox-dev/tox-uv/actions/workflows/check.yaml) 6 | [![Downloads](https://static.pepy.tech/badge/tox-uv/month)](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 | --------------------------------------------------------------------------------