Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use uv instead of pip to manage virtualenvs #432

Merged
merged 9 commits into from
Jul 23, 2024
1 change: 1 addition & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ jobs:
path: |
~/.cache/pypoetry/virtualenvs
~/.cache/pytest
~/.cache/uv
key: ${{ runner.os }}-poetry-tests-${{ hashFiles('poetry.lock') }}
- name: Install project
run: poetry install --no-interaction --sync --with=nox,test
Expand Down
20 changes: 13 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,23 +232,29 @@ fallback strategy.

#### Mapping by temporarily installing packages

Your local Python environements might not always have all your project's
Your local Python environments might not always have all your project's
dependencies installed. Assuming that you don’t want to go through the
bother of installing packages manually, and you also don't want to rely on
the inaccurate identity mapping as your fallback strategy, you can use the
`--install-deps` option. This will `pip install`
missing dependencies (from [PyPI](https://pypi.org/), by default) into a
_temporary virtualenv_, and allow FawltyDeps to use this to come up with the
correct mapping.
`--install-deps` option. This will automatically install missing dependencies
(from [PyPI](https://pypi.org/), by default) into a _temporary virtualenv_,
and allow FawltyDeps to use this to come up with the correct mapping.

Since this is a potentially expensive strategy (e.g. downloading packages from
PyPI), we have chosen to hide it behind the `--install-deps` command-line
option. If you want to always enable this option, you can set the corresponding
`install_deps` configuration variable to `true` in the `[tool.fawltydeps]`
section of your `pyproject.toml`.

To customize how this auto-installation happens (e.g. use a different package index),
you can use [pip’s environment variables](https://pip.pypa.io/en/stable/topics/configuration/).
FawltyDeps will use [`uv`](https://github.com/astral-sh/uv) by default to
temporarily install missing dependencies. If `uv` not available, `pip` will be
used instead. If you want to ensure that the faster `uv` is available, you can
install `fawltydeps` with the `uv` extra (e.g. `pip install fawltydeps[uv]`).

To further customize how this automatic installation is done (e.g. if you need
to use a different package index), you can use environment variables to alter
[`uv`'s](https://github.com/astral-sh/uv?tab=readme-ov-file#environment-variables)
or [`pip`’s ](https://pip.pypa.io/en/stable/topics/configuration/) behavior.

Note that we’re never guaranteed to be able to resolve _all_ dependencies with
this method: For example, there could be a typo in your `requirements.txt` that
Expand Down
2 changes: 1 addition & 1 deletion fawltydeps/cli_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ def populate_parser_paths_options(parser: argparse._ActionsContainer) -> None:
dest="install_deps",
action="store_true",
help=(
"Allow FawltyDeps to `pip install` declared dependencies into a"
"Allow FawltyDeps to auto-install declared dependencies into a"
" separate temporary virtualenv to discover the imports they expose."
),
)
Expand Down
136 changes: 89 additions & 47 deletions fawltydeps/packages.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Encapsulate the lookup of packages and their provided import names."""

import logging
import shutil
import subprocess
import sys
import tempfile
Expand Down Expand Up @@ -397,66 +398,106 @@ def pyenv_sources(*pyenv_paths: Path) -> Set[PyEnvSource]:
return ret


class TemporaryPipInstallResolver(BasePackageResolver):
class TemporaryAutoInstallResolver(BasePackageResolver):
"""Resolve packages by installing them in to a temporary venv.

This provides a resolver for packages that are not installed in an existing
local environment. This is done by creating a temporary venv, and then
`pip install`ing the packages into this venv, and then resolving the
packages in this venv. The venv is automatically deleted before as soon as
the packages have been resolved.
local environment. This is done by creating a temporary venv, installing
the packages into this venv, and then resolving the packages in this venv.
The venv is automatically deleted before as soon as the packages have been
resolved.
"""

# This is only used in tests by `test_resolver`
cached_venv: Optional[Path] = None

@staticmethod
def _venv_create(venv_dir: Path, uv_exe: Optional[str] = None) -> None:
"""Create a new virtualenv at the given venv_dir."""
if uv_exe is None: # use venv module
venv.create(venv_dir, clear=True, with_pip=True)
else:
subprocess.run(
[uv_exe, "venv", "--python", sys.executable, str(venv_dir)], # noqa: S603
check=True,
)

@staticmethod
def _venv_install_cmd(venv_dir: Path, uv_exe: Optional[str] = None) -> List[str]:
"""Return argv prefix for installing packages into the given venv.

Construct the initial part of the command line (argv) for installing one
or more packages into the given venv_dir. The caller will append one or
more packages to the returned list, and run it via subprocess.run().
"""
if sys.platform.startswith("win"): # Windows
python_exe = venv_dir / "Scripts" / "python.exe"
else: # Assume POSIX
python_exe = venv_dir / "bin" / "python"

if uv_exe is None: # use `$python_exe -m pip install`
return [
f"{python_exe}",
"-m",
"pip",
"install",
"--no-deps",
"--quiet",
"--disable-pip-version-check",
]
# else use `uv pip install`
return [
uv_exe,
"pip",
"install",
f"--python={python_exe}",
"--no-deps",
"--quiet",
]

@classmethod
@contextmanager
def installed_requirements(
venv_dir: Path, requirements: List[str]
cls, venv_dir: Path, requirements: List[str]
) -> Iterator[Path]:
"""Install the given requirements into venv_dir with `pip install`.
"""Install the given requirements into venv_dir.

We try to install as many of the given requirements as possible. Failed
requirements will be logged with warning messages, but no matter how
many failures we get, we will still enter the caller's context. It is
up to the caller to handle any requirements that we failed to install.
"""
uv_exe = shutil.which("uv") # None -> fall back to venv/pip

marker_file = venv_dir / ".installed"
if not marker_file.is_file():
venv.create(venv_dir, clear=True, with_pip=True)
# Capture output from `pip install` to prevent polluting our own stdout
pip_install_runner = partial(
subprocess.run,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
check=False,
)
if sys.platform.startswith("win"): # Windows
pip_path = venv_dir / "Scripts" / "pip.exe"
else: # Assume POSIX
pip_path = venv_dir / "bin" / "pip"
cls._venv_create(venv_dir, uv_exe)

def install_helper(*packages: str) -> int:
"""Install the given package(s) into venv_dir.

Return the subprocess exit code from the install process.
"""
argv = cls._venv_install_cmd(venv_dir, uv_exe) + list(packages)
proc = subprocess.run(
argv, # noqa: S603
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
check=False,
)
if proc.returncode: # log warnings on failure
logger.warning("Command failed (%i): %s", proc.returncode, argv)
if proc.stdout.strip():
logger.warning("Output:\n%s", proc.stdout)
return proc.returncode

argv = [
f"{pip_path}",
"install",
"--no-deps",
"--quiet",
"--disable-pip-version-check",
]
proc = pip_install_runner(argv + requirements)
if proc.returncode: # pip install failed
logger.warning("Command failed: %s", argv + requirements)
if proc.stdout.strip():
logger.warning("Output:\n%s", proc.stdout)
if install_helper(*requirements): # install failed
logger.info("Retrying each requirement individually...")
for req in requirements:
proc = pip_install_runner([*argv, req])
if proc.returncode: # pip install failed
if install_helper(req):
logger.warning("Failed to install %s", repr(req))
if proc.stdout.strip():
logger.warning("Output:\n%s", proc.stdout)

marker_file.touch()
yield venv_dir

Expand All @@ -466,8 +507,8 @@ def temp_installed_requirements(cls, requirements: List[str]) -> Iterator[Path]:
"""Create a temporary venv and install the given requirements into it.

Provide a path to the temporary venv into the caller's context in which
the given requirements have been `pip install`ed. Automatically remove
the venv at the end of the context.
the given requirements have been installed. Automatically remove the
venv at the end of the context.

Installation is done on a "best effort" basis as documented by
.installed_requirements() above. The caller is expected to handle any
Expand All @@ -478,19 +519,20 @@ def temp_installed_requirements(cls, requirements: List[str]) -> Iterator[Path]:
yield venv_dir

def lookup_packages(self, package_names: Set[str]) -> Dict[str, Package]:
"""Convert package names into Package objects via temporary pip install.
"""Convert package names into Package objects via temporary auto-install.

Use the temp_installed_requirements() above to `pip install` the given
package names into a temporary venv, and then use LocalPackageResolver
on this venv to provide the Package objects that correspond to the
package names.
Use the temp_installed_requirements() above to install the given package
names into a temporary venv, then use LocalPackageResolver on this venv
to provide the Package objects that correspond to the package names.
"""
if self.cached_venv is None:
# Use .temp_installed_requirements() to create a new virtualenv for
# installing these packages (and then automatically remove it).
installed = self.temp_installed_requirements
logger.info("Installing dependencies into a temporary Python environment.")
# If self.cached_venv has been set, then use that path instead of creating
# a temporary venv for package installation.
else:
# self.cached_venv has been set, so pass that path directly to
# .installed_requirements() instead of creating a temporary dir.
installed = partial(self.installed_requirements, self.cached_venv)
logger.info(f"Installing dependencies into {self.cached_venv}.")
with installed(sorted(package_names)) as venv_dir:
Expand All @@ -499,7 +541,7 @@ def lookup_packages(self, package_names: Set[str]) -> Dict[str, Package]:
name: replace(
package,
resolved_with=self.__class__,
debug_info="Provided by temporary `pip install`",
debug_info="Provided by temporary auto-install",
)
for name, package in resolver.lookup_packages(package_names).items()
}
Expand Down Expand Up @@ -550,7 +592,7 @@ def setup_resolvers(
yield SysPathPackageResolver()

if install_deps:
yield TemporaryPipInstallResolver()
yield TemporaryAutoInstallResolver()
else:
yield IdentityMapping()

Expand Down
10 changes: 8 additions & 2 deletions noxfile.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import hashlib
import os
import shutil
from pathlib import Path
from typing import Iterable

import nox

python_versions = ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]

# Use 'uv' to manager Nox' virtualenvs, if available
if shutil.which("uv"):
nox.options.default_venv_backend = "uv"


def patch_binaries_if_needed(session: nox.Session, venv_dir: str) -> None:
"""If we are on Nix, auto-patch any binaries under `venv_dir`.
Expand Down Expand Up @@ -76,12 +81,13 @@ def install_groups(
hashfile.write_text(digest)

session.install("-r", str(requirements_txt))
if include_self:
session.install("-e", ".")

if not session.virtualenv._reused: # noqa: SLF001
patch_binaries_if_needed(session, session.virtualenv.location)

if include_self:
session.install("-e", ".")


@nox.session(python=python_versions)
def tests(session):
Expand Down
51 changes: 39 additions & 12 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading