Skip to content
Open
77 changes: 77 additions & 0 deletions .github/workflows/integtests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,30 @@ jobs:
run: |
cd /fades
bin/fades -v -d pytest -x pytest --version
- name: default backend is pip (uv NOT used without opt-in)
run: |
cd /fades
# even with uv on PATH, the default must stay pip -- the uv-backend log line is absent
bin/fades -v -d six -x python -c "import six" > default.log 2>&1
! grep -q "Using uv backend" default.log
- name: uv backend is used with --use-uv
run: |
cd /fades
bin/fades -v --use-uv -d six -x python -c "import six" 2>&1 | grep -q "Using uv backend"
- name: --use-uv with --pip-options is an error
run: |
cd /fades
! bin/fades --use-uv --pip-options="--foo" -d six -x python -c "import six"
- name: --use-uv with a bogus --uv-path is an error
run: |
cd /fades
! bin/fades --use-uv --uv-path /no/such/uv -d six -x python -c "import six"
- name: --freeze on a uv venv produces a pinned file
run: |
cd /fades
# uv venvs are seeded with pip, so --freeze yields a normal requirements file
bin/fades -v --use-uv -d six --freeze=frozen.txt -x python -c "import six"
grep -q "^six==" frozen.txt
- name: Using the oldest supported Python
env:
OLDEST: ${{ needs.min-python.outputs.version }}
Expand Down Expand Up @@ -74,6 +98,30 @@ jobs:
run: |
cd /fades
bin/fades -v -d pytest -x pytest --version
- name: default backend is pip (uv NOT used without opt-in)
run: |
cd /fades
# even with uv on PATH, the default must stay pip -- the uv-backend log line is absent
bin/fades -v -d six -x python -c "import six" > default.log 2>&1
! grep -q "Using uv backend" default.log
- name: uv backend is used with --use-uv
run: |
cd /fades
bin/fades -v --use-uv -d six -x python -c "import six" 2>&1 | grep -q "Using uv backend"
- name: --use-uv with --pip-options is an error
run: |
cd /fades
! bin/fades --use-uv --pip-options="--foo" -d six -x python -c "import six"
- name: --use-uv with a bogus --uv-path is an error
run: |
cd /fades
! bin/fades --use-uv --uv-path /no/such/uv -d six -x python -c "import six"
- name: --freeze on a uv venv produces a pinned file
run: |
cd /fades
# uv venvs are seeded with pip, so --freeze yields a normal requirements file
bin/fades -v --use-uv -d six --freeze=frozen.txt -x python -c "import six"
grep -q "^six==" frozen.txt
- name: Using the oldest supported Python
env:
OLDEST: ${{ needs.min-python.outputs.version }}
Expand Down Expand Up @@ -112,10 +160,39 @@ jobs:
- name: Install dependencies
run: |
${{ steps.matrixpy.outputs.python-path }} -m pip install -U packaging
- name: Install uv (on PATH for all following steps)
uses: astral-sh/setup-uv@v6
- name: Simple fades run
run: |
${{ steps.matrixpy.outputs.python-path }} bin/fades -v -d pytest -x pytest --version

- name: uv backend works on Windows with --use-uv
shell: bash
run: |
# this exercises the whole uv path on Windows: 'uv venv' + 'uv pip install --python
# <venv>\Scripts\python.exe'. If the python/python.exe resolution were wrong, uv would
# fail to find the interpreter and the install (hence this step) would fail.
which uv && uv --version
# single-quote to keep the backslashes, then turn them into forward slashes so bash
# (Git Bash) doesn't strip them out of the Windows path
PY='${{ steps.matrixpy.outputs.python-path }}'; PY="${PY//\\//}"
"$PY" bin/fades -v --use-uv -d six -x python -c "import six" > uv.log 2>&1 || { cat uv.log; exit 1; }
cat uv.log
grep -q "Using uv backend" uv.log
- name: default backend is pip on Windows (no opt-in)
shell: bash
run: |
PY='${{ steps.matrixpy.outputs.python-path }}'; PY="${PY//\\//}"
"$PY" bin/fades -v -d six -x python -c "import six" > default.log 2>&1
! grep -q "Using uv backend" default.log
- name: --freeze on a uv venv produces a pinned file (Windows)
shell: bash
run: |
PY='${{ steps.matrixpy.outputs.python-path }}'; PY="${PY//\\//}"
"$PY" bin/fades -v --use-uv -d six --freeze=frozen.txt -x python -c "import six" > freeze.log 2>&1 || { cat freeze.log; exit 1; }
cat frozen.txt
grep -q "^six==" frozen.txt

- name: Using a different Python
run: |
${{ steps.matrixpy.outputs.python-path }} bin/fades -v --python=${{ steps.otherpy.outputs.python-path }} -d pytest -x pytest -v --integtest-pyversion=${{ needs.min-python.outputs.version }} tests/integtest.py
Expand Down
40 changes: 38 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,42 @@ Examples:
``fades --python-options=-B foo.py``


Using uv as backend
-------------------

fades can optionally use `uv <https://github.com/astral-sh/uv>`_ as a (much faster) backend to create the virtual environment and install the dependencies, instead of ``venv`` and ``pip``. This is **opt-in**: fades does not change its behaviour just because ``uv`` happens to be installed, because ``uv`` produces slightly different virtual environments than ``pip``.

Enable it per run with ``--use-uv``::

fades --use-uv -d requests -x python

``uv`` must be available: fades looks it up in ``PATH`` (installed via your system package manager, the standalone installer, etc.; fades does not depend on the ``uv`` PyPI package). You can point at a specific executable with ``--uv-path /path/to/uv`` (which also implies ``--use-uv``). If ``uv`` is requested but can't be found, fades errors out.

Everything else stays fades: dependency parsing (``# fades`` comments, docstring, ``-d``/``-r``), venv indexing and reuse, ``--where``/``--rm``/``--clean-unused-venvs``, config files, the REPL and ``--autoimport``, etc.

Some notes when using ``uv``:

- fades runs ``uv venv --seed``, so the virtual environment ships ``pip`` (and ``setuptools``, depending on the Python version) just like the classic ``venv`` backend, keeping packages that expect them present working.
- The PyPI availability pre-check is skipped (``uv`` resolves directly and fails early on its own).
- ``--pip-options`` and ``--venv-options`` are **not** valid together with ``--use-uv`` (they target ``pip``/``venv``); use ``--uv-pip-options`` to pass extra options to ``uv pip install`` instead.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

change "together with --use-uv" for something like "when uv indicated to use", aligned with argparse/man texts.


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 (``<xdg-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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to explain again how the config files work, just refer to "Setting options using config files" section.


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)"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have mixed feelings about this code block...

  • "editing files by hand" is a problem for a fades user?
  • if we leave this recommendation on all config parameters, the README is a mess
  • the code block is quite ugly (not sure that it can be improved without losing portability)
  • maybe something like this corresponds to the "Setting options using config files" section?


A note about ``pkg_resources``: some packages (e.g. ``azure-cli``) still import the long-deprecated ``pkg_resources``, which was **removed** in ``setuptools >= 81``. uv (and the classic backend on Python 3.12+) installs a recent ``setuptools`` that no longer provides it, so those imports fail. This is not specific to the uv backend; if you hit it, add an older setuptools as an explicit dependency::

fades --use-uv -d azure-cli -d "setuptools<81" -x az --help


Setting options using config files
----------------------------------

Expand Down Expand Up @@ -479,9 +515,9 @@ Execute the Python interactive interpreter in a virtual environment after instal

fades -r requirements.txt -r requirements_devel.txt

Use the ``django-admin.py`` script to start a new project named ``foo``, without having to have django previously installed::
Use the ``django-admin`` script to start a new project named ``foo``, without having to have django previously installed::

fades -d django -x django-admin.py startproject foo
fades -d django -x django-admin startproject foo

Remove a virtual environment matching the given uuid from disk and cache index::

Expand Down
57 changes: 50 additions & 7 deletions fades/envbuilder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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__)
Expand Down Expand Up @@ -115,11 +117,49 @@ def post_setup(self, context):
self.env_bin_path = Path(context.bin_path)


def create_venv(requested_deps, interpreter, is_current, options, pip_options, avoid_pip_upgrade):
"""Create a new virtualvenv with the requirements of this script."""
def create_with_uv(interpreter, is_current, venv_options, uv_exe):
"""Create a virtual environment using uv, returning its path and bin path."""
env_path = helpers.get_basedir() / str(uuid4())
logger.debug("Env will be created at: %s (with uv)", env_path)

# when there's no explicitly requested interpreter (current Python), point uv at fades'
# own interpreter so the venv matches what the cache was keyed on
python = interpreter if (interpreter is not None and not is_current) else sys.executable
# '--seed' installs pip + setuptools (+ wheel) like the classic 'python -m venv' backend,
# so packages that expect those to be present in the venv keep working (uv seeds nothing
# by default); the cost is negligible
args = [uv_exe, "venv", "--seed", str(env_path), "--python", str(python)]
args.extend(venv_options)

try:
helpers.logged_exec(args)
except helpers.ExecutionError as error:
error.dump_to_log(logger)
raise FadesError("Failed to create virtual environment with uv")
except Exception as error:
logger.exception("Error creating virtual environment with uv: %s", error)
raise FadesError("General error while creating venv with uv")

env_bin_path = helpers.get_env_bin_path(env_path)
return env_path, env_bin_path


def create_venv(requested_deps, interpreter, is_current, options, pip_options, avoid_pip_upgrade,
use_uv=False, uv_exe=None, uv_pip_options=None):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like this function's interface. It's weird (bunch of options together not working with other bunch of options). It's hard to check all combinations (e.g. having uv_exe if use_uv, but also that avoid_pip_upgrade is not needed, etc).

I suggest the following refactor:

  • have two entry points: create_venv_classic and create_venv_uv
  • the caller in main.py is responsible of deciding which to use
    • this will make the interface of each function simpler and will make sense
    • no need to weird verifications
  • each function will do a little of work and will call a common one
    • this common one would be completely agnostic
    • specially if it receives a "manager" (UvManager or PipManager instance, according to the caller)

What do you think?

"""Create a new virtualvenv with the requirements of this script.

When ``use_uv`` is True the caller must provide ``uv_exe`` (the resolved uv binary).
"""
# create virtual environment
env = _FadesEnvBuilder()
env_path, env_bin_path, pip_installed = env.create_env(interpreter, is_current, options)
if use_uv:
logger.debug("Creating virtual environment with uv backend")
env_path, env_bin_path = create_with_uv(
interpreter, is_current, options['venv_options'], uv_exe)
# 'uv venv --seed' installs pip in the venv, so it's available like in the classic path
pip_installed = True
else:
env = _FadesEnvBuilder()
env_path, env_bin_path, pip_installed = env.create_env(interpreter, is_current, options)
venv_data = {}
venv_data['env_path'] = env_path
venv_data['env_bin_path'] = env_bin_path
Expand All @@ -129,9 +169,12 @@ def create_venv(requested_deps, interpreter, is_current, options, pip_options, a
installed = {}
for repo in requested_deps.keys():
if repo in (REPO_PYPI, REPO_VCS):
mgr = PipManager(
env_bin_path, pip_installed=pip_installed, options=pip_options,
avoid_pip_upgrade=avoid_pip_upgrade)
if use_uv:
mgr = UvManager(env_bin_path, options=uv_pip_options, uv_exe=uv_exe)
else:
mgr = PipManager(
env_bin_path, pip_installed=pip_installed, options=pip_options,
avoid_pip_upgrade=avoid_pip_upgrade)
else:
logger.warning("Install from %r not implemented", repo)
continue
Expand Down
2 changes: 1 addition & 1 deletion fades/file_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
12 changes: 12 additions & 0 deletions fades/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import json
import logging
import os
import shutil
import subprocess
import sys
import tempfile
Expand Down Expand Up @@ -88,6 +89,17 @@ def logged_exec(cmd):
return stdout


def get_uv_exe(uv_path=None):
"""Return the path to the ``uv`` binary, or None if it can't be found.

If ``uv_path`` is given that exact location is validated (it must exist and be executable),
otherwise ``uv`` is looked up in PATH. fades runs against the system Python (it is not
installed inside a venv), so we rely on a ``uv`` *binary* rather than the ``uv`` PyPI package,
which would require polluting the system Python.
"""
return shutil.which(uv_path) if uv_path else shutil.which("uv")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should log in DEBUG here, for eventually be sure which uv is being used...



def _get_basedirectory():
from xdg import BaseDirectory
return BaseDirectory
Expand Down
56 changes: 52 additions & 4 deletions fades/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,33 @@ def detect_inside_virtualenv(prefix, real_prefix, base_prefix):
return prefix != base_prefix


def _resolve_uv_backend(args):
"""Decide whether to use the uv backend, validating the related options.

Return a tuple ``(use_uv, uv_exe, uv_pip_options)``. uv is used only when explicitly
requested (``--use-uv``, ``--uv-path`` or ``use_uv`` in a config file). Raise FadesError if
uv is requested but can't be found, or if incompatible options were given.
"""
use_uv = bool(args.use_uv or args.uv_path)
if not use_uv:
return False, None, []

uv_exe = helpers.get_uv_exe(args.uv_path)
if not uv_exe:
msg = ("uv was requested (--use-uv) but no usable uv binary was found; install uv or "
"indicate its location with --uv-path")
logger.error(msg)
raise FadesError(msg)

if args.pip_options or args.venv_options:
msg = ("--pip-options/--venv-options can't be used together with --use-uv; "
"use --uv-pip-options to pass options to uv")
logger.error(msg)
raise FadesError(msg)

return True, uv_exe, args.uv_pip_options


def go():
"""Make the magic happen."""
parser = argparse.ArgumentParser(
Expand Down Expand Up @@ -281,6 +308,18 @@ def go():
'--avoid-pip-upgrade', action='store_true',
help="disable the automatic pip upgrade that happens after the virtualenv is created "
"and before the dependencies begin to be installed.")
parser.add_argument(
'--use-uv', action='store_true',
help="use uv (instead of venv/pip) to create the virtualenv and install dependencies; "
"uv must be available in PATH or indicated with --uv-path.")
parser.add_argument(
'--uv-path', action='store', metavar='UV_PATH',
help="path to the uv executable to use (implies --use-uv); if not given, uv is looked up "
"in PATH.")
parser.add_argument(
'--uv-pip-options', action='append', default=[],
help="extra options to be supplied to 'uv pip install' (this option can be used multiple "
"times); only valid together with --use-uv.")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please rephrase this (as it also may be used if --uv-path is included). Maybe saying something like "only valid if 'uv' is indicated to use"?


mutexg = parser.add_mutually_exclusive_group()
mutexg.add_argument(
Expand Down Expand Up @@ -399,6 +438,11 @@ def go():
if args.system_site_packages:
options['venv_options'].append("--system-site-packages")

# use uv as the backend only when explicitly requested (flag, --uv-path or config)
use_uv, uv_exe, uv_pip_options = _resolve_uv_backend(args)
if use_uv:
logger.debug("Using uv backend found at %r", uv_exe)

create_venv = False
venv_data = venvscache.get_venv(indicated_deps, interpreter, uuid, options)
if venv_data:
Expand All @@ -412,8 +456,9 @@ def go():
create_venv = True

if create_venv:
# Check if the requested packages exists in pypi.
if not args.no_precheck_availability and indicated_deps.get('pypi'):
# Check if the requested packages exists in pypi. uv resolves directly and fails early
# by itself, so this extra check is skipped when using the uv backend.
if not args.no_precheck_availability and not use_uv and indicated_deps.get('pypi'):
logger.info(
"Checking the availabilty of dependencies in PyPI. "
"You can use '--no-precheck-availability' to avoid it.")
Expand All @@ -423,7 +468,8 @@ def go():

# Create a new venv
venv_data, installed = envbuilder.create_venv(
indicated_deps, args.python, is_current, options, pip_options, args.avoid_pip_upgrade)
indicated_deps, args.python, is_current, options, pip_options,
args.avoid_pip_upgrade, use_uv, uv_exe, uv_pip_options)
# store this new venv in the cache
venvscache.store(installed, venv_data, interpreter, options)

Expand All @@ -433,7 +479,9 @@ def go():
return 0

if args.freeze:
# beyond all the rest of work, dump the dependencies versions to a file
# beyond all the rest of work, dump the dependencies versions to a file; all venvs have
# pip available (the uv backend seeds it via 'uv venv --seed'), so a plain pip freeze
# gives a consistent requirements format regardless of the backend used
mgr = pipmanager.PipManager(venv_data['env_bin_path'])
mgr.freeze(args.freeze)

Expand Down
Loading
Loading