diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..0f9e29b --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @gaborbernat diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..d56c532 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +tidelift: pypi/tox-uv diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..c25d768 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +|---------| ------------------ | +| 1.0.0 + | :white_check_mark: | +| < 1.0.0 | :x: | + +## Reporting a Vulnerability + +To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift +will coordinate the fix and disclosure. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1230149 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..9d1e098 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,5 @@ +changelog: + exclude: + authors: + - dependabot + - pre-commit-ci diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..1c71c98 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,72 @@ +name: check +on: + workflow_dispatch: + push: + branches: ["main"] + tags-ignore: ["**"] + pull_request: + schedule: + - cron: "0 8 * * *" + +concurrency: + group: check-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: test ${{ matrix.py }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + py: + - "3.12" + - "3.11" + - "3.10" + - "3.9" + - "3.8" + steps: + - name: setup python for tox + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: actions/checkout@v4 + - name: Install self + run: python -m pip install . + - name: setup python for test ${{ matrix.py }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.py }} + - name: Pick environment to run + run: | + import codecs; import os; import sys + env = "TOXENV=py{}{}\n".format(*sys.version_info[0:2]) + print("Picked:\n{}for{}".format(env, sys.version)) + with codecs.open(os.environ["GITHUB_ENV"], "a", "utf-8") as file_handler: + file_handler.write(env) + shell: python + - name: setup test suite + run: tox -vv --notest + - name: run test suite + run: tox --skip-pkg-install + + check: + name: tox env ${{ matrix.tox_env }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + tox_env: + - type + - dev + - readme + steps: + - uses: actions/checkout@v4 + - name: setup Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install self + run: python -m pip install . + - name: run check for ${{ matrix.tox_env }} + run: python -m tox -e ${{ matrix.tox_env }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b695b12 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,27 @@ +name: Release to PyPI +on: + push: + tags: ["*"] + +jobs: + release: + runs-on: ubuntu-latest + environment: + name: release + url: https://pypi.org/p/tox-uv + permissions: + id-token: write + steps: + - name: Setup python to build package + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install build + run: python -m pip install build + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Build package + run: pyproject-build -s -w . -o dist + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@v1.8.11 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e6930d --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/magic +.idea +*.egg-info +.tox/ +.coverage* +coverage.xml +.*_cache +__pycache__ +**.pyc +/build +dist +src/tox_uv/version.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ab70beb --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,36 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/python-jsonschema/check-jsonschema + rev: 0.28.0 + hooks: + - id: check-github-workflows + args: [ "--verbose" ] + - repo: https://github.com/codespell-project/codespell + rev: v2.2.6 + hooks: + - id: codespell + additional_dependencies: ["tomli>=2.0.1"] + - repo: https://github.com/tox-dev/tox-ini-fmt + rev: "1.3.1" + hooks: + - id: tox-ini-fmt + args: ["-p", "fix"] + - repo: https://github.com/tox-dev/pyproject-fmt + rev: "1.7.0" + hooks: + - id: pyproject-fmt + additional_dependencies: ["tox>=4.12.1"] + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: "v0.2.1" + hooks: + - id: ruff-format + - id: ruff + args: ["--fix", "--unsafe-fixes", "--exit-non-zero-on-fix"] + - repo: meta + hooks: + - id: check-hooks-apply + - id: check-useless-excludes diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3649823 --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..75098e2 --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# tox-uv + +[![PyPI version](https://badge.fury.io/py/tox-uv.svg)](https://badge.fury.io/py/tox-uv) +[![PyPI Supported Python Versions](https://img.shields.io/pypi/pyversions/tox-uv.svg)](https://pypi.python.org/pypi/tox-uv/) +[![check](https://github.com/tox-dev/tox-uv/actions/workflows/check.yml/badge.svg)](https://github.com/tox-dev/tox-uv/actions/workflows/check.yml) +[![Downloads](https://static.pepy.tech/badge/tox-uv/month)](https://pepy.tech/project/tox-uv) + +**tox-uv** is a tox plugin which replaces pip with uv in tox. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c1994a5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,115 @@ +[build-system] +build-backend = "hatchling.build" +requires = [ + "hatch-vcs>=0.4", + "hatchling>=1.21.1", +] + +[project] +name = "tox-uv" +description = "Integration of uv into GitHub Actions." +readme = "README.md" +keywords = [ + "environments", + "isolated", + "testing", + "virtual", +] +license = "MIT" +maintainers = [{ name = "Bernát Gábor", email = "gaborjbernat@gmail.com" }] +requires-python = ">=3.8" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Internet", + "Topic :: Software Development :: Libraries", + "Topic :: System", +] +dynamic = [ + "version", +] +dependencies = [ + "tox<5,>=4.12.1", + "uv<1,>=0.1.1", +] +optional-dependencies.test = [ + "covdefaults>=2.3", + "devpi-process>=1", + "pytest>=8", + "pytest-cov>=4.1", + "pytest-mock>=3.12", +] +urls.Documentation = "https://github.com/tox-dev/tox-uv#tox-uv" +urls.Homepage = "https://github.com/tox-dev/tox-uv" +urls.Source = "https://github.com/tox-dev/tox-uv" +urls.Tracker = "https://github.com/tox-dev/tox-uv/issues" +entry-points.tox = {"tox-uv" = "tox_uv.plugin"} + +[tool.hatch] +build.hooks.vcs.version-file = "src/tox_uv/version.py" +build.targets.sdist.include = ["/src", "/tests"] +version.source = "vcs" + +[tool.black] +line-length = 120 + +[tool.ruff] +line-length = 120 +target-version = "py38" +lint.isort = { known-first-party = ["tox_uv", "tests"], required-imports = ["from __future__ import annotations"] } +lint.select = ["ALL"] +lint.ignore = [ + "ANN101", # Missing type annotation for `self` in method + "D301", # Use `r"""` if any backslashes in a docstring + "D205", # 1 blank line required between summary line and description + "D401", # First line of docstring should be in imperative mood + "D203", # `one-blank-line-before-class` (D203) and `no-blank-line-before-class` (D211) are incompatible + "D212", # `multi-line-summary-first-line` (D212) and `multi-line-summary-second-line` (D213) are incompatible + "S104", # Possible binding to all interface + "COM812", # Conflict with formatter + "ISC001", # Conflict with formatter + "CPY", # No copyriuvt statements + "D", # no documentation for now +] +lint.preview = true +format.preview = true +format.docstring-code-format = true +format.docstring-code-line-length = 100 +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = [ + "S101", # asserts allowed in tests... + "FBT", # don"t care about booleans as positional arguments in tests + "INP001", # no implicit namespace + "D", # don"t care about documentation in tests + "S603", # `subprocess` call: check for execution of untrusted input + "PLR2004", # Magic value used in comparison, consider replacing with a constant variable +] + +[tool.codespell] +builtin = "clear,usage,en-GB_to_en-US" +write-changes = true +count = true + +[tool.coverage] +html.show_contexts = true +html.skip_covered = false +paths.source = ["src", ".tox/*/.venv/lib/*/site-packages", ".tox\\*\\.venv\\Lib\\site-packages", "**/src", "**\\src"] +paths.other = [".", "*/tox_uv", "*\\tox_uv"] +report.fail_under = 96 +run.parallel = true +run.plugins = ["covdefaults"] + +[tool.mypy] +python_version = "3.11" +show_error_codes = true +strict = true +overrides = [{ module = ["virtualenv.*"], ignore_missing_imports = true }] diff --git a/src/tox_uv/__init__.py b/src/tox_uv/__init__.py new file mode 100644 index 0000000..c792f13 --- /dev/null +++ b/src/tox_uv/__init__.py @@ -0,0 +1,9 @@ +"""GitHub Actions integration.""" + +from __future__ import annotations + +from .version import version as __version__ + +__all__ = [ + "__version__", +] diff --git a/src/tox_uv/_installer.py b/src/tox_uv/_installer.py new file mode 100644 index 0000000..315b5e0 --- /dev/null +++ b/src/tox_uv/_installer.py @@ -0,0 +1,110 @@ +"""GitHub Actions integration.""" + +from __future__ import annotations + +import logging +import sys +from collections import defaultdict +from pathlib import Path +from typing import TYPE_CHECKING, Any, Sequence + +from packaging.requirements import Requirement +from tox.config.types import Command +from tox.execute.request import StdinSource +from tox.tox_env.errors import Recreate +from tox.tox_env.python.package import EditableLegacyPackage, EditablePackage, SdistPackage, WheelPackage +from tox.tox_env.python.pip.pip_install import Pip +from tox.tox_env.python.pip.req_file import PythonDeps + +if TYPE_CHECKING: + from tox.config.main import Config + from tox.tox_env.package import PathPackage + + +class UvInstaller(Pip): + """Pip is a python installer that can install packages as defined by PEP-508 and PEP-517.""" + + @property + def uv(self) -> str: + return str(Path(sys.executable).parent / "uv") + + def default_install_command(self, conf: Config, env_name: str | None) -> Command: # noqa: ARG002 + return Command([self.uv, "pip", "install", "{opts}", "{packages}"]) + + def post_process_install_command(self, cmd: Command) -> Command: + install_command = cmd.args + pip_pre: bool = self._env.conf["pip_pre"] + try: + opts_at = install_command.index("{opts}") + except ValueError: + if pip_pre: + install_command.extend(("--prerelease", "allow")) + else: + if pip_pre: + install_command[opts_at] = "--prerelease" + install_command.insert(opts_at + 1, "allow") + else: + install_command.pop(opts_at) + return cmd + + def installed(self) -> list[str]: + cmd: Command = self._env.conf["list_dependencies_command"] + result = self._env.execute(cmd=cmd.args, stdin=StdinSource.OFF, run_id="freeze", show=False) + result.assert_success() + return result.out.splitlines() + + def install(self, arguments: Any, section: str, of_type: str) -> None: # noqa: ANN401 + if isinstance(arguments, PythonDeps): + self._install_requirement_file(arguments, section, of_type) + elif isinstance(arguments, Sequence): # pragma: no branch + self._install_list_of_deps(arguments, section, of_type) + else: # pragma: no cover + logging.warning("uv cannot install %r", arguments) # pragma: no cover + raise SystemExit(1) # pragma: no cover + + def _install_list_of_deps( # noqa: C901 + self, + arguments: Sequence[ + Requirement | WheelPackage | SdistPackage | EditableLegacyPackage | EditablePackage | PathPackage + ], + section: str, + of_type: str, + ) -> None: + groups: dict[str, list[str]] = defaultdict(list) + for arg in arguments: + if isinstance(arg, Requirement): # pragma: no branch + groups["req"].append(str(arg)) # pragma: no cover + elif isinstance(arg, (WheelPackage, SdistPackage, EditablePackage)): + groups["req"].extend(str(i) for i in arg.deps) + name = arg.path.name.split("-")[0] + groups["pkg"].append(f"{name}@{arg.path}") + elif isinstance(arg, EditableLegacyPackage): + groups["req"].extend(str(i) for i in arg.deps) + groups["dev_pkg"].append(str(arg.path)) + else: # pragma: no branch + logging.warning("uv install %r", arg) # pragma: no cover + raise SystemExit(1) # pragma: no cover + req_of_type = f"{of_type}_deps" if groups["pkg"] or groups["dev_pkg"] else of_type + for value in groups.values(): + value.sort() + with self._env.cache.compare(groups["req"], section, req_of_type) as (eq, old): + if not eq: # pragma: no branch + miss = sorted(set(old or []) - set(groups["req"])) + if miss: # no way yet to know what to uninstall here (transitive dependencies?) # pragma: no branch + msg = f"dependencies removed: {', '.join(str(i) for i in miss)}" # pragma: no cover + raise Recreate(msg) # pragma: no branch # pragma: no cover + new_deps = sorted(set(groups["req"]) - set(old or [])) + if new_deps: # pragma: no branch + self._execute_installer(new_deps, req_of_type) + install_args = ["--reinstall", "--no-deps"] + if groups["pkg"]: + self._execute_installer(install_args + groups["pkg"], of_type) + if groups["dev_pkg"]: + for entry in groups["dev_pkg"]: + install_args.extend(("-e", str(entry))) + self._execute_installer(install_args, of_type) + + +__all__ = [ + "UvInstaller", +] diff --git a/src/tox_uv/_run.py b/src/tox_uv/_run.py new file mode 100644 index 0000000..defe899 --- /dev/null +++ b/src/tox_uv/_run.py @@ -0,0 +1,38 @@ +"""GitHub Actions integration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from tox.tox_env.python.runner import PythonRun + +from ._venv import UvVenv + +if TYPE_CHECKING: + from pathlib import Path + + +class UvVenvRunner(UvVenv, PythonRun): + @staticmethod + def id() -> str: + return "uv-venv-runner" + + @property + def _package_tox_env_type(self) -> str: + return "virtualenv-pep-517" + + @property + def _external_pkg_tox_env_type(self) -> str: + return "virtualenv-cmd-builder" # pragma: no cover + + @property + def default_pkg_type(self) -> str: + tox_root: Path = self.core["tox_root"] + if not (any((tox_root / i).exists() for i in ("pyproject.toml", "setup.py", "setup.cfg"))): + return "skip" + return super().default_pkg_type + + +__all__ = [ + "UvVenvRunner", +] diff --git a/src/tox_uv/_venv.py b/src/tox_uv/_venv.py new file mode 100644 index 0000000..c5ee31c --- /dev/null +++ b/src/tox_uv/_venv.py @@ -0,0 +1,129 @@ +"""GitHub Actions integration.""" + +from __future__ import annotations + +import sys +from abc import ABC +from pathlib import Path +from platform import python_implementation +from typing import TYPE_CHECKING, Any, cast + +from tox.execute.local_sub_process import LocalSubProcessExecutor +from tox.execute.request import StdinSource +from tox.tox_env.python.api import Python, PythonInfo, VersionInfo +from virtualenv.discovery.py_spec import PythonSpec + +from ._installer import UvInstaller + +if TYPE_CHECKING: + from tox.execute.api import Execute + from tox.tox_env.api import ToxEnvCreateArgs + from tox.tox_env.installer import Installer + + +class UvVenv(Python, ABC): + def __init__(self, create_args: ToxEnvCreateArgs) -> None: + self._executor: Execute | None = None + self._installer: UvInstaller | None = None + super().__init__(create_args) + + @property + def executor(self) -> Execute: + if self._executor is None: + self._executor = LocalSubProcessExecutor(self.options.is_colored) + return self._executor + + @property + def installer(self) -> Installer[Any]: + if self._installer is None: + self._installer = UvInstaller(self) + return self._installer + + @property + def runs_on_platform(self) -> str: + return sys.platform + + def _get_python(self, base_python: list[str]) -> PythonInfo | None: # noqa: PLR6301 + for base in base_python: # pragma: no branch + if base == sys.executable: + version_info = sys.version_info + return PythonInfo( + implementation=python_implementation(), + version_info=VersionInfo( + major=version_info.major, + minor=version_info.minor, + micro=version_info.micro, + releaselevel=version_info.releaselevel, + serial=version_info.serial, + ), + version=sys.version, + is_64=sys.maxsize > 2**32, + platform=sys.platform, + extra={}, + ) + spec = PythonSpec.from_string_spec(base) + return PythonInfo( + implementation=spec.implementation, + version_info=VersionInfo( + major=spec.major, + minor=spec.minor, + micro=spec.micro, + releaselevel="", + serial=0, + ), + version=str(spec), + is_64=spec.architecture == 64, # noqa: PLR2004 + platform=sys.platform, + extra={}, + ) + return None # pragma: no cover + + @property + def uv(self) -> str: + return str(Path(sys.executable).parent / "uv") + + @property + def venv_dir(self) -> Path: + return cast(Path, self.conf["env_dir"]) / ".venv" + + @property + def environment_variables(self) -> dict[str, str]: + env = super().environment_variables + env["VIRTUAL_ENV"] = str(self.venv_dir) + return env + + def create_python_env(self) -> None: + base = self.base_python + cmd = [self.uv, "venv", "-p", base.version_dot, "--seed", str(self.venv_dir)] + outcome = self.execute(cmd, stdin=StdinSource.OFF, run_id="venv", show=False) + outcome.assert_success() + + @property + def _allow_externals(self) -> list[str]: + result = super()._allow_externals + result.append(self.uv) + return result + + def prepend_env_var_path(self) -> list[Path]: + return [self.env_bin_dir()] + + def env_bin_dir(self) -> Path: + if sys.platform == "win32": # pragma: win32 cover + return self.venv_dir / "Scripts" + # pragma: win32 no cover + return self.venv_dir / "bin" + + def env_python(self) -> Path: + suffix = ".exe" if sys.platform == "win32" else "" + return self.env_bin_dir() / f"python{suffix}" + + def env_site_package_dir(self) -> Path: + if sys.platform == "win32": # pragma: win32 cover + return self.venv_dir / "Lib" / "site-packages" + # pragma: win32 no cover + return self.venv_dir / "lib" / f"python{self.base_python.version_dot}" / "site-packages" + + +__all__ = [ + "UvVenv", +] diff --git a/src/tox_uv/plugin.py b/src/tox_uv/plugin.py new file mode 100644 index 0000000..c60bda9 --- /dev/null +++ b/src/tox_uv/plugin.py @@ -0,0 +1,23 @@ +"""GitHub Actions integration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from tox.plugin import impl + +from ._run import UvVenvRunner + +if TYPE_CHECKING: + from tox.tox_env.register import ToxEnvRegister + + +@impl +def tox_register_tox_env(register: ToxEnvRegister) -> None: + register.add_run_env(UvVenvRunner) + register._default_run_env = UvVenvRunner.id() # noqa: SLF001 + + +__all__ = [ + "tox_register_tox_env", +] diff --git a/src/tox_uv/py.typed b/src/tox_uv/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c3e5d4e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + + +@pytest.fixture(scope="session") +def root() -> Path: + return Path(__file__).parent + + +@pytest.fixture(scope="session") +def demo_pkg_setuptools(root: Path) -> Path: + return root / "demo_pkg_setuptools" + + +@pytest.fixture(scope="session") +def demo_pkg_inline(root: Path) -> Path: + return root / "demo_pkg_inline" + + +pytest_plugins = [ + "tox.pytest", +] diff --git a/tests/demo_pkg_inline/build.py b/tests/demo_pkg_inline/build.py new file mode 100644 index 0000000..105a4b7 --- /dev/null +++ b/tests/demo_pkg_inline/build.py @@ -0,0 +1,130 @@ +""" +Please keep this file Python 2.7 compatible. +See https://tox.readthedocs.io/en/rewrite/development.html#code-style-guide +""" + +from __future__ import annotations + +import os +import sys +import tarfile +from pathlib import Path +from textwrap import dedent +from zipfile import ZipFile + +name = "demo_pkg_inline" +pkg_name = name.replace("_", "-") + +version = "1.0.0" +dist_info = f"{name}-{version}.dist-info" +logic = f"{name}/__init__.py" +plugin = f"{name}/example_plugin.py" +entry_points = f"{dist_info}/entry_points.txt" +metadata = f"{dist_info}/METADATA" +wheel = f"{dist_info}/WHEEL" +record = f"{dist_info}/RECORD" +content = { + logic: f"def do():\n print('greetings from {name}')", + plugin: """ + try: + from tox.plugin import impl + from tox.tox_env.python.virtual_env.runner import VirtualEnvRunner + from tox.tox_env.register import ToxEnvRegister + except ImportError: + pass + else: + class ExampleVirtualEnvRunner(VirtualEnvRunner): + @staticmethod + def id() -> str: + return "example" + @impl + def tox_register_tox_env(register: ToxEnvRegister) -> None: + register.add_run_env(ExampleVirtualEnvRunner) + """, +} +metadata_files = { + entry_points: f""" + [tox] + example = {name}.example_plugin""", + metadata: """ + Metadata-Version: 2.1 + Name: {} + Version: {} + Summary: UNKNOWN + Home-page: UNKNOWN + Author: UNKNOWN + Author-email: UNKNOWN + License: UNKNOWN + {} + Platform: UNKNOWN + + UNKNOWN + """.format( + pkg_name, + version, + "\n ".join(os.environ.get("METADATA_EXTRA", "").split("\n")), + ), + wheel: f""" + Wheel-Version: 1.0 + Generator: {name}-{version} + Root-Is-Purelib: true + Tag: py{sys.version_info[0]}-none-any + """, + f"{dist_info}/top_level.txt": name, + record: f""" + {name}/__init__.py,, + {dist_info}/METADATA,, + {dist_info}/WHEEL,, + {dist_info}/top_level.txt,, + {dist_info}/RECORD,, + """, +} + + +def build_wheel( + wheel_directory: str, + config_settings: dict[str, str] | None = None, # noqa: ARG001 + metadata_directory: str | None = None, +) -> str: + base_name = f"{name}-{version}-py{sys.version_info[0]}-none-any.whl" + path = Path(wheel_directory) / base_name + with ZipFile(str(path), "w") as zip_file_handler: + for arc_name, data in content.items(): # pragma: no branch + zip_file_handler.writestr(arc_name, dedent(data).strip()) + if metadata_directory is not None: + for sub_directory, _, filenames in os.walk(metadata_directory): + for filename in filenames: + zip_file_handler.write( + str(Path(metadata_directory) / sub_directory / filename), + str(Path(sub_directory) / filename), + ) + else: + for arc_name, data in metadata_files.items(): + zip_file_handler.writestr(arc_name, dedent(data).strip()) + print(f"created wheel {path}") # noqa: T201 + return base_name + + +def get_requires_for_build_wheel(config_settings: dict[str, str] | None = None) -> list[str]: # noqa: ARG001 + return [] # pragma: no cover # only executed in non-host pythons + + +def build_editable( + wheel_directory: str, + config_settings: dict[str, str] | None = None, + metadata_directory: str | None = None, +) -> str: + return build_wheel(wheel_directory, config_settings, metadata_directory) + + +def build_sdist(sdist_directory: str, config_settings: dict[str, str] | None = None) -> str: # noqa: ARG001 + result = f"{name}-{version}.tar.gz" # pragma: win32 cover + with tarfile.open(str(Path(sdist_directory) / result), "w:gz") as tar: # pragma: win32 cover + root = Path(__file__).parent # pragma: win32 cover + tar.add(str(root / "build.py"), "build.py") # pragma: win32 cover + tar.add(str(root / "pyproject.toml"), "pyproject.toml") # pragma: win32 cover + return result # pragma: win32 cover + + +def get_requires_for_build_sdist(config_settings: dict[str, str] | None = None) -> list[str]: # noqa: ARG001 + return [] # pragma: no cover # only executed in non-host pythons diff --git a/tests/demo_pkg_inline/pyproject.toml b/tests/demo_pkg_inline/pyproject.toml new file mode 100644 index 0000000..24c7f1d --- /dev/null +++ b/tests/demo_pkg_inline/pyproject.toml @@ -0,0 +1,10 @@ +[build-system] +build-backend = "build" +requires = [ +] +backend-path = [ + ".", +] + +[tool.black] +line-length = 120 diff --git a/tests/demo_pkg_setuptools/demo_pkg_setuptools/__init__.py b/tests/demo_pkg_setuptools/demo_pkg_setuptools/__init__.py new file mode 100644 index 0000000..b14013e --- /dev/null +++ b/tests/demo_pkg_setuptools/demo_pkg_setuptools/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + + +def do() -> None: + print("greetings from demo_pkg_setuptools") # noqa: T201 diff --git a/tests/demo_pkg_setuptools/pyproject.toml b/tests/demo_pkg_setuptools/pyproject.toml new file mode 100644 index 0000000..4a05c8b --- /dev/null +++ b/tests/demo_pkg_setuptools/pyproject.toml @@ -0,0 +1,5 @@ +[build-system] +build-backend = 'setuptools.build_meta' +requires = [ + "setuptools>=63", +] diff --git a/tests/demo_pkg_setuptools/setup.cfg b/tests/demo_pkg_setuptools/setup.cfg new file mode 100644 index 0000000..80e4254 --- /dev/null +++ b/tests/demo_pkg_setuptools/setup.cfg @@ -0,0 +1,6 @@ +[metadata] +name = demo_pkg_setuptools +version = 1.2.3 + +[options] +packages = find: diff --git a/tests/test_tox_uv_installer.py b/tests/test_tox_uv_installer.py new file mode 100644 index 0000000..963f57e --- /dev/null +++ b/tests/test_tox_uv_installer.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import pytest + from tox.pytest import ToxProjectCreator + + +def test_uv_install_in_ci_list(tox_project: ToxProjectCreator, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CI", "1") + project = tox_project({"tox.ini": "[testenv]\ndeps = tomli\npackage=skip"}) + result = project.run() + result.assert_success() + assert "tomli==" in result.out + + +def test_uv_install_with_pre(tox_project: ToxProjectCreator) -> None: + project = tox_project({"tox.ini": "[testenv]\ndeps = tomli\npip_pre = true\npackage=skip"}) + result = project.run("-vv") + result.assert_success() + + +def test_uv_install_with_pre_custom_install_cmd(tox_project: ToxProjectCreator) -> None: + project = tox_project({ + "tox.ini": """ + [testenv] + deps = tomli + pip_pre = true + package = skip + install_command = uv pip install {packages} + """ + }) + result = project.run("-vv") + result.assert_success() + + +def test_uv_install_without_pre_custom_install_cmd(tox_project: ToxProjectCreator) -> None: + project = tox_project({ + "tox.ini": """ + [testenv] + deps = tomli + package = skip + install_command = uv pip install {packages} + """ + }) + result = project.run("-vv") + result.assert_success() diff --git a/tests/test_tox_uv_package.py b/tests/test_tox_uv_package.py new file mode 100644 index 0000000..2b3e05f --- /dev/null +++ b/tests/test_tox_uv_package.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from pathlib import Path + + from tox.pytest import ToxProjectCreator + + +def test_uv_package_skip(tox_project: ToxProjectCreator) -> None: + project = tox_project({"tox.ini": "[testenv]\npackage=skip"}) + result = project.run("-vv") + result.assert_success() + + +def test_uv_package_use_default_from_file(tox_project: ToxProjectCreator) -> None: + project = tox_project({"tox.ini": "[testenv]\npackage=skip", "pyproject.toml": ""}) + result = project.run("-vv") + result.assert_success() + + +@pytest.mark.parametrize("package", ["sdist", "wheel", "editable"]) +def test_uv_package_editable(tox_project: ToxProjectCreator, package: str, demo_pkg_inline: Path) -> None: + project = tox_project({"tox.ini": f"[testenv]\npackage={package}"}, base=demo_pkg_inline) + result = project.run() + if package == "sdist": + result.assert_failed(code=2) + else: + result.assert_success() + + +def test_uv_package_editable_legacy(tox_project: ToxProjectCreator, demo_pkg_setuptools: Path) -> None: + project = tox_project({"tox.ini": "[testenv]\npackage=editable-legacy"}, base=demo_pkg_setuptools) + result = project.run() + result.assert_success() + + +def test_uv_package_requirements(tox_project: ToxProjectCreator) -> None: + project = tox_project({"tox.ini": "[testenv]\npackage=skip\ndeps=-r demo.txt", "demo.txt": "tomli"}) + result = project.run("-vv") + result.assert_success() diff --git a/tests/test_tox_uv_venv.py b/tests/test_tox_uv_venv.py new file mode 100644 index 0000000..db62921 --- /dev/null +++ b/tests/test_tox_uv_venv.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from tox.pytest import ToxProjectCreator + + +def test_uv_venv_self(tox_project: ToxProjectCreator) -> None: + project = tox_project({"tox.ini": "[testenv]\npackage=skip"}) + result = project.run("-vv") + result.assert_success() + + +def test_uv_venv_spec(tox_project: ToxProjectCreator) -> None: + ver = sys.version_info + project = tox_project({"tox.ini": f"[testenv]\npackage=skip\nbase_python={ver.major}.{ver.minor}"}) + result = project.run("-vv") + result.assert_success() + + +def test_uv_venv_na(tox_project: ToxProjectCreator) -> None: + project = tox_project({"tox.ini": "[testenv]\npackage=skip\nbase_python=1.0"}) + result = project.run("-vv") + result.assert_failed(code=1) + + +def test_uv_venv_platform_check(tox_project: ToxProjectCreator) -> None: + project = tox_project({"tox.ini": f"[testenv]\nplatform={sys.platform}\npackage=skip"}) + result = project.run("-vv") + result.assert_success() + + +def test_uv_env_bin_dir(tox_project: ToxProjectCreator) -> None: + project = tox_project({"tox.ini": "[testenv]\npackage=skip\ncommands=python -c 'print(\"{env_bin_dir}\")'"}) + result = project.run("-vv") + result.assert_success() + + env_bin_dir = str(project.path / ".tox" / "py" / ".venv" / ("Scripts" if sys.platform == "win32" else "bin")) + assert env_bin_dir in result.out + + +def test_uv_env_python(tox_project: ToxProjectCreator) -> None: + project = tox_project({"tox.ini": "[testenv]\npackage=skip\ncommands=python -c 'print(\"{env_python}\")'"}) + result = project.run("-vv") + result.assert_success() + + exe = "python.exe" if sys.platform == "win32" else "python" + env_bin_dir = str(project.path / ".tox" / "py" / ".venv" / ("Scripts" if sys.platform == "win32" else "bin") / exe) + assert env_bin_dir in result.out + + +def test_uv_env_site_package_dir(tox_project: ToxProjectCreator) -> None: + project = tox_project({"tox.ini": "[testenv]\npackage=skip\ncommands=python -c 'print(\"{envsitepackagesdir}\")'"}) + result = project.run("-vv") + result.assert_success() + + env_dir = project.path / ".tox" / "py" / ".venv" + ver = sys.version_info + if sys.platform == "win32": # pragma: win32 cover + path = str(env_dir / "Lib" / "site-packages") + else: # pragma: win32 no cover + path = str(env_dir / "lib" / f"python{ver.major}.{ver.minor}" / "site-packages") + assert path in result.out diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..0d08afb --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,7 @@ +from __future__ import annotations + + +def test_version() -> None: + from tox_uv import __version__ # noqa: PLC0415 + + assert __version__ diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..4c4c705 --- /dev/null +++ b/tox.ini @@ -0,0 +1,72 @@ +[tox] +requires = + tox>=4.2 +env_list = + fix + py312 + py311 + py310 + py39 + py38 + type + readme +skip_missing_interpreters = true + +[testenv] +description = run the unit tests with pytest under {basepython} +package = wheel +wheel_build_env = .pkg +extras = + test +set_env = + COVERAGE_FILE = {toxworkdir}{/}.coverage.{envname} +commands = + pytest {tty:--color=yes} {posargs: \ + --cov {envsitepackagesdir}{/}tox_uv --cov {toxinidir}{/}tests --cov-context=test \ + --no-cov-on-fail --cov-config {toxinidir}{/}pyproject.toml \ + --cov-report term-missing:skip-covered --junitxml {toxworkdir}{/}junit.{envname}.xml \ + --cov-report html:{envtmpdir}{/}htmlcov \ + tests} + +[testenv:fix] +description = run static analysis and style check using flake8 +skip_install = true +deps = + pre-commit>=3.6.1 +pass_env = + HOMEPATH + PROGRAMDATA +commands = + pre-commit run --all-files --show-diff-on-failure + +[testenv:type] +description = run type check on code base +deps = + mypy==1.8 +set_env = + {tty:MYPY_FORCE_COLOR = 1} +commands = + mypy src {posargs} + mypy tests {posargs} + +[testenv:readme] +description = check that the package metadata is correct +skip_install = true +deps = + build[virtualenv]>=1.0.3 + twine>=5 +set_env = + {tty:FORCE_COLOR = 1} +change_dir = {toxinidir} +commands = + python -m build --sdist --wheel -o {envtmpdir} . + twine check {envtmpdir}{/}* + +[testenv:dev] +description = generate a DEV environment +package = editable +extras = + test +commands = + python -m pip list --format=columns + python -c 'import sys; print(sys.executable)'