From 8759be66b0da76ae9361f17a288af36b2576cfff Mon Sep 17 00:00:00 2001 From: gilgamezh Date: Sat, 20 Jun 2026 12:48:05 +0200 Subject: [PATCH 1/9] Add optional uv backend for venv creation and dependency install When a `uv` binary is found in PATH, fades now uses it as a faster backend to create the virtualenv (`uv venv`) and install dependencies (`uv pip install`), instead of stdlib `venv` + `pip`. Detection is via `helpers.get_uv_exe()` (shutil.which); fades does not depend on the `uv` PyPI package, since it runs against the system Python. The new `UvManager` mirrors `PipManager`'s interface (install/get_version/ freeze) so it plugs into the existing dispatch in `envbuilder.create_venv`. A `--no-uv` flag forces the classic pip backend even when uv is available. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/integtests.yaml | 20 +++++ README.rst | 12 +++ fades/envbuilder.py | 52 ++++++++++-- fades/helpers.py | 11 +++ fades/main.py | 22 +++++- fades/uvmanager.py | 88 +++++++++++++++++++++ man/fades.1 | 5 ++ tests/test_envbuilder.py | 44 +++++++++++ tests/test_helpers.py | 15 ++++ tests/test_uvmanager.py | 126 ++++++++++++++++++++++++++++++ 10 files changed, 386 insertions(+), 9 deletions(-) create mode 100644 fades/uvmanager.py create mode 100644 tests/test_uvmanager.py diff --git a/.github/workflows/integtests.yaml b/.github/workflows/integtests.yaml index cd1b305..d3d984f 100644 --- a/.github/workflows/integtests.yaml +++ b/.github/workflows/integtests.yaml @@ -46,6 +46,16 @@ jobs: run: | cd /fades bin/fades -v -d pytest -x pytest --version + - name: uv backend is auto-detected and used + run: | + cd /fades + # with uv on PATH, fades must pick the uv backend (assert via the verbose log) + bin/fades -v -d six -x python -c "import six" 2>&1 | grep -q "Using uv backend" + - name: pip backend still works with --no-uv + run: | + cd /fades + # even with uv present, --no-uv must fall back to pip (no uv-backend log line) + ! bin/fades -v --no-uv -d six -x python -c "import six" 2>&1 | grep -q "Using uv backend" - name: Using the oldest supported Python env: OLDEST: ${{ needs.min-python.outputs.version }} @@ -74,6 +84,16 @@ jobs: run: | cd /fades bin/fades -v -d pytest -x pytest --version + - name: uv backend is auto-detected and used + run: | + cd /fades + # with uv on PATH, fades must pick the uv backend (assert via the verbose log) + bin/fades -v -d six -x python -c "import six" 2>&1 | grep -q "Using uv backend" + - name: pip backend still works with --no-uv + run: | + cd /fades + # even with uv present, --no-uv must fall back to pip (no uv-backend log line) + ! bin/fades -v --no-uv -d six -x python -c "import six" 2>&1 | grep -q "Using uv backend" - name: Using the oldest supported Python env: OLDEST: ${{ needs.min-python.outputs.version }} diff --git a/README.rst b/README.rst index 55d30ec..99abbd6 100644 --- a/README.rst +++ b/README.rst @@ -391,6 +391,18 @@ Examples: ``fades --python-options=-B foo.py`` +Using uv as backend +------------------- + +If a `uv `_ binary is found in ``PATH``, fades automatically uses it as a faster backend to create the virtual environment and install the dependencies (instead of ``venv`` and ``pip``). Nothing else changes: fades keeps doing its own dependency parsing, venv indexing, garbage collection, etc. + +uv is detected as a binary in ``PATH`` (installed via your system package manager, the standalone installer, etc.); fades does not depend on the ``uv`` PyPI package. + +If you want to force the classic ``pip`` backend even when uv is available, use ``--no-uv``: + +``fades --no-uv -d requests -x python`` + + Setting options using config files ---------------------------------- diff --git a/fades/envbuilder.py b/fades/envbuilder.py index 7c11a94..07ef36f 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,46 @@ 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): +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 + args = [uv_exe, "venv", 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): """Create a new virtualvenv with the requirements of this script.""" + # decide the backend; only use uv if requested *and* actually available + uv_exe = helpers.get_uv_exe() if use_uv else None + use_uv = uv_exe is not None + # 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) + pip_installed = False + 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 +166,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=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/helpers.py b/fades/helpers.py index 2a0ccc1..b9367e5 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,16 @@ def logged_exec(cmd): return stdout +def get_uv_exe(): + """Return the path to the ``uv`` binary if available on PATH, else None. + + fades runs against the system Python (it is not installed inside a venv), so we rely on a + ``uv`` *binary* found in PATH rather than the ``uv`` PyPI package, which would require + polluting the system Python. + """ + return shutil.which("uv") + + def _get_basedirectory(): from xdg import BaseDirectory return BaseDirectory diff --git a/fades/main.py b/fades/main.py index 7256691..772201e 100644 --- a/fades/main.py +++ b/fades/main.py @@ -37,6 +37,7 @@ parsing, pipmanager, pkgnamesdb, + uvmanager, ) from fades.logger import set_up as logger_set_up @@ -281,6 +282,10 @@ 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( + '--no-uv', action='store_true', + help="don't use uv as the backend to create the virtualenv and install dependencies, " + "even if a uv binary is available in PATH (use pip instead).") mutexg = parser.add_mutually_exclusive_group() mutexg.add_argument( @@ -399,6 +404,12 @@ def go(): if args.system_site_packages: options['venv_options'].append("--system-site-packages") + # use uv as the backend when it's available and the user didn't opt out + uv_exe = None if args.no_uv else helpers.get_uv_exe() + use_uv = uv_exe is not None + 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: @@ -423,7 +434,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) # store this new venv in the cache venvscache.store(installed, venv_data, interpreter, options) @@ -433,8 +445,12 @@ def go(): return 0 if args.freeze: - # beyond all the rest of work, dump the dependencies versions to a file - mgr = pipmanager.PipManager(venv_data['env_bin_path']) + # beyond all the rest of work, dump the dependencies versions to a file; use the uv + # backend when active (a uv-built venv has no pip to run 'pip freeze' with) + if use_uv: + mgr = uvmanager.UvManager(venv_data['env_bin_path'], uv_exe=uv_exe) + else: + mgr = pipmanager.PipManager(venv_data['env_bin_path']) mgr.freeze(args.freeze) # run forest run!! diff --git a/fades/uvmanager.py b/fades/uvmanager.py new file mode 100644 index 0000000..b0265df --- /dev/null +++ b/fades/uvmanager.py @@ -0,0 +1,88 @@ +# 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 + self.python_exe = self.env_bin_path / "python" + 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 '' + + def freeze(self, filepath: Path): + """Dump venv contents to the indicated filepath.""" + logger.debug("running freeze to store in %r", filepath) + stdout = helpers.logged_exec( + [self.uv_exe, "pip", "freeze", "--quiet", "--python", str(self.python_exe)]) + with open(filepath, "wt", encoding='utf8') as fh: + fh.writelines(line + '\n' for line in sorted(stdout)) diff --git a/man/fades.1 b/man/fades.1 index 273ab28..28bf117 100644 --- a/man/fades.1 +++ b/man/fades.1 @@ -27,6 +27,7 @@ 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--no-uv\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 +143,10 @@ 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 --no-uv +Don't use \fBuv\fR as the backend to create the virtual environment and install dependencies, even if a \fBuv\fR binary is available in \fBPATH\fR (use \fBpip\fR instead). By default, when a \fBuv\fR binary is found in \fBPATH\fR, fades uses it as a faster backend. + .SH EXAMPLES diff --git a/tests/test_envbuilder.py b/tests/test_envbuilder.py index e501396..d45302c 100644 --- a/tests/test_envbuilder.py +++ b/tests/test_envbuilder.py @@ -92,6 +92,50 @@ 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.helpers, 'get_uv_exe', return_value='/bin/uv'): + 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) + + # the uv venv creator was used, and a UvManager (not PipManager) installed the deps + mock_create.assert_called_once_with(interpreter, is_current, [], '/bin/uv') + self.assertEqual( + mock_mgr_c.call_args, call('env_bin_path', options=[], uv_exe='/bin/uv')) + self.assertDictEqual(installed, {REPO_PYPI: {'dep1': 'v1'}}) + + def test_create_uv_requested_but_unavailable_falls_back_to_pip(self): + requested = { + REPO_PYPI: [get_req('dep1 == v1')] + } + options = {"venv_options": []} + with patch.object(envbuilder.helpers, 'get_uv_exe', return_value=None): + with patch.object(envbuilder._FadesEnvBuilder, 'create_env') as mock_create: + with patch.object(envbuilder, 'PipManager') as mock_mgr_c: + with patch.object(envbuilder, 'UvManager') as mock_uv_c: + mock_create.return_value = ('env_path', 'env_bin_path', 'pip_installed') + mock_mgr_c.return_value = fake_manager = self.FakeManager() + fake_manager.really_installed = {'dep1': 'v1'} + envbuilder.create_venv( + requested, 'python3', True, options, [], False, use_uv=True) + + # uv was requested but not present: pip backend used instead + self.assertTrue(mock_mgr_c.called) + self.assertFalse(mock_uv_c.called) + 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..3a30599 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -582,3 +582,18 @@ 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 diff --git a/tests/test_uvmanager.py b/tests/test_uvmanager.py new file mode 100644 index 0000000..9e2df0a --- /dev/null +++ b/tests/test_uvmanager.py @@ -0,0 +1,126 @@ +# 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_freeze(tmp_path): + tmp_file = tmp_path / "reqtest.txt" + + mgr = get_manager() + with patch.object(helpers, "logged_exec") as mock: + mock.return_value = ['moño>11', 'foo==1.2'] # "bad" order, on purpose + mgr.freeze(tmp_file) + + mock.assert_called_with([UV, "pip", "freeze", "--quiet", "--python", PYTHON_PATH]) + + # check results were stored properly (sorted) + with open(tmp_file, 'rt', encoding='utf8') as fh: + stored = fh.read() + assert stored == 'foo==1.2\nmoño>11\n' From 00592a97e8a9e2b6a57fb040a4d00452ae1b9a6f Mon Sep 17 00:00:00 2001 From: gilgamezh Date: Sat, 20 Jun 2026 13:08:05 +0200 Subject: [PATCH 2/9] django changed the executable name --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 99abbd6..c1a8aa7 100644 --- a/README.rst +++ b/README.rst @@ -491,9 +491,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:: From 2a6dc3a3903c19d7aaaa17e8c605441f7e48e15d Mon Sep 17 00:00:00 2001 From: gilgamezh Date: Sat, 20 Jun 2026 13:35:08 +0200 Subject: [PATCH 3/9] Address review findings on the uv backend - freeze: drop `uv pip freeze --quiet`, which empties the whole output on some uv versions (producing a 0-byte freeze file); filter uv's info line instead, mirroring get_version. - freeze: pick the backend from the venv's `pip_installed` metadata, not the current run's flags. A uv-built venv has no pip, and the cache key doesn't include the backend, so it can be reused under --no-uv / when uv is gone; PipManager.freeze would then crash on a missing pip. - uv `--python`: probe for `python` / `python.exe` so the uv backend works on Windows (uv treats --python as a literal path, no PATHEXT resolution). - main: fall back to the pip backend when `--venv-options` contains flags `uv venv` doesn't accept (e.g. --symlinks), keeping documented usage working. - CI: make the --no-uv check also assert fades' own exit status (a crash could silently pass the negated grep), and add a --freeze regression step. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/integtests.yaml | 28 ++++++++++++++++++++++++---- fades/main.py | 27 ++++++++++++++++++++++----- fades/uvmanager.py | 16 +++++++++++++--- tests/test_uvmanager.py | 24 +++++++++++++++++++++--- 4 files changed, 80 insertions(+), 15 deletions(-) diff --git a/.github/workflows/integtests.yaml b/.github/workflows/integtests.yaml index d3d984f..5d99bc6 100644 --- a/.github/workflows/integtests.yaml +++ b/.github/workflows/integtests.yaml @@ -54,8 +54,18 @@ jobs: - name: pip backend still works with --no-uv run: | cd /fades - # even with uv present, --no-uv must fall back to pip (no uv-backend log line) - ! bin/fades -v --no-uv -d six -x python -c "import six" 2>&1 | grep -q "Using uv backend" + # fades itself must succeed (so a --no-uv crash can't silently pass the negated grep), + # and the uv-backend log line must be absent + bin/fades -v --no-uv -d six -x python -c "import six" > nouv.log 2>&1 + ! grep -q "Using uv backend" nouv.log + - name: --freeze works on a uv-built venv (even with --no-uv) + run: | + cd /fades + # the 'six' venv was built by uv above (no pip); freezing it must still produce a + # non-empty pinned file -- fades has to pick the freeze backend from the venv, not the + # --no-uv flag, and must not silence the freeze output + bin/fades -v --no-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 }} @@ -92,8 +102,18 @@ jobs: - name: pip backend still works with --no-uv run: | cd /fades - # even with uv present, --no-uv must fall back to pip (no uv-backend log line) - ! bin/fades -v --no-uv -d six -x python -c "import six" 2>&1 | grep -q "Using uv backend" + # fades itself must succeed (so a --no-uv crash can't silently pass the negated grep), + # and the uv-backend log line must be absent + bin/fades -v --no-uv -d six -x python -c "import six" > nouv.log 2>&1 + ! grep -q "Using uv backend" nouv.log + - name: --freeze works on a uv-built venv (even with --no-uv) + run: | + cd /fades + # the 'six' venv was built by uv above (no pip); freezing it must still produce a + # non-empty pinned file -- fades has to pick the freeze backend from the venv, not the + # --no-uv flag, and must not silence the freeze output + bin/fades -v --no-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 }} diff --git a/fades/main.py b/fades/main.py index 772201e..ce6f9b0 100644 --- a/fades/main.py +++ b/fades/main.py @@ -407,6 +407,16 @@ def go(): # use uv as the backend when it's available and the user didn't opt out uv_exe = None if args.no_uv else helpers.get_uv_exe() use_uv = uv_exe is not None + if use_uv: + # 'uv venv' only understands a subset of the stdlib venv flags (e.g. it rejects + # --symlinks/--copies/--without-pip), so if the user passed venv options beyond + # --system-site-packages, fall back to pip to keep those working + unsupported = [o for o in options['venv_options'] if o != "--system-site-packages"] + if unsupported: + logger.info( + "Using pip backend instead of uv: 'uv venv' does not support %s", unsupported) + use_uv = False + uv_exe = None if use_uv: logger.debug("Using uv backend found at %r", uv_exe) @@ -445,12 +455,19 @@ def go(): return 0 if args.freeze: - # beyond all the rest of work, dump the dependencies versions to a file; use the uv - # backend when active (a uv-built venv has no pip to run 'pip freeze' with) - if use_uv: - mgr = uvmanager.UvManager(venv_data['env_bin_path'], uv_exe=uv_exe) - else: + # beyond all the rest of work, dump the dependencies versions to a file. Decide the + # freeze backend from the venv itself, not the current run's flags: a venv built by uv + # has no pip to run 'pip freeze' with, and it may be reused on a later run where uv is + # absent or --no-uv was passed (the cache key doesn't include the backend) + if venv_data.get('pip_installed'): mgr = pipmanager.PipManager(venv_data['env_bin_path']) + else: + freeze_uv = uv_exe or helpers.get_uv_exe() + if freeze_uv is None: + raise FadesError( + "Cannot freeze: this venv was built with uv (no pip) but no uv binary is " + "available; install uv or recreate the venv with --no-uv") + mgr = uvmanager.UvManager(venv_data['env_bin_path'], uv_exe=freeze_uv) mgr.freeze(args.freeze) # run forest run!! diff --git a/fades/uvmanager.py b/fades/uvmanager.py index b0265df..785edef 100644 --- a/fades/uvmanager.py +++ b/fades/uvmanager.py @@ -38,7 +38,13 @@ class UvManager(): def __init__(self, env_bin_path: Path, options: list = None, uv_exe: str = None): self.env_bin_path = env_bin_path self.options = options - self.python_exe = self.env_bin_path / "python" + # 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): @@ -82,7 +88,11 @@ def get_version(self, dependency): def freeze(self, filepath: Path): """Dump venv contents to the indicated filepath.""" logger.debug("running freeze to store in %r", filepath) + # note: no '--quiet' here; depending on the uv version it silences the *whole* freeze + # output (producing an empty file), so instead we drop uv's informational + # "Using Python ... environment at:" line explicitly stdout = helpers.logged_exec( - [self.uv_exe, "pip", "freeze", "--quiet", "--python", str(self.python_exe)]) + [self.uv_exe, "pip", "freeze", "--python", str(self.python_exe)]) + lines = [line for line in stdout if not line.startswith("Using Python")] with open(filepath, "wt", encoding='utf8') as fh: - fh.writelines(line + '\n' for line in sorted(stdout)) + fh.writelines(line + '\n' for line in sorted(lines)) diff --git a/tests/test_uvmanager.py b/tests/test_uvmanager.py index 9e2df0a..3d840bf 100644 --- a/tests/test_uvmanager.py +++ b/tests/test_uvmanager.py @@ -115,12 +115,30 @@ def test_freeze(tmp_path): mgr = get_manager() with patch.object(helpers, "logged_exec") as mock: - mock.return_value = ['moño>11', 'foo==1.2'] # "bad" order, on purpose + # include uv's informational header line; it must be filtered out (not '--quiet'-ed, + # which would empty the whole output on some uv versions) + mock.return_value = [ + 'Using Python 3.13.1 environment at: /tmp/x', 'moño>11', 'foo==1.2'] mgr.freeze(tmp_file) - mock.assert_called_with([UV, "pip", "freeze", "--quiet", "--python", PYTHON_PATH]) + mock.assert_called_with([UV, "pip", "freeze", "--python", PYTHON_PATH]) - # check results were stored properly (sorted) + # check results were stored properly (sorted, info line dropped) with open(tmp_file, 'rt', encoding='utf8') as fh: stored = fh.read() assert stored == 'foo==1.2\nmoño>11\n' + + +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" From b93fcd1a2b06967a8e7946d62a51ebc847cf4f6c Mon Sep 17 00:00:00 2001 From: gilgamezh Date: Sat, 20 Jun 2026 13:39:43 +0200 Subject: [PATCH 4/9] CI: exercise the uv backend on Windows Install uv in the native-windows job and add the uv auto-detect, --no-uv, and --freeze checks there. This confirms the uv path works on Windows, where the venv interpreter is Scripts\python.exe (regression coverage for the uv --python resolution). The grep-based steps run under bash (Git Bash), since the Windows runner defaults to PowerShell. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/integtests.yaml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/integtests.yaml b/.github/workflows/integtests.yaml index 5d99bc6..4447253 100644 --- a/.github/workflows/integtests.yaml +++ b/.github/workflows/integtests.yaml @@ -151,11 +151,29 @@ jobs: - name: Install dependencies run: | - ${{ steps.matrixpy.outputs.python-path }} -m pip install -U packaging + ${{ steps.matrixpy.outputs.python-path }} -m pip install -U packaging uv - name: Simple fades run run: | ${{ steps.matrixpy.outputs.python-path }} bin/fades -v -d pytest -x pytest --version + - name: uv backend is auto-detected and used (Windows) + 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. + ${{ steps.matrixpy.outputs.python-path }} bin/fades -v -d six -x python -c "import six" 2>&1 | grep -q "Using uv backend" + - name: pip backend still works with --no-uv (Windows) + shell: bash + run: | + ${{ steps.matrixpy.outputs.python-path }} bin/fades -v --no-uv -d six -x python -c "import six" > nouv.log 2>&1 + ! grep -q "Using uv backend" nouv.log + - name: --freeze works on a uv-built venv (Windows) + shell: bash + run: | + ${{ steps.matrixpy.outputs.python-path }} bin/fades -v --no-uv -d six --freeze=frozen.txt -x python -c "import six" + 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 From af1bda7824d3ea20b25da0d63a22f38e845120ee Mon Sep 17 00:00:00 2001 From: gilgamezh Date: Sat, 20 Jun 2026 13:43:05 +0200 Subject: [PATCH 5/9] CI: use setup-uv on Windows and add uv-path diagnostics pip-installed uv wasn't reliably on PATH for fades' shutil.which on the Windows runner, so the auto-detect step couldn't confirm the uv backend. Use the official astral-sh/setup-uv action to guarantee uv on PATH, and capture + print fades output so failures in the uv path are diagnosable. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/integtests.yaml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/integtests.yaml b/.github/workflows/integtests.yaml index 4447253..db9db42 100644 --- a/.github/workflows/integtests.yaml +++ b/.github/workflows/integtests.yaml @@ -151,7 +151,9 @@ jobs: - name: Install dependencies run: | - ${{ steps.matrixpy.outputs.python-path }} -m pip install -U packaging uv + ${{ 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 @@ -162,7 +164,10 @@ jobs: # 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. - ${{ steps.matrixpy.outputs.python-path }} bin/fades -v -d six -x python -c "import six" 2>&1 | grep -q "Using uv backend" + which uv && uv --version + ${{ steps.matrixpy.outputs.python-path }} bin/fades -v -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: pip backend still works with --no-uv (Windows) shell: bash run: | @@ -171,7 +176,8 @@ jobs: - name: --freeze works on a uv-built venv (Windows) shell: bash run: | - ${{ steps.matrixpy.outputs.python-path }} bin/fades -v --no-uv -d six --freeze=frozen.txt -x python -c "import six" + ${{ steps.matrixpy.outputs.python-path }} bin/fades -v --no-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 From 85db37122bc73e720b954b9fe90373c80763c30a Mon Sep 17 00:00:00 2001 From: gilgamezh Date: Sat, 20 Jun 2026 13:46:19 +0200 Subject: [PATCH 6/9] CI: fix Windows python path mangling in bash uv steps The Windows interpreter path was interpolated raw into the bash steps, where unquoted backslashes were stripped (command not found). Single-quote it and convert backslashes to forward slashes (accepted by Git Bash) so the uv path is actually exercised on Windows. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/integtests.yaml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/integtests.yaml b/.github/workflows/integtests.yaml index db9db42..4f58f9b 100644 --- a/.github/workflows/integtests.yaml +++ b/.github/workflows/integtests.yaml @@ -165,18 +165,23 @@ jobs: # \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 - ${{ steps.matrixpy.outputs.python-path }} bin/fades -v -d six -x python -c "import six" > uv.log 2>&1 || { cat uv.log; exit 1; } + # 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 -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: pip backend still works with --no-uv (Windows) shell: bash run: | - ${{ steps.matrixpy.outputs.python-path }} bin/fades -v --no-uv -d six -x python -c "import six" > nouv.log 2>&1 + PY='${{ steps.matrixpy.outputs.python-path }}'; PY="${PY//\\//}" + "$PY" bin/fades -v --no-uv -d six -x python -c "import six" > nouv.log 2>&1 ! grep -q "Using uv backend" nouv.log - name: --freeze works on a uv-built venv (Windows) shell: bash run: | - ${{ steps.matrixpy.outputs.python-path }} bin/fades -v --no-uv -d six --freeze=frozen.txt -x python -c "import six" > freeze.log 2>&1 || { cat freeze.log; exit 1; } + PY='${{ steps.matrixpy.outputs.python-path }}'; PY="${PY//\\//}" + "$PY" bin/fades -v --no-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 From 9ab2790aecffa9e8f10af2318bf940915ac07bec Mon Sep 17 00:00:00 2001 From: gilgamezh Date: Sat, 20 Jun 2026 14:11:27 +0200 Subject: [PATCH 7/9] uv: seed pip/setuptools into the venv (uv venv --seed) uv seeds nothing by default, so uv-built venvs lacked pip and setuptools that the classic 'python -m venv' backend provides, breaking packages that expect them present. Pass --seed so the venv ships pip (+ setuptools, per the Python version, matching stdlib venv) at negligible cost. Note: this does not restore pkg_resources for packages like azure-cli, since uv seeds a recent setuptools (>=81) that removed it -- the same is true for the pip backend on Python 3.12+. Documented the 'setuptools<81' workaround. Co-Authored-By: Claude Opus 4.8 --- README.rst | 8 ++++++++ fades/envbuilder.py | 5 ++++- tests/test_envbuilder.py | 15 +++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index c1a8aa7..81f2b37 100644 --- a/README.rst +++ b/README.rst @@ -398,10 +398,18 @@ If a `uv `_ binary is found in ``PATH``, fades uv is detected as a binary in ``PATH`` (installed via your system package manager, the standalone installer, etc.); fades does not depend on the ``uv`` PyPI package. +fades runs ``uv venv --seed``, so the virtual environment ships ``pip`` and ``setuptools`` just like the classic ``venv`` backend, keeping packages that expect them present working. + If you want to force the classic ``pip`` backend even when uv is available, use ``--no-uv``: ``fades --no-uv -d requests -x python`` +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 -d azure-cli -d "setuptools<81" -x az --help + +(or use ``--no-uv`` on a Python version old enough to seed an old setuptools). + Setting options using config files ---------------------------------- diff --git a/fades/envbuilder.py b/fades/envbuilder.py index 07ef36f..f437617 100644 --- a/fades/envbuilder.py +++ b/fades/envbuilder.py @@ -125,7 +125,10 @@ def create_with_uv(interpreter, is_current, venv_options, uv_exe): # 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 - args = [uv_exe, "venv", str(env_path), "--python", str(python)] + # '--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: diff --git a/tests/test_envbuilder.py b/tests/test_envbuilder.py index d45302c..bb4e431 100644 --- a/tests/test_envbuilder.py +++ b/tests/test_envbuilder.py @@ -136,6 +136,21 @@ def test_create_uv_requested_but_unavailable_falls_back_to_pip(self): self.assertTrue(mock_mgr_c.called) self.assertFalse(mock_uv_c.called) + 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")] From 7b47250878353ed8f9be927be078a7d9452e2734 Mon Sep 17 00:00:00 2001 From: gilgamezh Date: Sat, 20 Jun 2026 16:15:30 +0200 Subject: [PATCH 8/9] Make the uv backend opt-in instead of the default Per discussion on #451, uv is no longer auto-detected/used just because it's on PATH (it produces different venvs than pip, so behaviour shouldn't change based on an external binary's presence). uv is now explicit: - `--use-uv` enables it; `--uv-path PATH` points at a specific uv (implies --use-uv); without a path uv is looked up in PATH and validated. If uv is requested but not found, fades errors out. - `use_uv=true` in any fades config file enables it permanently (machine/user/ repo `.fades.ini`); a CLI flag still wins. - `--pip-options`/`--venv-options` together with --use-uv is an error; new `--uv-pip-options` passes options to `uv pip install`. - the PyPI availability pre-check is skipped under uv (uv resolves directly). - `--freeze` uses plain pip freeze (uv venvs are seeded with pip), dropping the uv-specific freeze code. Detection/resolution lives in main._resolve_uv_backend (unit-tested); README and man page document the opt-in model and how to enable it always via config. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/integtests.yaml | 74 +++++++++++++++------------- README.rst | 32 +++++++++--- fades/envbuilder.py | 14 +++--- fades/file_options.py | 2 +- fades/helpers.py | 13 ++--- fades/main.py | 81 ++++++++++++++++++------------- fades/uvmanager.py | 12 ----- man/fades.1 | 16 ++++-- tests/test_envbuilder.py | 45 ++++++----------- tests/test_helpers.py | 12 +++++ tests/test_main.py | 55 +++++++++++++++++++++ tests/test_uvmanager.py | 19 -------- 12 files changed, 222 insertions(+), 153 deletions(-) diff --git a/.github/workflows/integtests.yaml b/.github/workflows/integtests.yaml index 4f58f9b..d891b53 100644 --- a/.github/workflows/integtests.yaml +++ b/.github/workflows/integtests.yaml @@ -46,25 +46,29 @@ jobs: run: | cd /fades bin/fades -v -d pytest -x pytest --version - - name: uv backend is auto-detected and used + - name: default backend is pip (uv NOT used without opt-in) run: | cd /fades - # with uv on PATH, fades must pick the uv backend (assert via the verbose log) - bin/fades -v -d six -x python -c "import six" 2>&1 | grep -q "Using uv backend" - - name: pip backend still works with --no-uv + # 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 - # fades itself must succeed (so a --no-uv crash can't silently pass the negated grep), - # and the uv-backend log line must be absent - bin/fades -v --no-uv -d six -x python -c "import six" > nouv.log 2>&1 - ! grep -q "Using uv backend" nouv.log - - name: --freeze works on a uv-built venv (even with --no-uv) + 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 - # the 'six' venv was built by uv above (no pip); freezing it must still produce a - # non-empty pinned file -- fades has to pick the freeze backend from the venv, not the - # --no-uv flag, and must not silence the freeze output - bin/fades -v --no-uv -d six --freeze=frozen.txt -x python -c "import six" + ! 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: @@ -94,25 +98,29 @@ jobs: run: | cd /fades bin/fades -v -d pytest -x pytest --version - - name: uv backend is auto-detected and used + - 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 - # with uv on PATH, fades must pick the uv backend (assert via the verbose log) - bin/fades -v -d six -x python -c "import six" 2>&1 | grep -q "Using uv backend" - - name: pip backend still works with --no-uv + ! 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 - # fades itself must succeed (so a --no-uv crash can't silently pass the negated grep), - # and the uv-backend log line must be absent - bin/fades -v --no-uv -d six -x python -c "import six" > nouv.log 2>&1 - ! grep -q "Using uv backend" nouv.log - - name: --freeze works on a uv-built venv (even with --no-uv) + ! 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 - # the 'six' venv was built by uv above (no pip); freezing it must still produce a - # non-empty pinned file -- fades has to pick the freeze backend from the venv, not the - # --no-uv flag, and must not silence the freeze output - bin/fades -v --no-uv -d six --freeze=frozen.txt -x python -c "import six" + # 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: @@ -158,7 +166,7 @@ jobs: run: | ${{ steps.matrixpy.outputs.python-path }} bin/fades -v -d pytest -x pytest --version - - name: uv backend is auto-detected and used (Windows) + - 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 @@ -168,20 +176,20 @@ jobs: # 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 -d six -x python -c "import six" > uv.log 2>&1 || { cat uv.log; exit 1; } + "$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: pip backend still works with --no-uv (Windows) + - 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 --no-uv -d six -x python -c "import six" > nouv.log 2>&1 - ! grep -q "Using uv backend" nouv.log - - name: --freeze works on a uv-built venv (Windows) + "$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 --no-uv -d six --freeze=frozen.txt -x python -c "import six" > freeze.log 2>&1 || { cat freeze.log; exit 1; } + "$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 diff --git a/README.rst b/README.rst index 81f2b37..c05da7d 100644 --- a/README.rst +++ b/README.rst @@ -394,21 +394,37 @@ Examples: Using uv as backend ------------------- -If a `uv `_ binary is found in ``PATH``, fades automatically uses it as a faster backend to create the virtual environment and install the dependencies (instead of ``venv`` and ``pip``). Nothing else changes: fades keeps doing its own dependency parsing, venv indexing, garbage collection, etc. +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``. -uv is detected as a binary in ``PATH`` (installed via your system package manager, the standalone installer, etc.); fades does not depend on the ``uv`` PyPI package. +Enable it per run with ``--use-uv``:: -fades runs ``uv venv --seed``, so the virtual environment ships ``pip`` and ``setuptools`` just like the classic ``venv`` backend, keeping packages that expect them present working. + fades --use-uv -d requests -x python -If you want to force the classic ``pip`` backend even when uv is available, use ``--no-uv``: +``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. -``fades --no-uv -d requests -x python`` +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. -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:: +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. - fades -d azure-cli -d "setuptools<81" -x az --help +To turn it on for your user without editing files by hand, run:: + + python -c "import configparser; from fades.helpers import get_confdir; \ + p = 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:: -(or use ``--no-uv`` on a Python version old enough to seed an old setuptools). + fades --use-uv -d azure-cli -d "setuptools<81" -x az --help Setting options using config files diff --git a/fades/envbuilder.py b/fades/envbuilder.py index f437617..5d842dd 100644 --- a/fades/envbuilder.py +++ b/fades/envbuilder.py @@ -145,18 +145,18 @@ def create_with_uv(interpreter, is_current, venv_options, uv_exe): def create_venv(requested_deps, interpreter, is_current, options, pip_options, avoid_pip_upgrade, - use_uv=False): - """Create a new virtualvenv with the requirements of this script.""" - # decide the backend; only use uv if requested *and* actually available - uv_exe = helpers.get_uv_exe() if use_uv else None - use_uv = uv_exe is not None + 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 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) - pip_installed = False + # '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) @@ -170,7 +170,7 @@ def create_venv(requested_deps, interpreter, is_current, options, pip_options, a for repo in requested_deps.keys(): if repo in (REPO_PYPI, REPO_VCS): if use_uv: - mgr = UvManager(env_bin_path, options=pip_options, uv_exe=uv_exe) + 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, 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 b9367e5..0341b8a 100644 --- a/fades/helpers.py +++ b/fades/helpers.py @@ -89,14 +89,15 @@ def logged_exec(cmd): return stdout -def get_uv_exe(): - """Return the path to the ``uv`` binary if available on PATH, else None. +def get_uv_exe(uv_path=None): + """Return the path to the ``uv`` binary, or None if it can't be found. - fades runs against the system Python (it is not installed inside a venv), so we rely on a - ``uv`` *binary* found in PATH rather than the ``uv`` PyPI package, which would require - polluting the system Python. + 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") + return shutil.which(uv_path) if uv_path else shutil.which("uv") def _get_basedirectory(): diff --git a/fades/main.py b/fades/main.py index ce6f9b0..b174da4 100644 --- a/fades/main.py +++ b/fades/main.py @@ -37,7 +37,6 @@ parsing, pipmanager, pkgnamesdb, - uvmanager, ) from fades.logger import set_up as logger_set_up @@ -224,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( @@ -283,9 +309,17 @@ def go(): help="disable the automatic pip upgrade that happens after the virtualenv is created " "and before the dependencies begin to be installed.") parser.add_argument( - '--no-uv', action='store_true', - help="don't use uv as the backend to create the virtualenv and install dependencies, " - "even if a uv binary is available in PATH (use pip instead).") + '--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( @@ -404,19 +438,8 @@ def go(): if args.system_site_packages: options['venv_options'].append("--system-site-packages") - # use uv as the backend when it's available and the user didn't opt out - uv_exe = None if args.no_uv else helpers.get_uv_exe() - use_uv = uv_exe is not None - if use_uv: - # 'uv venv' only understands a subset of the stdlib venv flags (e.g. it rejects - # --symlinks/--copies/--without-pip), so if the user passed venv options beyond - # --system-site-packages, fall back to pip to keep those working - unsupported = [o for o in options['venv_options'] if o != "--system-site-packages"] - if unsupported: - logger.info( - "Using pip backend instead of uv: 'uv venv' does not support %s", unsupported) - use_uv = False - uv_exe = None + # 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) @@ -433,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.") @@ -445,7 +469,7 @@ 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, use_uv) + 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) @@ -455,19 +479,10 @@ def go(): return 0 if args.freeze: - # beyond all the rest of work, dump the dependencies versions to a file. Decide the - # freeze backend from the venv itself, not the current run's flags: a venv built by uv - # has no pip to run 'pip freeze' with, and it may be reused on a later run where uv is - # absent or --no-uv was passed (the cache key doesn't include the backend) - if venv_data.get('pip_installed'): - mgr = pipmanager.PipManager(venv_data['env_bin_path']) - else: - freeze_uv = uv_exe or helpers.get_uv_exe() - if freeze_uv is None: - raise FadesError( - "Cannot freeze: this venv was built with uv (no pip) but no uv binary is " - "available; install uv or recreate the venv with --no-uv") - mgr = uvmanager.UvManager(venv_data['env_bin_path'], uv_exe=freeze_uv) + # 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) # run forest run!! diff --git a/fades/uvmanager.py b/fades/uvmanager.py index 785edef..3f23272 100644 --- a/fades/uvmanager.py +++ b/fades/uvmanager.py @@ -84,15 +84,3 @@ def get_version(self, dependency): logger.error('Fades is having problems getting the installed version. ' 'Run with -v or check the logs for details') return '' - - def freeze(self, filepath: Path): - """Dump venv contents to the indicated filepath.""" - logger.debug("running freeze to store in %r", filepath) - # note: no '--quiet' here; depending on the uv version it silences the *whole* freeze - # output (producing an empty file), so instead we drop uv's informational - # "Using Python ... environment at:" line explicitly - stdout = helpers.logged_exec( - [self.uv_exe, "pip", "freeze", "--python", str(self.python_exe)]) - lines = [line for line in stdout if not line.startswith("Using Python")] - with open(filepath, "wt", encoding='utf8') as fh: - fh.writelines(line + '\n' for line in sorted(lines)) diff --git a/man/fades.1 b/man/fades.1 index 28bf117..d31b8dd 100644 --- a/man/fades.1 +++ b/man/fades.1 @@ -27,7 +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--no-uv\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. @@ -144,8 +146,16 @@ Run library module as a script (same behaviour than Python's \fB-m\fR parameter) Disable the automatic \fBpip\fR upgrade that happens after the virtual environment is created and before the dependencies begin to be installed. .TP -.BR --no-uv -Don't use \fBuv\fR as the backend to create the virtual environment and install dependencies, even if a \fBuv\fR binary is available in \fBPATH\fR (use \fBpip\fR instead). By default, when a \fBuv\fR binary is found in \fBPATH\fR, fades uses it as a faster backend. +.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 bb4e431..9e6b3b3 100644 --- a/tests/test_envbuilder.py +++ b/tests/test_envbuilder.py @@ -101,41 +101,24 @@ def test_create_with_uv_backend(self): avoid_pip_upgrade = False options = {"venv_options": []} pip_options = [] - with patch.object(envbuilder.helpers, 'get_uv_exe', return_value='/bin/uv'): - 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) - - # the uv venv creator was used, and a UvManager (not PipManager) installed the deps + 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=[], uv_exe='/bin/uv')) + 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_uv_requested_but_unavailable_falls_back_to_pip(self): - requested = { - REPO_PYPI: [get_req('dep1 == v1')] - } - options = {"venv_options": []} - with patch.object(envbuilder.helpers, 'get_uv_exe', return_value=None): - with patch.object(envbuilder._FadesEnvBuilder, 'create_env') as mock_create: - with patch.object(envbuilder, 'PipManager') as mock_mgr_c: - with patch.object(envbuilder, 'UvManager') as mock_uv_c: - mock_create.return_value = ('env_path', 'env_bin_path', 'pip_installed') - mock_mgr_c.return_value = fake_manager = self.FakeManager() - fake_manager.really_installed = {'dep1': 'v1'} - envbuilder.create_venv( - requested, 'python3', True, options, [], False, use_uv=True) - - # uv was requested but not present: pip backend used instead - self.assertTrue(mock_mgr_c.called) - self.assertFalse(mock_uv_c.called) - 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')): diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 3a30599..4e7427d 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -597,3 +597,15 @@ 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 index 3d840bf..9945f06 100644 --- a/tests/test_uvmanager.py +++ b/tests/test_uvmanager.py @@ -110,25 +110,6 @@ def test_install_raise_error(logs): assert "Error installing foo: Kapow!" in logs.error -def test_freeze(tmp_path): - tmp_file = tmp_path / "reqtest.txt" - - mgr = get_manager() - with patch.object(helpers, "logged_exec") as mock: - # include uv's informational header line; it must be filtered out (not '--quiet'-ed, - # which would empty the whole output on some uv versions) - mock.return_value = [ - 'Using Python 3.13.1 environment at: /tmp/x', 'moño>11', 'foo==1.2'] - mgr.freeze(tmp_file) - - mock.assert_called_with([UV, "pip", "freeze", "--python", PYTHON_PATH]) - - # check results were stored properly (sorted, info line dropped) - with open(tmp_file, 'rt', encoding='utf8') as fh: - stored = fh.read() - assert stored == 'foo==1.2\nmoño>11\n' - - 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 From 9e1cfa0b2a3d941fb8f28dc21a1e6e409eeb98a6 Mon Sep 17 00:00:00 2001 From: gilgamezh Date: Sat, 20 Jun 2026 16:29:17 +0200 Subject: [PATCH 9/9] README: make the 'enable uv' command version-robust Wrap get_confdir() in Path() so the one-liner works whether get_confdir returns a str (older fades) or a Path, and add -W ignore to silence the pkg_resources deprecation warning emitted by older installed versions. Co-Authored-By: Claude Opus 4.8 --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index c05da7d..8f7f619 100644 --- a/README.rst +++ b/README.rst @@ -417,8 +417,8 @@ If you always want the uv backend, set ``use_uv=true`` under the ``[fades]`` sec To turn it on for your user without editing files by hand, run:: - python -c "import configparser; from fades.helpers import get_confdir; \ - p = get_confdir() / 'fades.ini'; c = configparser.ConfigParser(); c.read(p); \ + 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)"