diff --git a/.github/workflows/integtests.yaml b/.github/workflows/integtests.yaml index cd1b305..d891b53 100644 --- a/.github/workflows/integtests.yaml +++ b/.github/workflows/integtests.yaml @@ -46,6 +46,30 @@ jobs: run: | cd /fades bin/fades -v -d pytest -x pytest --version + - name: default backend is pip (uv NOT used without opt-in) + run: | + cd /fades + # even with uv on PATH, the default must stay pip -- the uv-backend log line is absent + bin/fades -v -d six -x python -c "import six" > default.log 2>&1 + ! grep -q "Using uv backend" default.log + - name: uv backend is used with --use-uv + run: | + cd /fades + bin/fades -v --use-uv -d six -x python -c "import six" 2>&1 | grep -q "Using uv backend" + - name: --use-uv with --pip-options is an error + run: | + cd /fades + ! bin/fades --use-uv --pip-options="--foo" -d six -x python -c "import six" + - name: --use-uv with a bogus --uv-path is an error + run: | + cd /fades + ! bin/fades --use-uv --uv-path /no/such/uv -d six -x python -c "import six" + - name: --freeze on a uv venv produces a pinned file + run: | + cd /fades + # uv venvs are seeded with pip, so --freeze yields a normal requirements file + bin/fades -v --use-uv -d six --freeze=frozen.txt -x python -c "import six" + grep -q "^six==" frozen.txt - name: Using the oldest supported Python env: OLDEST: ${{ needs.min-python.outputs.version }} @@ -74,6 +98,30 @@ jobs: run: | cd /fades bin/fades -v -d pytest -x pytest --version + - name: default backend is pip (uv NOT used without opt-in) + run: | + cd /fades + # even with uv on PATH, the default must stay pip -- the uv-backend log line is absent + bin/fades -v -d six -x python -c "import six" > default.log 2>&1 + ! grep -q "Using uv backend" default.log + - name: uv backend is used with --use-uv + run: | + cd /fades + bin/fades -v --use-uv -d six -x python -c "import six" 2>&1 | grep -q "Using uv backend" + - name: --use-uv with --pip-options is an error + run: | + cd /fades + ! bin/fades --use-uv --pip-options="--foo" -d six -x python -c "import six" + - name: --use-uv with a bogus --uv-path is an error + run: | + cd /fades + ! bin/fades --use-uv --uv-path /no/such/uv -d six -x python -c "import six" + - name: --freeze on a uv venv produces a pinned file + run: | + cd /fades + # uv venvs are seeded with pip, so --freeze yields a normal requirements file + bin/fades -v --use-uv -d six --freeze=frozen.txt -x python -c "import six" + grep -q "^six==" frozen.txt - name: Using the oldest supported Python env: OLDEST: ${{ needs.min-python.outputs.version }} @@ -112,10 +160,39 @@ jobs: - name: Install dependencies run: | ${{ steps.matrixpy.outputs.python-path }} -m pip install -U packaging + - name: Install uv (on PATH for all following steps) + uses: astral-sh/setup-uv@v6 - name: Simple fades run run: | ${{ steps.matrixpy.outputs.python-path }} bin/fades -v -d pytest -x pytest --version + - name: uv backend works on Windows with --use-uv + shell: bash + run: | + # this exercises the whole uv path on Windows: 'uv venv' + 'uv pip install --python + # \Scripts\python.exe'. If the python/python.exe resolution were wrong, uv would + # fail to find the interpreter and the install (hence this step) would fail. + which uv && uv --version + # single-quote to keep the backslashes, then turn them into forward slashes so bash + # (Git Bash) doesn't strip them out of the Windows path + PY='${{ steps.matrixpy.outputs.python-path }}'; PY="${PY//\\//}" + "$PY" bin/fades -v --use-uv -d six -x python -c "import six" > uv.log 2>&1 || { cat uv.log; exit 1; } + cat uv.log + grep -q "Using uv backend" uv.log + - name: default backend is pip on Windows (no opt-in) + shell: bash + run: | + PY='${{ steps.matrixpy.outputs.python-path }}'; PY="${PY//\\//}" + "$PY" bin/fades -v -d six -x python -c "import six" > default.log 2>&1 + ! grep -q "Using uv backend" default.log + - name: --freeze on a uv venv produces a pinned file (Windows) + shell: bash + run: | + PY='${{ steps.matrixpy.outputs.python-path }}'; PY="${PY//\\//}" + "$PY" bin/fades -v --use-uv -d six --freeze=frozen.txt -x python -c "import six" > freeze.log 2>&1 || { cat freeze.log; exit 1; } + cat frozen.txt + grep -q "^six==" frozen.txt + - name: Using a different Python run: | ${{ steps.matrixpy.outputs.python-path }} bin/fades -v --python=${{ steps.otherpy.outputs.python-path }} -d pytest -x pytest -v --integtest-pyversion=${{ needs.min-python.outputs.version }} tests/integtest.py diff --git a/README.rst b/README.rst index 55d30ec..8f7f619 100644 --- a/README.rst +++ b/README.rst @@ -391,6 +391,42 @@ Examples: ``fades --python-options=-B foo.py`` +Using uv as backend +------------------- + +fades can optionally use `uv `_ as a (much faster) backend to create the virtual environment and install the dependencies, instead of ``venv`` and ``pip``. This is **opt-in**: fades does not change its behaviour just because ``uv`` happens to be installed, because ``uv`` produces slightly different virtual environments than ``pip``. + +Enable it per run with ``--use-uv``:: + + fades --use-uv -d requests -x python + +``uv`` must be available: fades looks it up in ``PATH`` (installed via your system package manager, the standalone installer, etc.; fades does not depend on the ``uv`` PyPI package). You can point at a specific executable with ``--uv-path /path/to/uv`` (which also implies ``--use-uv``). If ``uv`` is requested but can't be found, fades errors out. + +Everything else stays fades: dependency parsing (``# fades`` comments, docstring, ``-d``/``-r``), venv indexing and reuse, ``--where``/``--rm``/``--clean-unused-venvs``, config files, the REPL and ``--autoimport``, etc. + +Some notes when using ``uv``: + +- fades runs ``uv venv --seed``, so the virtual environment ships ``pip`` (and ``setuptools``, depending on the Python version) just like the classic ``venv`` backend, keeping packages that expect them present working. +- The PyPI availability pre-check is skipped (``uv`` resolves directly and fails early on its own). +- ``--pip-options`` and ``--venv-options`` are **not** valid together with ``--use-uv`` (they target ``pip``/``venv``); use ``--uv-pip-options`` to pass extra options to ``uv pip install`` instead. + +Enabling uv permanently (via config) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you always want the uv backend, set ``use_uv=true`` under the ``[fades]`` section of any fades config file. fades reads (in increasing priority) ``/etc/fades/fades.ini`` (machine-wide), the per-user config (``/fades/fades.ini``) and a repo-local ``./.fades.ini`` (handy to enable uv for a single project). A ``--use-uv``/``--no``-style flag on the command line still wins over the config. + +To turn it on for your user without editing files by hand, run:: + + python -W ignore -c "import configparser; from pathlib import Path; from fades.helpers import get_confdir; \ + p = Path(get_confdir()) / 'fades.ini'; c = configparser.ConfigParser(); c.read(p); \ + c.has_section('fades') or c.add_section('fades'); c.set('fades', 'use_uv', 'true'); \ + c.write(p.open('w')); print('uv enabled by default in', p)" + +A note about ``pkg_resources``: some packages (e.g. ``azure-cli``) still import the long-deprecated ``pkg_resources``, which was **removed** in ``setuptools >= 81``. uv (and the classic backend on Python 3.12+) installs a recent ``setuptools`` that no longer provides it, so those imports fail. This is not specific to the uv backend; if you hit it, add an older setuptools as an explicit dependency:: + + fades --use-uv -d azure-cli -d "setuptools<81" -x az --help + + Setting options using config files ---------------------------------- @@ -479,9 +515,9 @@ Execute the Python interactive interpreter in a virtual environment after instal fades -r requirements.txt -r requirements_devel.txt -Use the ``django-admin.py`` script to start a new project named ``foo``, without having to have django previously installed:: +Use the ``django-admin`` script to start a new project named ``foo``, without having to have django previously installed:: - fades -d django -x django-admin.py startproject foo + fades -d django -x django-admin startproject foo Remove a virtual environment matching the given uuid from disk and cache index:: diff --git a/fades/envbuilder.py b/fades/envbuilder.py index 7c11a94..5d842dd 100644 --- a/fades/envbuilder.py +++ b/fades/envbuilder.py @@ -19,6 +19,7 @@ import logging import os import shutil +import sys from datetime import datetime, timezone from pathlib import Path from uuid import uuid4 @@ -27,6 +28,7 @@ from fades import FadesError, REPO_PYPI, REPO_VCS from fades import helpers, cache from fades.pipmanager import PipManager +from fades.uvmanager import UvManager from fades.multiplatform import filelock logger = logging.getLogger(__name__) @@ -115,11 +117,49 @@ def post_setup(self, context): self.env_bin_path = Path(context.bin_path) -def create_venv(requested_deps, interpreter, is_current, options, pip_options, avoid_pip_upgrade): - """Create a new virtualvenv with the requirements of this script.""" +def create_with_uv(interpreter, is_current, venv_options, uv_exe): + """Create a virtual environment using uv, returning its path and bin path.""" + env_path = helpers.get_basedir() / str(uuid4()) + logger.debug("Env will be created at: %s (with uv)", env_path) + + # when there's no explicitly requested interpreter (current Python), point uv at fades' + # own interpreter so the venv matches what the cache was keyed on + python = interpreter if (interpreter is not None and not is_current) else sys.executable + # '--seed' installs pip + setuptools (+ wheel) like the classic 'python -m venv' backend, + # so packages that expect those to be present in the venv keep working (uv seeds nothing + # by default); the cost is negligible + args = [uv_exe, "venv", "--seed", str(env_path), "--python", str(python)] + args.extend(venv_options) + + try: + helpers.logged_exec(args) + except helpers.ExecutionError as error: + error.dump_to_log(logger) + raise FadesError("Failed to create virtual environment with uv") + except Exception as error: + logger.exception("Error creating virtual environment with uv: %s", error) + raise FadesError("General error while creating venv with uv") + + env_bin_path = helpers.get_env_bin_path(env_path) + return env_path, env_bin_path + + +def create_venv(requested_deps, interpreter, is_current, options, pip_options, avoid_pip_upgrade, + use_uv=False, uv_exe=None, uv_pip_options=None): + """Create a new virtualvenv with the requirements of this script. + + When ``use_uv`` is True the caller must provide ``uv_exe`` (the resolved uv binary). + """ # create virtual environment - env = _FadesEnvBuilder() - env_path, env_bin_path, pip_installed = env.create_env(interpreter, is_current, options) + if use_uv: + logger.debug("Creating virtual environment with uv backend") + env_path, env_bin_path = create_with_uv( + interpreter, is_current, options['venv_options'], uv_exe) + # 'uv venv --seed' installs pip in the venv, so it's available like in the classic path + pip_installed = True + else: + env = _FadesEnvBuilder() + env_path, env_bin_path, pip_installed = env.create_env(interpreter, is_current, options) venv_data = {} venv_data['env_path'] = env_path venv_data['env_bin_path'] = env_bin_path @@ -129,9 +169,12 @@ def create_venv(requested_deps, interpreter, is_current, options, pip_options, a installed = {} for repo in requested_deps.keys(): if repo in (REPO_PYPI, REPO_VCS): - mgr = PipManager( - env_bin_path, pip_installed=pip_installed, options=pip_options, - avoid_pip_upgrade=avoid_pip_upgrade) + if use_uv: + mgr = UvManager(env_bin_path, options=uv_pip_options, uv_exe=uv_exe) + else: + mgr = PipManager( + env_bin_path, pip_installed=pip_installed, options=pip_options, + avoid_pip_upgrade=avoid_pip_upgrade) else: logger.warning("Install from %r not implemented", repo) continue diff --git a/fades/file_options.py b/fades/file_options.py index c963b3e..0b6ed53 100644 --- a/fades/file_options.py +++ b/fades/file_options.py @@ -27,7 +27,7 @@ CONFIG_FILES = (Path("/etc/fades/fades.ini"), get_confdir() / 'fades.ini', Path(".fades.ini")) -MERGEABLE_CONFIGS = ("dependency", "pip_options", "venv-options") +MERGEABLE_CONFIGS = ("dependency", "pip_options", "venv-options", "uv_pip_options") def options_from_file(args): diff --git a/fades/helpers.py b/fades/helpers.py index 2a0ccc1..0341b8a 100644 --- a/fades/helpers.py +++ b/fades/helpers.py @@ -19,6 +19,7 @@ import json import logging import os +import shutil import subprocess import sys import tempfile @@ -88,6 +89,17 @@ def logged_exec(cmd): return stdout +def get_uv_exe(uv_path=None): + """Return the path to the ``uv`` binary, or None if it can't be found. + + If ``uv_path`` is given that exact location is validated (it must exist and be executable), + otherwise ``uv`` is looked up in PATH. fades runs against the system Python (it is not + installed inside a venv), so we rely on a ``uv`` *binary* rather than the ``uv`` PyPI package, + which would require polluting the system Python. + """ + return shutil.which(uv_path) if uv_path else shutil.which("uv") + + def _get_basedirectory(): from xdg import BaseDirectory return BaseDirectory diff --git a/fades/main.py b/fades/main.py index 7256691..b174da4 100644 --- a/fades/main.py +++ b/fades/main.py @@ -223,6 +223,33 @@ def detect_inside_virtualenv(prefix, real_prefix, base_prefix): return prefix != base_prefix +def _resolve_uv_backend(args): + """Decide whether to use the uv backend, validating the related options. + + Return a tuple ``(use_uv, uv_exe, uv_pip_options)``. uv is used only when explicitly + requested (``--use-uv``, ``--uv-path`` or ``use_uv`` in a config file). Raise FadesError if + uv is requested but can't be found, or if incompatible options were given. + """ + use_uv = bool(args.use_uv or args.uv_path) + if not use_uv: + return False, None, [] + + uv_exe = helpers.get_uv_exe(args.uv_path) + if not uv_exe: + msg = ("uv was requested (--use-uv) but no usable uv binary was found; install uv or " + "indicate its location with --uv-path") + logger.error(msg) + raise FadesError(msg) + + if args.pip_options or args.venv_options: + msg = ("--pip-options/--venv-options can't be used together with --use-uv; " + "use --uv-pip-options to pass options to uv") + logger.error(msg) + raise FadesError(msg) + + return True, uv_exe, args.uv_pip_options + + def go(): """Make the magic happen.""" parser = argparse.ArgumentParser( @@ -281,6 +308,18 @@ def go(): '--avoid-pip-upgrade', action='store_true', help="disable the automatic pip upgrade that happens after the virtualenv is created " "and before the dependencies begin to be installed.") + parser.add_argument( + '--use-uv', action='store_true', + help="use uv (instead of venv/pip) to create the virtualenv and install dependencies; " + "uv must be available in PATH or indicated with --uv-path.") + parser.add_argument( + '--uv-path', action='store', metavar='UV_PATH', + help="path to the uv executable to use (implies --use-uv); if not given, uv is looked up " + "in PATH.") + parser.add_argument( + '--uv-pip-options', action='append', default=[], + help="extra options to be supplied to 'uv pip install' (this option can be used multiple " + "times); only valid together with --use-uv.") mutexg = parser.add_mutually_exclusive_group() mutexg.add_argument( @@ -399,6 +438,11 @@ def go(): if args.system_site_packages: options['venv_options'].append("--system-site-packages") + # use uv as the backend only when explicitly requested (flag, --uv-path or config) + use_uv, uv_exe, uv_pip_options = _resolve_uv_backend(args) + if use_uv: + logger.debug("Using uv backend found at %r", uv_exe) + create_venv = False venv_data = venvscache.get_venv(indicated_deps, interpreter, uuid, options) if venv_data: @@ -412,8 +456,9 @@ def go(): create_venv = True if create_venv: - # Check if the requested packages exists in pypi. - if not args.no_precheck_availability and indicated_deps.get('pypi'): + # Check if the requested packages exists in pypi. uv resolves directly and fails early + # by itself, so this extra check is skipped when using the uv backend. + if not args.no_precheck_availability and not use_uv and indicated_deps.get('pypi'): logger.info( "Checking the availabilty of dependencies in PyPI. " "You can use '--no-precheck-availability' to avoid it.") @@ -423,7 +468,8 @@ def go(): # Create a new venv venv_data, installed = envbuilder.create_venv( - indicated_deps, args.python, is_current, options, pip_options, args.avoid_pip_upgrade) + indicated_deps, args.python, is_current, options, pip_options, + args.avoid_pip_upgrade, use_uv, uv_exe, uv_pip_options) # store this new venv in the cache venvscache.store(installed, venv_data, interpreter, options) @@ -433,7 +479,9 @@ def go(): return 0 if args.freeze: - # beyond all the rest of work, dump the dependencies versions to a file + # beyond all the rest of work, dump the dependencies versions to a file; all venvs have + # pip available (the uv backend seeds it via 'uv venv --seed'), so a plain pip freeze + # gives a consistent requirements format regardless of the backend used mgr = pipmanager.PipManager(venv_data['env_bin_path']) mgr.freeze(args.freeze) diff --git a/fades/uvmanager.py b/fades/uvmanager.py new file mode 100644 index 0000000..3f23272 --- /dev/null +++ b/fades/uvmanager.py @@ -0,0 +1,86 @@ +# Copyright 2024-2026 Facundo Batista, Nicolás Demarchi +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License version 3, as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranties of +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR +# PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see . +# +# For further info, check https://github.com/PyAr/fades + +"""Interface to handle uv as an alternative backend to pip. + +uv has no supported Python API, so (just like pip) we invoke its binary through subprocess. +It operates on the already-created virtualenv by pointing ``--python`` at the venv's interpreter. +""" + +import logging +from pathlib import Path + +from fades import helpers + +logger = logging.getLogger(__name__) + + +class UvManager(): + """A manager for all uv related actions. + + Exposes the same public interface as ``PipManager`` (install/get_version/freeze) so it can be + used interchangeably as a fades install backend. + """ + + def __init__(self, env_bin_path: Path, options: list = None, uv_exe: str = None): + self.env_bin_path = env_bin_path + self.options = options + # uv parses the '--python' value as a literal path (no Windows PATHEXT resolution like + # subprocess does for pip), so point it at the right interpreter file: 'python' on POSIX, + # 'python.exe' on Windows (mirrors the pip/pip.exe probe in envbuilder.create_env) + python_exe = self.env_bin_path / "python" + if not python_exe.exists() and (self.env_bin_path / "python.exe").exists(): + python_exe = self.env_bin_path / "python.exe" + self.python_exe = python_exe + self.uv_exe = uv_exe or helpers.get_uv_exe() + + def install(self, dependency): + """Install a new dependency into the venv using uv.""" + # split to pass several tokens on multiword dependency (this is very specific for '-e' on + # external requirements, but implemented generically; mirror PipManager.install) + str_dep = str(dependency) + args = [self.uv_exe, "pip", "install", "--python", str(self.python_exe)] + str_dep.split() + + if self.options: + for option in self.options: + args.extend(option.split()) + logger.info("Installing dependency (with uv): %r", str_dep) + try: + helpers.logged_exec(args) + except helpers.ExecutionError as error: + error.dump_to_log(logger) + raise error + except Exception as error: + logger.exception("Error installing %s: %s", str_dep, error) + raise error + + def get_version(self, dependency): + """Return the installed version parsing the output of 'uv pip show'.""" + logger.debug("getting installed version for %s", dependency) + # note: no '--quiet' here, as uv silences the whole 'pip show' output with it (the + # informational "Using Python ... environment" line it emits is harmless, as below we + # only keep lines starting with 'Version:') + stdout = helpers.logged_exec( + [self.uv_exe, "pip", "show", "--python", str(self.python_exe), str(dependency)]) + version = [line for line in stdout if line.startswith('Version:')] + if len(version) == 1: + version = version[0].strip().split()[1] + logger.debug("Installed version of %s is: %s", dependency, version) + return version + else: + logger.error('Fades is having problems getting the installed version. ' + 'Run with -v or check the logs for details') + return '' diff --git a/man/fades.1 b/man/fades.1 index 273ab28..d31b8dd 100644 --- a/man/fades.1 +++ b/man/fades.1 @@ -27,6 +27,9 @@ fades - A system that automatically handles the virtualenvs in the cases normall [\fB--freeze\fR] [\fB-m\fR][\fB--module\fR] [\fB--avoid-pip-upgrade\fR] +[\fB--use-uv\fR] +[\fB--uv-path\fR=\fIUV_PATH\fR] +[\fB--uv-pip-options\fR=\fIoptions\fR] [child_program [child_options]] \fBfades\fR can be used to execute directly your script, or put it with a #! at your script's beginning. @@ -142,6 +145,18 @@ Run library module as a script (same behaviour than Python's \fB-m\fR parameter) .BR --avoid-pip-upgrade Disable the automatic \fBpip\fR upgrade that happens after the virtual environment is created and before the dependencies begin to be installed. +.TP +.BR --use-uv +Use \fBuv\fR (instead of \fBvenv\fR/\fBpip\fR) as the backend to create the virtual environment and install dependencies. This is opt-in: fades uses \fBpip\fR by default even when \fBuv\fR is installed. \fBuv\fR must be available in \fBPATH\fR (or indicated with \fB--uv-path\fR), otherwise fades errors out. It can also be enabled permanently with \fBuse_uv=true\fR in a config file. Not compatible with \fB--pip-options\fR/\fB--venv-options\fR. + +.TP +.BR --uv-path =\fIUV_PATH\fR +Path to the \fBuv\fR executable to use (implies \fB--use-uv\fR). If not given, \fBuv\fR is looked up in \fBPATH\fR. + +.TP +.BR --uv-pip-options =\fIUV_PIP_OPTION\fR +Extra options to be supplied to \fBuv pip install\fR (this option can be used multiple times); only valid together with \fB--use-uv\fR. + .SH EXAMPLES diff --git a/tests/test_envbuilder.py b/tests/test_envbuilder.py index e501396..9e6b3b3 100644 --- a/tests/test_envbuilder.py +++ b/tests/test_envbuilder.py @@ -92,6 +92,48 @@ def test_create_simple(self): avoid_pip_upgrade=avoid_pip_upgrade) self.assertEqual(mock_mgr_c.call_args, expected_pipmanager_call) + def test_create_with_uv_backend(self): + requested = { + REPO_PYPI: [get_req('dep1 == v1')] + } + interpreter = 'python3' + is_current = False + avoid_pip_upgrade = False + options = {"venv_options": []} + pip_options = [] + with patch.object(envbuilder, 'create_with_uv') as mock_create: + with patch.object(envbuilder, 'UvManager') as mock_mgr_c: + mock_create.return_value = ('env_path', 'env_bin_path') + mock_mgr_c.return_value = fake_manager = self.FakeManager() + fake_manager.really_installed = {'dep1': 'v1'} + venv_data, installed = envbuilder.create_venv( + requested, interpreter, is_current, options, pip_options, + avoid_pip_upgrade, use_uv=True, uv_exe='/bin/uv', + uv_pip_options=['--foo']) + + # the uv venv creator was used, and a UvManager (not PipManager) installed the deps with + # the uv-specific options; the seeded venv reports pip as available + mock_create.assert_called_once_with(interpreter, is_current, [], '/bin/uv') + self.assertEqual( + mock_mgr_c.call_args, call('env_bin_path', options=['--foo'], uv_exe='/bin/uv')) + self.assertTrue(venv_data['pip_installed']) + self.assertDictEqual(installed, {REPO_PYPI: {'dep1': 'v1'}}) + + def test_create_with_uv_seeds_and_passes_options(self): + # the uv venv command must seed pip/setuptools and forward the python + venv options + with patch.object(envbuilder.helpers, 'get_basedir', return_value=Path('/base')): + with patch.object(envbuilder.helpers, 'logged_exec') as mock_exec: + with patch.object(envbuilder.helpers, 'get_env_bin_path', + return_value=Path('/base/x/bin')): + envbuilder.create_with_uv( + '/usr/bin/python3', False, ['--system-site-packages'], '/bin/uv') + + cmd = mock_exec.call_args[0][0] + self.assertEqual(cmd[:3], ['/bin/uv', 'venv', '--seed']) + self.assertIn('--python', cmd) + self.assertIn('/usr/bin/python3', cmd) + self.assertIn('--system-site-packages', cmd) + def test_create_vcs(self): requested = { REPO_VCS: [parsing.VCSDependency("someurl")] diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 9b7284c..4e7427d 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -582,3 +582,30 @@ def test_getbinpath_windows(tmp_path): def test_getbinpath_missing(tmp_path): with pytest.raises(ValueError): helpers.get_env_bin_path(tmp_path) + + +class GetUvExeTestCase(unittest.TestCase): + """Tests for the uv binary detection.""" + + def test_uv_present(self): + with patch.object(helpers.shutil, 'which', return_value='/usr/bin/uv') as mock: + result = helpers.get_uv_exe() + assert result == '/usr/bin/uv' + mock.assert_called_once_with('uv') + + def test_uv_absent(self): + with patch.object(helpers.shutil, 'which', return_value=None): + result = helpers.get_uv_exe() + assert result is None + + def test_uv_path_given_is_validated(self): + # an explicit path is looked up (validated) via shutil.which + with patch.object(helpers.shutil, 'which', return_value='/opt/uv') as mock: + result = helpers.get_uv_exe('/opt/uv') + assert result == '/opt/uv' + mock.assert_called_once_with('/opt/uv') + + def test_uv_path_given_not_found(self): + with patch.object(helpers.shutil, 'which', return_value=None): + result = helpers.get_uv_exe('/no/such/uv') + assert result is None diff --git a/tests/test_main.py b/tests/test_main.py index 9e1d5bf..49aa5bd 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -47,6 +47,61 @@ def test_prefix_is_not_baseprefix(self): self.assertTrue(resp) +def _uv_args(**kwargs): + """Build a minimal args namespace for _resolve_uv_backend.""" + from argparse import Namespace + base = dict(use_uv=False, uv_path=None, pip_options=[], venv_options=[], uv_pip_options=[]) + base.update(kwargs) + return Namespace(**base) + + +class ResolveUvBackendTestCase(unittest.TestCase): + """Tests for the opt-in uv backend resolution.""" + + def test_disabled_by_default(self): + use_uv, uv_exe, uv_pip_options = main._resolve_uv_backend(_uv_args()) + self.assertFalse(use_uv) + self.assertIsNone(uv_exe) + self.assertEqual(uv_pip_options, []) + + def test_enabled_finds_uv_in_path(self): + with patch.object(main.helpers, 'get_uv_exe', return_value='/usr/bin/uv'): + use_uv, uv_exe, uv_pip_options = main._resolve_uv_backend( + _uv_args(use_uv=True, uv_pip_options=['--foo'])) + self.assertTrue(use_uv) + self.assertEqual(uv_exe, '/usr/bin/uv') + self.assertEqual(uv_pip_options, ['--foo']) + + def test_uv_path_implies_use_uv_and_is_validated(self): + # --uv-path alone enables uv; the path is validated via get_uv_exe (must exist) + with patch.object(main.helpers, 'get_uv_exe', + side_effect=lambda p=None: '/opt/uv' if p == '/opt/uv' else None): + use_uv, uv_exe, _ = main._resolve_uv_backend(_uv_args(uv_path='/opt/uv')) + self.assertTrue(use_uv) + self.assertEqual(uv_exe, '/opt/uv') + + def test_uv_path_not_found(self): + # a bogus --uv-path that doesn't resolve to an executable is an error + with patch.object(main.helpers, 'get_uv_exe', return_value=None): + with self.assertRaises(FadesError): + main._resolve_uv_backend(_uv_args(uv_path='/no/such/uv')) + + def test_enabled_but_uv_not_found(self): + with patch.object(main.helpers, 'get_uv_exe', return_value=None): + with self.assertRaises(FadesError): + main._resolve_uv_backend(_uv_args(use_uv=True)) + + def test_pip_options_conflict(self): + with patch.object(main.helpers, 'get_uv_exe', return_value='/usr/bin/uv'): + with self.assertRaises(FadesError): + main._resolve_uv_backend(_uv_args(use_uv=True, pip_options=['--foo'])) + + def test_venv_options_conflict(self): + with patch.object(main.helpers, 'get_uv_exe', return_value='/usr/bin/uv'): + with self.assertRaises(FadesError): + main._resolve_uv_backend(_uv_args(use_uv=True, venv_options=['--symlinks'])) + + class DepsGatheringTestCase(unittest.TestCase): """Tests for the gathering stage of consolidate_dependencies.""" diff --git a/tests/test_uvmanager.py b/tests/test_uvmanager.py new file mode 100644 index 0000000..9945f06 --- /dev/null +++ b/tests/test_uvmanager.py @@ -0,0 +1,125 @@ +# Copyright 2024-2026 Facundo Batista, Nicolás Demarchi +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License version 3, as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranties of +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR +# PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see . +# +# For further info, check https://github.com/PyAr/fades + +"""Tests for uv related code.""" + +import pytest +from pathlib import Path +from unittest.mock import patch + +from fades.uvmanager import UvManager +from fades import helpers + +BIN_PATH = Path("somepath") +UV = "uv" +PYTHON_PATH = str(Path(BIN_PATH) / "python") + + +def get_manager(): + return UvManager(BIN_PATH, uv_exe=UV) + + +def test_uv_exe_autodetected(): + with patch.object(helpers, "get_uv_exe", return_value="/path/to/uv"): + mgr = UvManager(BIN_PATH) + assert mgr.uv_exe == "/path/to/uv" + + +def test_get_parsing_ok(): + mocked_stdout = [ + "Name: foo", + "Version: 2.0.0", + "Location: ~/.local/share/fades/86cc492/lib/python3.4/site-packages", + "Requires: ", + ] + mgr = get_manager() + with patch.object(helpers, "logged_exec", return_value=mocked_stdout) as mock: + version = mgr.get_version("foo") + assert version == "2.0.0" + mock.assert_called_with( + [UV, "pip", "show", "--python", PYTHON_PATH, "foo"]) + + +def test_get_parsing_error(logs): + mocked_stdout = [ + "Name: foo", + "Release: 2.0.0", + "Location: ~/.local/share/fades/86cc492/lib/python3.4/site-packages", + "Requires: ", + ] + mgr = get_manager() + with patch.object(helpers, "logged_exec", return_value=mocked_stdout): + version = mgr.get_version("foo") + + assert version == "" + assert ( + 'Fades is having problems getting the installed version. ' + 'Run with -v or check the logs for details' + ) in logs.error + + +def test_install(): + mgr = get_manager() + with patch.object(helpers, "logged_exec") as mock: + mgr.install("foo") + mock.assert_called_with([UV, "pip", "install", "--python", PYTHON_PATH, "foo"]) + + +def test_install_multiword_dependency(): + mgr = get_manager() + with patch.object(helpers, "logged_exec") as mock: + mgr.install("foo bar") + mock.assert_called_with([UV, "pip", "install", "--python", PYTHON_PATH, "foo", "bar"]) + + +def test_install_with_options(): + mgr = UvManager(BIN_PATH, options=["--bar baz"], uv_exe=UV) + with patch.object(helpers, "logged_exec") as mock: + mgr.install("foo") + mock.assert_called_with( + [UV, "pip", "install", "--python", PYTHON_PATH, "foo", "--bar", "baz"]) + + +def test_install_with_options_using_equal(): + mgr = UvManager(BIN_PATH, options=["--bar=baz"], uv_exe=UV) + with patch.object(helpers, "logged_exec") as mock: + mgr.install("foo") + mock.assert_called_with( + [UV, "pip", "install", "--python", PYTHON_PATH, "foo", "--bar=baz"]) + + +def test_install_raise_error(logs): + mgr = get_manager() + with patch.object(helpers, "logged_exec", side_effect=Exception("Kapow!")): + with pytest.raises(Exception): + mgr.install("foo") + + assert "Error installing foo: Kapow!" in logs.error + + +def test_python_exe_resolves_windows_exe(tmp_path): + # on Windows the venv interpreter is 'python.exe' (no extension-less 'python'); UvManager + # must point uv's --python at the file that actually exists + (tmp_path / "python.exe").touch() + mgr = UvManager(tmp_path, uv_exe=UV) + assert mgr.python_exe == tmp_path / "python.exe" + + +def test_python_exe_defaults_to_python(tmp_path): + # POSIX (and the mocked tests above): plain 'python' is used + (tmp_path / "python").touch() + mgr = UvManager(tmp_path, uv_exe=UV) + assert mgr.python_exe == tmp_path / "python"