From f026a3974a9aa333c468acc019d6571a623e3eb1 Mon Sep 17 00:00:00 2001 From: gilgamezh Date: Sat, 20 Jun 2026 12:48:05 +0200 Subject: [PATCH 01/15] 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 | 12 ++- fades/main.py | 21 ++++- 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, 385 insertions(+), 10 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 4c25fc0..ccbf7b9 100644 --- a/.github/workflows/integtests.yaml +++ b/.github/workflows/integtests.yaml @@ -46,6 +46,16 @@ jobs: run: | cd /fades python3 -m 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) + python3 -m 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) + ! python3 -m 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 python3 -m 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) + python3 -m 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) + ! python3 -m 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 3e3ff62..249d9ef 100644 --- a/README.rst +++ b/README.rst @@ -423,6 +423,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 194fed2..d1b37fd 100644 --- a/fades/helpers.py +++ b/fades/helpers.py @@ -17,9 +17,9 @@ """A collection of utilities for fades.""" import json -import shutil import logging import os +import shutil import subprocess import sys import tempfile @@ -94,6 +94,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 0a3f47a..bb0b34a 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 @@ -284,6 +285,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( @@ -408,6 +413,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: @@ -433,7 +444,7 @@ def go(): # Create a new venv venv_data, installed = envbuilder.create_venv( indicated_deps, python_interpreter, is_current, options, pip_options, - args.avoid_pip_upgrade) + args.avoid_pip_upgrade, use_uv) # store this new venv in the cache venvscache.store(installed, venv_data, interpreter, options) @@ -443,8 +454,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 e9631c7..72c1f53 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. @@ -144,6 +145,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 287f1da..1377d44 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -787,3 +787,18 @@ def test_external_interpreter_empty_output(self): with patch.object(helpers, 'logged_exec', return_value=[]): with pytest.raises(FadesError): helpers._get_interpreter_full_version('/usr/bin/python3.12') + + +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 81bfcea2aeb899d29d1d3b9d8b87d316fb7e9d57 Mon Sep 17 00:00:00 2001 From: gilgamezh Date: Sat, 20 Jun 2026 13:08:05 +0200 Subject: [PATCH 02/15] django changed the executable name --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 249d9ef..735c87a 100644 --- a/README.rst +++ b/README.rst @@ -523,9 +523,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 0c4806c556b14ebf250fb423b77e447d0b501243 Mon Sep 17 00:00:00 2001 From: gilgamezh Date: Sat, 20 Jun 2026 13:35:08 +0200 Subject: [PATCH 03/15] 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 ccbf7b9..45fa09b 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) - ! python3 -m 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 + python3 -m 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 + python3 -m 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) - ! python3 -m 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 + python3 -m 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 + python3 -m 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 bb0b34a..bcc6447 100644 --- a/fades/main.py +++ b/fades/main.py @@ -416,6 +416,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) @@ -454,12 +464,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 5aec8392bd7d7c3772615bb0f20f6d30591453cf Mon Sep 17 00:00:00 2001 From: gilgamezh Date: Sat, 20 Jun 2026 13:39:43 +0200 Subject: [PATCH 04/15] 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 45fa09b..2931b24 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 }} -m 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 }} -m fades -v --python=${{ steps.otherpy.outputs.python-path }} -d pytest -x pytest -v --integtest-pyversion=${{ needs.min-python.outputs.version }} tests/integtest.py From b585bc2799cb5b7d57059bb1c09db1780439aa14 Mon Sep 17 00:00:00 2001 From: gilgamezh Date: Sat, 20 Jun 2026 13:43:05 +0200 Subject: [PATCH 05/15] 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 2931b24..206a9ab 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 }} -m 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 824e8e51da58408d1a019f09d640900be45ee99b Mon Sep 17 00:00:00 2001 From: gilgamezh Date: Sat, 20 Jun 2026 13:46:19 +0200 Subject: [PATCH 06/15] 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 206a9ab..7159eef 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 8645bcd7bfb5f990dd6f71383802c7f8d835c3e4 Mon Sep 17 00:00:00 2001 From: gilgamezh Date: Sat, 20 Jun 2026 14:11:27 +0200 Subject: [PATCH 07/15] 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 735c87a..bda2306 100644 --- a/README.rst +++ b/README.rst @@ -430,10 +430,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 9e2cad85ca04b0dc7aad995e8c38d49eae2dfe47 Mon Sep 17 00:00:00 2001 From: gilgamezh Date: Sat, 20 Jun 2026 16:15:30 +0200 Subject: [PATCH 08/15] 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 7159eef..148b84e 100644 --- a/.github/workflows/integtests.yaml +++ b/.github/workflows/integtests.yaml @@ -46,25 +46,29 @@ jobs: run: | cd /fades python3 -m 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) - python3 -m 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 + python3 -m 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 - python3 -m 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) + python3 -m 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 - python3 -m fades -v --no-uv -d six --freeze=frozen.txt -x python -c "import six" + ! python3 -m 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 + ! python3 -m 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 + python3 -m 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 python3 -m 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 + python3 -m 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 + python3 -m 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) - python3 -m fades -v -d six -x python -c "import six" 2>&1 | grep -q "Using uv backend" - - name: pip backend still works with --no-uv + ! python3 -m 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 - python3 -m 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) + ! python3 -m 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 - python3 -m 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 + python3 -m 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 }} -m 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" python3 -m 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" python3 -m 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" python3 -m 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 bda2306..e246be6 100644 --- a/README.rst +++ b/README.rst @@ -426,21 +426,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 d1b37fd..f6f4984 100644 --- a/fades/helpers.py +++ b/fades/helpers.py @@ -94,14 +94,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 bcc6447..eda0720 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 @@ -227,6 +226,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( @@ -286,9 +312,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( @@ -413,19 +447,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) @@ -442,8 +465,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.") @@ -454,7 +478,7 @@ def go(): # Create a new venv venv_data, installed = envbuilder.create_venv( indicated_deps, python_interpreter, 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) @@ -464,19 +488,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 72c1f53..33e16f5 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. @@ -146,8 +148,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 1377d44..9815f52 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -802,3 +802,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 fd42154..aa00b57 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 554054e3e3028ff22e7f16ac9ac2a3dcee665605 Mon Sep 17 00:00:00 2001 From: gilgamezh Date: Sat, 20 Jun 2026 16:29:17 +0200 Subject: [PATCH 09/15] 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 e246be6..1d2f3cc 100644 --- a/README.rst +++ b/README.rst @@ -449,8 +449,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)" From fc915e5ead24e757feb113db475e4f566e498a9e Mon Sep 17 00:00:00 2001 From: gilgamezh Date: Sat, 17 Oct 2026 14:30:02 +0200 Subject: [PATCH 10/15] CI: run fades as a module in the uv steps bin/fades was removed in #457; use 'python3 -m fades' (or '"$PY" -m fades' on Windows) like the rest of the workflow. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/integtests.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/integtests.yaml b/.github/workflows/integtests.yaml index 148b84e..27bc6c7 100644 --- a/.github/workflows/integtests.yaml +++ b/.github/workflows/integtests.yaml @@ -176,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" python3 -m fades -v --use-uv -d six -x python -c "import six" > uv.log 2>&1 || { cat uv.log; exit 1; } + "$PY" -m 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" python3 -m fades -v -d six -x python -c "import six" > default.log 2>&1 + "$PY" -m 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" python3 -m fades -v --use-uv -d six --freeze=frozen.txt -x python -c "import six" > freeze.log 2>&1 || { cat freeze.log; exit 1; } + "$PY" -m 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 From dd08cc5d85da64dd160ca3c7557a8f3e188acbcc Mon Sep 17 00:00:00 2001 From: gilgamezh Date: Sat, 17 Oct 2026 14:30:02 +0200 Subject: [PATCH 11/15] Fix copyright year of the new uv files Files created this year carry only the current year. Co-Authored-By: Claude Opus 5.5 --- fades/uvmanager.py | 2 +- tests/test_uvmanager.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/fades/uvmanager.py b/fades/uvmanager.py index 3f23272..b0db4d1 100644 --- a/fades/uvmanager.py +++ b/fades/uvmanager.py @@ -1,4 +1,4 @@ -# Copyright 2024-2026 Facundo Batista, Nicolás Demarchi +# Copyright 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 diff --git a/tests/test_uvmanager.py b/tests/test_uvmanager.py index 9945f06..2fd19ce 100644 --- a/tests/test_uvmanager.py +++ b/tests/test_uvmanager.py @@ -1,4 +1,4 @@ -# Copyright 2024-2026 Facundo Batista, Nicolás Demarchi +# Copyright 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 From a652b6376b5a84ec14d67ee3110628a1a5e67a2b Mon Sep 17 00:00:00 2001 From: gilgamezh Date: Sat, 17 Oct 2026 14:41:31 +0200 Subject: [PATCH 12/15] Split create_venv into classic and uv entry points main.py now decides which backend to use and calls create_venv_classic or create_venv_uv, each with only the options that make sense for it; both share a backend-agnostic _install_deps that works with the given manager. The backend in use is logged in DEBUG. Co-Authored-By: Claude Opus 5.5 --- fades/envbuilder.py | 53 ++++++++++++++++++++++------------------ tests/test_envbuilder.py | 19 +++++++------- 2 files changed, 38 insertions(+), 34 deletions(-) diff --git a/fades/envbuilder.py b/fades/envbuilder.py index 5d842dd..2253320 100644 --- a/fades/envbuilder.py +++ b/fades/envbuilder.py @@ -144,22 +144,33 @@ def create_with_uv(interpreter, is_current, venv_options, uv_exe): 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). +def create_venv_classic( + requested_deps, interpreter, is_current, options, pip_options, avoid_pip_upgrade): + """Create a new virtualenv using venv, and install the dependencies with pip.""" + logger.debug("Creating virtual environment with the classic backend (venv + pip)") + env = _FadesEnvBuilder() + env_path, env_bin_path, pip_installed = env.create_env(interpreter, is_current, options) + mgr = PipManager( + env_bin_path, pip_installed=pip_installed, options=pip_options, + avoid_pip_upgrade=avoid_pip_upgrade) + return _install_deps(requested_deps, env_path, env_bin_path, pip_installed, mgr) + + +def create_venv_uv(requested_deps, interpreter, is_current, options, uv_exe, uv_pip_options): + """Create a new virtualenv and install the dependencies, all using uv.""" + logger.debug("Creating virtual environment with the uv backend (using %r)", uv_exe) + env_path, env_bin_path = create_with_uv( + interpreter, is_current, options['venv_options'], uv_exe) + mgr = UvManager(env_bin_path, options=uv_pip_options, uv_exe=uv_exe) + # 'uv venv --seed' installs pip in the venv, so it's available like in the classic backend + return _install_deps(requested_deps, env_path, env_bin_path, True, mgr) + + +def _install_deps(requested_deps, env_path, env_bin_path, pip_installed, mgr): + """Install the dependencies in an already created virtualenv, using the given manager. + + This is agnostic of the backend: the manager (PipManager or UvManager) does the work. """ - # 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) - # '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 @@ -168,20 +179,14 @@ def create_venv(requested_deps, interpreter, is_current, options, pip_options, a # install deps installed = {} for repo in requested_deps.keys(): - if repo in (REPO_PYPI, REPO_VCS): - 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: + if repo not in (REPO_PYPI, REPO_VCS): logger.warning("Install from %r not implemented", repo) continue installed[repo] = {} repo_requested = requested_deps[repo] - logger.debug("Installing dependencies for repo %r: requested=%s", repo, repo_requested) + logger.debug("Installing dependencies for repo %r with %s: requested=%s", + repo, type(mgr).__name__, repo_requested) for dependency in repo_requested: try: mgr.install(dependency) diff --git a/tests/test_envbuilder.py b/tests/test_envbuilder.py index 9e6b3b3..d1ba3c4 100644 --- a/tests/test_envbuilder.py +++ b/tests/test_envbuilder.py @@ -73,7 +73,7 @@ def test_create_simple(self): 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', 'dep2': 'v2'} - venv_data, installed = envbuilder.create_venv( + venv_data, installed = envbuilder.create_venv_classic( requested, interpreter, is_current, options, pip_options, avoid_pip_upgrade) self.assertEqual(venv_data, { @@ -91,6 +91,7 @@ def test_create_simple(self): 'env_bin_path', pip_installed='pip_installed', options=[], avoid_pip_upgrade=avoid_pip_upgrade) self.assertEqual(mock_mgr_c.call_args, expected_pipmanager_call) + self.assertLoggedDebug("Creating virtual environment with the classic backend") def test_create_with_uv_backend(self): requested = { @@ -98,17 +99,14 @@ def test_create_with_uv_backend(self): } 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', + venv_data, installed = envbuilder.create_venv_uv( + requested, interpreter, is_current, options, uv_exe='/bin/uv', uv_pip_options=['--foo']) # the uv venv creator was used, and a UvManager (not PipManager) installed the deps with @@ -118,6 +116,7 @@ def test_create_with_uv_backend(self): 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'}}) + self.assertLoggedDebug("Creating virtual environment with the uv backend", "/bin/uv") def test_create_with_uv_seeds_and_passes_options(self): # the uv venv command must seed pip/setuptools and forward the python + venv options @@ -147,7 +146,7 @@ def test_create_vcs(self): with patch.object(envbuilder, 'PipManager') as mock_mgr_c: mock_create.return_value = ('env_path', 'env_bin_path', 'pip_installed') mock_mgr_c.return_value = self.FakeManager() - venv_data, installed = envbuilder.create_venv( + venv_data, installed = envbuilder.create_venv_classic( requested, interpreter, is_current, options, pip_options, avoid_pip_upgrade) self.assertEqual(venv_data, { @@ -170,7 +169,7 @@ def test_unknown_repo(self): with patch.object(envbuilder, 'PipManager') as mock_mgr_c: mock_create.return_value = ('env_path', 'env_bin_path', 'pip_installed') mock_mgr_c.return_value = self.FakeManager() - envbuilder.create_venv( + envbuilder.create_venv_classic( requested, interpreter, is_current, options, pip_options, avoid_pip_upgrade) self.assertLoggedWarning("Install from 'unknown' not implemented") @@ -191,7 +190,7 @@ def test_non_existing_dep(self): mock_mgr_c.return_value = self.FailInstallManager() with patch.object(envbuilder, 'destroy_venv', spec=True) as mock_destroy: with self.assertRaises(FadesError) as cm: - envbuilder.create_venv( + envbuilder.create_venv_classic( requested, interpreter, is_current, options, pip_options, avoid_pip_upgrade) self.assertEqual(str(cm.exception), 'Dependency installation failed') @@ -213,7 +212,7 @@ def test_different_versions(self): 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': 'vX', 'dep2': 'v2'} - _, installed = envbuilder.create_venv( + _, installed = envbuilder.create_venv_classic( requested, interpreter, is_current, options, pip_options, avoid_pip_upgrade) self.assertEqual(installed, { From 6a4bccfd29024a0d0da4698005d21f876b0c11e6 Mon Sep 17 00:00:00 2001 From: gilgamezh Date: Sat, 17 Oct 2026 14:41:31 +0200 Subject: [PATCH 13/15] Log which uv is used, and say "when uv is used" in texts uv can be enabled with --use-uv, --uv-path or the config file, so help, man and error texts no longer refer only to --use-uv. get_uv_exe logs in DEBUG where it looked for uv and what it found. Co-Authored-By: Claude Opus 5.5 --- fades/helpers.py | 9 ++++++++- fades/main.py | 18 ++++++++++++------ man/fades.1 | 2 +- tests/test_helpers.py | 5 +++++ 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/fades/helpers.py b/fades/helpers.py index f6f4984..387845b 100644 --- a/fades/helpers.py +++ b/fades/helpers.py @@ -102,7 +102,14 @@ def get_uv_exe(uv_path=None): 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") + if uv_path: + uv_exe = shutil.which(uv_path) + logger.debug("Looking for uv at the path indicated with --uv-path (%r): %r", + uv_path, uv_exe) + else: + uv_exe = shutil.which("uv") + logger.debug("Looking for uv in PATH: %r", uv_exe) + return uv_exe def _get_basedirectory(): diff --git a/fades/main.py b/fades/main.py index eda0720..ca6f3ee 100644 --- a/fades/main.py +++ b/fades/main.py @@ -239,13 +239,13 @@ def _resolve_uv_backend(args): 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 " + msg = ("uv was requested 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; " + msg = ("--pip-options/--venv-options can't be used when uv is used; " "use --uv-pip-options to pass options to uv") logger.error(msg) raise FadesError(msg) @@ -322,7 +322,7 @@ def go(): 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.") + "times); only valid when uv is used.") mutexg = parser.add_mutually_exclusive_group() mutexg.add_argument( @@ -451,6 +451,8 @@ def go(): use_uv, uv_exe, uv_pip_options = _resolve_uv_backend(args) if use_uv: logger.debug("Using uv backend found at %r", uv_exe) + else: + logger.debug("Using the classic backend (venv + pip)") create_venv = False venv_data = venvscache.get_venv(indicated_deps, interpreter, uuid, options) @@ -476,9 +478,13 @@ def go(): raise FadesError("Required dependency does not exist") # Create a new venv - venv_data, installed = envbuilder.create_venv( - indicated_deps, python_interpreter, is_current, options, pip_options, - args.avoid_pip_upgrade, use_uv, uv_exe, uv_pip_options) + if use_uv: + venv_data, installed = envbuilder.create_venv_uv( + indicated_deps, python_interpreter, is_current, options, uv_exe, uv_pip_options) + else: + venv_data, installed = envbuilder.create_venv_classic( + indicated_deps, python_interpreter, is_current, options, pip_options, + args.avoid_pip_upgrade) # store this new venv in the cache venvscache.store(installed, venv_data, interpreter, options) diff --git a/man/fades.1 b/man/fades.1 index 33e16f5..a29e074 100644 --- a/man/fades.1 +++ b/man/fades.1 @@ -157,7 +157,7 @@ Path to the \fBuv\fR executable to use (implies \fB--use-uv\fR). If not given, \ .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. +Extra options to be supplied to \fBuv pip install\fR (this option can be used multiple times); only valid when \fBuv\fR is used. .SH EXAMPLES diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 9815f52..063c49b 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -792,11 +792,15 @@ def test_external_interpreter_empty_output(self): class GetUvExeTestCase(unittest.TestCase): """Tests for the uv binary detection.""" + def setUp(self): + logassert.setup(self, 'fades.helpers') + 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') + self.assertLoggedDebug("Looking for uv in PATH", "/usr/bin/uv") def test_uv_absent(self): with patch.object(helpers.shutil, 'which', return_value=None): @@ -809,6 +813,7 @@ def test_uv_path_given_is_validated(self): result = helpers.get_uv_exe('/opt/uv') assert result == '/opt/uv' mock.assert_called_once_with('/opt/uv') + self.assertLoggedDebug("Looking for uv at the path indicated with --uv-path", "/opt/uv") def test_uv_path_given_not_found(self): with patch.object(helpers.shutil, 'which', return_value=None): From c44935d29b112775c35b04a144621174b17ce4bd Mon Sep 17 00:00:00 2001 From: gilgamezh Date: Sat, 17 Oct 2026 14:41:31 +0200 Subject: [PATCH 14/15] README: shorten the uv config section, move the recipe to docs Refer to the config files section instead of explaining it again, and move the one-liner that enables uv by default to docs/uv_by_default.rst. Also drop the claim that a command line flag overrides use_uv=true from the config: there is no such flag. Co-Authored-By: Claude Opus 5.5 --- README.rst | 11 ++--------- docs/index.rst | 3 ++- docs/uv_by_default.rst | 16 ++++++++++++++++ 3 files changed, 20 insertions(+), 10 deletions(-) create mode 100644 docs/uv_by_default.rst diff --git a/README.rst b/README.rst index 1d2f3cc..28790f8 100644 --- a/README.rst +++ b/README.rst @@ -440,19 +440,12 @@ 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. +- ``--pip-options`` and ``--venv-options`` are **not** valid when uv is used (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)" +To always use the uv backend, set ``use_uv=true`` in a fades config file (see `Setting options using config files`_). If you prefer a command that does it for you, check `this recipe `_. 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:: diff --git a/docs/index.rst b/docs/index.rst index 840f50e..636bcca 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,7 +14,8 @@ Contents: :maxdepth: 2 readme - development + development + uv_by_default diff --git a/docs/uv_by_default.rst b/docs/uv_by_default.rst new file mode 100644 index 0000000..dfa818b --- /dev/null +++ b/docs/uv_by_default.rst @@ -0,0 +1,16 @@ +Enabling the uv backend by default +================================== + +To always use the uv backend you need ``use_uv=true`` in the ``[fades]`` section of a +fades config file (see "Setting options using config files" in the README). + +If you prefer not to edit the file by hand, this command adds it to your user's config +file (creating it if needed):: + + 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)" + +Note it needs fades to be importable by that ``python`` (e.g. if fades was installed with +``pip``); otherwise just add the line to the file yourself. From f8f2308e7e92591447e3c4c8ce78832a6026c13e Mon Sep 17 00:00:00 2001 From: gilgamezh Date: Thu, 22 Oct 2026 11:40:09 +0200 Subject: [PATCH 15/15] Replace the uv-by-default recipe with a config file example Co-Authored-By: Claude Opus 5.5 --- README.rst | 5 ++++- docs/index.rst | 3 +-- docs/uv_by_default.rst | 16 ---------------- 3 files changed, 5 insertions(+), 19 deletions(-) delete mode 100644 docs/uv_by_default.rst diff --git a/README.rst b/README.rst index 28790f8..47497ff 100644 --- a/README.rst +++ b/README.rst @@ -445,7 +445,10 @@ Some notes when using ``uv``: Enabling uv permanently (via config) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -To always use the uv backend, set ``use_uv=true`` in a fades config file (see `Setting options using config files`_). If you prefer a command that does it for you, check `this recipe `_. +To always use the uv backend, set ``use_uv=true`` in a fades config file (see `Setting options using config files`_). For example, your user's ``fades.ini`` (e.g. ``~/.config/fades/fades.ini``) could be:: + + [fades] + use_uv=true 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:: diff --git a/docs/index.rst b/docs/index.rst index 636bcca..840f50e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,8 +14,7 @@ Contents: :maxdepth: 2 readme - development - uv_by_default + development diff --git a/docs/uv_by_default.rst b/docs/uv_by_default.rst deleted file mode 100644 index dfa818b..0000000 --- a/docs/uv_by_default.rst +++ /dev/null @@ -1,16 +0,0 @@ -Enabling the uv backend by default -================================== - -To always use the uv backend you need ``use_uv=true`` in the ``[fades]`` section of a -fades config file (see "Setting options using config files" in the README). - -If you prefer not to edit the file by hand, this command adds it to your user's config -file (creating it if needed):: - - 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)" - -Note it needs fades to be importable by that ``python`` (e.g. if fades was installed with -``pip``); otherwise just add the line to the file yourself.