Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
python3 -m 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
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
! 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:
OLDEST: ${{ needs.min-python.outputs.version }}
Expand Down Expand Up @@ -74,6 +98,30 @@ jobs:
run: |
cd /fades
python3 -m 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
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
! 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:
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 }} -m 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" -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" -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" -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

- 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
Expand Down
36 changes: 34 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,38 @@ 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 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)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

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::

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


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

Expand Down Expand Up @@ -511,9 +543,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
66 changes: 57 additions & 9 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,60 @@ 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."""
# create virtual environment
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_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.
"""
venv_data = {}
venv_data['env_path'] = env_path
venv_data['env_bin_path'] = env_bin_path
Expand All @@ -128,17 +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):
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)
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
20 changes: 19 additions & 1 deletion fades/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -94,6 +94,24 @@ 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.
"""
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():
from xdg import BaseDirectory
return BaseDirectory
Expand Down
Loading
Loading