diff --git a/changelog.d/1392.feature.md b/changelog.d/1392.feature.md new file mode 100644 index 0000000000..53080ae933 --- /dev/null +++ b/changelog.d/1392.feature.md @@ -0,0 +1 @@ +Support different backends (uv, virtualenv, venv) and installers (uv, pip) diff --git a/docs/backend.md b/docs/backend.md new file mode 100644 index 0000000000..a72eafa9d2 --- /dev/null +++ b/docs/backend.md @@ -0,0 +1,37 @@ +Starting from version 1.8.0, pipx supports different backends and installers. + +### Backends supported: +- `venv` (Default) +- `uv` (via `uv venv`) +- `virtualenv` + +### Installers supported: +- `pip` (Default) +- `uv` (via `uv pip`) + +> [!NOTE] +> If `uv` or `virtualenv` is not present in PATH, you should install them with `pipx install uv` or `pipx install virtualenv` in advance. + +If you wish to use a different backend or installer, you can either: + +- Pass command line arguments (`--backend`, `--installer`) +- Set environment variables (`PIPX_DEFAULT_BACKEND`, `PIPX_DEFAULT_INSTALLER`) + +> [!NOTE] +> Command line arguments always have higher precedence than environment variables. + +### Examples +```bash +# Use uv as backend and installer +pipx install --backend uv --installer uv black + +# Use virtualenv as backend and uv as installer +pipx install --backend virtualenv --installer uv black +``` + +Use environment variables to set backend and installer: +```bash +export PIPX_DEFAULT_BACKEND=uv +export PIPX_DEFAULT_INSTALLER=uv +pipx install black +``` diff --git a/docs/examples.md b/docs/examples.md index 2c79c54830..7dfd63f2a3 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -21,6 +21,7 @@ pipx install --index-url https://test.pypi.org/simple/ --pip-args='--extra-index pipx --global install pycowsay pipx install . pipx install path/to/some-project +pipx install --backend uv --installer uv black ``` ## `pipx run` examples @@ -41,6 +42,7 @@ pipx run pycowsay --version # prints pycowsay version pipx run --python pythonX pycowsay pipx run pycowsay==2.0 --version pipx run pycowsay[dev] --version +pipx run --backend uv --installer uv pycowsay pipx run --spec git+https://github.com/psf/black.git black pipx run --spec git+https://github.com/psf/black.git@branch-name black pipx run --spec git+https://github.com/psf/black.git@git-hash black diff --git a/mkdocs.yml b/mkdocs.yml index 3bc9212a67..35c575aef8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -31,6 +31,7 @@ nav: - Getting Started: "getting-started.md" - Docs: "docs.md" - Troubleshooting: "troubleshooting.md" + - Backend: "backend.md" - Examples: "examples.md" - Comparison to Other Tools: "comparisons.md" - How pipx works: "how-pipx-works.md" diff --git a/noxfile.py b/noxfile.py index d16eac0584..56111d4c4c 100644 --- a/noxfile.py +++ b/noxfile.py @@ -17,7 +17,14 @@ "markdown-gfm-admonition", ] MAN_DEPENDENCIES = ["argparse-manpage[setuptools]"] -TEST_DEPENDENCIES = ["pytest", "pypiserver[passlib]", 'setuptools; python_version>="3.12"', "pytest-cov"] +TEST_DEPENDENCIES = [ + "pytest", + "pypiserver[passlib]", + 'setuptools; python_version>="3.12"', + "pytest-cov", + "uv", + "virtualenv", +] # Packages whose dependencies need an intact system PATH to compile # pytest setup clears PATH. So pre-build some wheels to the pip cache. PREBUILD_PACKAGES = {"all": ["jupyter==1.0.0"], "macos": [], "unix": [], "win": []} diff --git a/src/pipx/backend.py b/src/pipx/backend.py new file mode 100644 index 0000000000..683fdb085c --- /dev/null +++ b/src/pipx/backend.py @@ -0,0 +1,24 @@ +import logging +import os +import shutil + +DEFAULT_BACKEND = os.getenv("PIPX_DEFAULT_BACKEND", "venv") +DEFAULT_INSTALLER = os.getenv("PIPX_DEFAULT_INSTALLER", "pip") +SUPPORTED_VENV_BACKENDS = ("uv", "venv", "virtualenv") +SUPPORTED_INSTALLERS = ("uv", "pip") + +logger = logging.getLogger(__name__) + + +def path_to_exec(executable: str, is_installer: bool = False) -> str: + if executable in ("venv", "pip"): + return executable + path = shutil.which(executable) + if path: + return path + elif is_installer: + logger.warning(f"'{executable}' not found on PATH. Falling back to 'pip'.") + return "" + else: + logger.warning(f"'{executable}' not found on PATH. Falling back to 'venv'.") + return "" diff --git a/src/pipx/commands/install.py b/src/pipx/commands/install.py index dbf26ba28f..7e8eb8178a 100644 --- a/src/pipx/commands/install.py +++ b/src/pipx/commands/install.py @@ -34,6 +34,8 @@ def install( preinstall_packages: Optional[List[str]], suffix: str = "", python_flag_passed=False, + backend: str, + installer: str, ) -> ExitCode: """Returns pipx exit code.""" # package_spec is anything pip-installable, including package_name, vcs spec, @@ -58,7 +60,7 @@ def install( except StopIteration: exists = False - venv = Venv(venv_dir, python=python, verbose=verbose) + venv = Venv(venv_dir, python=python, verbose=verbose, backend=backend, installer=installer) venv.check_upgrade_shared_libs(pip_args=pip_args, verbose=verbose) if exists: if not reinstall and force and python_flag_passed: @@ -214,6 +216,8 @@ def install_all( include_dependencies=main_package.include_dependencies, preinstall_packages=[], suffix=main_package.suffix, + backend=venv_metadata.backend, + installer=venv_metadata.installer, ) # Install the injected packages diff --git a/src/pipx/commands/interpreter.py b/src/pipx/commands/interpreter.py index 6fea85b7b1..9f90ceaffd 100644 --- a/src/pipx/commands/interpreter.py +++ b/src/pipx/commands/interpreter.py @@ -134,6 +134,8 @@ def upgrade_interpreters(venv_container: VenvContainer, verbose: bool): local_man_dir=paths.ctx.man_dir, python=str(interpreter_python), verbose=verbose, + backend=venv.pipx_metadata.backend, + installer=venv.pipx_metadata.installer, ) upgraded.append((venv.name, interpreter_full_version, latest_micro_version)) diff --git a/src/pipx/commands/reinstall.py b/src/pipx/commands/reinstall.py index 72011c5deb..413a242576 100644 --- a/src/pipx/commands/reinstall.py +++ b/src/pipx/commands/reinstall.py @@ -25,6 +25,8 @@ def reinstall( local_man_dir: Path, python: str, verbose: bool, + backend: str, + installer: str, force_reinstall_shared_libs: bool = False, python_flag_passed: bool = False, ) -> ExitCode: @@ -76,6 +78,8 @@ def reinstall( preinstall_packages=[], suffix=venv.pipx_metadata.main_package.suffix, python_flag_passed=python_flag_passed, + backend=backend, + installer=installer, ) # now install injected packages @@ -105,6 +109,8 @@ def reinstall_all( local_man_dir: Path, python: str, verbose: bool, + backend: str, + installer: str, *, skip: Sequence[str], python_flag_passed: bool = False, @@ -127,6 +133,8 @@ def reinstall_all( local_man_dir=local_man_dir, python=python, verbose=verbose, + backend=backend, + installer=installer, force_reinstall_shared_libs=first_reinstall, python_flag_passed=python_flag_passed, ) diff --git a/src/pipx/commands/run.py b/src/pipx/commands/run.py index c68dfcf8a2..d5f76874ad 100644 --- a/src/pipx/commands/run.py +++ b/src/pipx/commands/run.py @@ -78,6 +78,8 @@ def run_script( venv_args: List[str], verbose: bool, use_cache: bool, + backend: str, + installer: str, ) -> NoReturn: requirements = _get_requirements_from_script(content) if requirements is None: @@ -96,7 +98,7 @@ def run_script( if venv_dir.exists(): logger.info(f"Reusing cached venv {venv_dir}") else: - venv = Venv(venv_dir, python=python, verbose=verbose) + venv = Venv(venv_dir, python=python, verbose=verbose, backend=backend, installer=installer) venv.check_upgrade_shared_libs(pip_args=pip_args, verbose=verbose) venv.create_venv(venv_args, pip_args) venv.install_unmanaged_packages(requirements, pip_args) @@ -118,6 +120,8 @@ def run_package( pypackages: bool, verbose: bool, use_cache: bool, + backend: str, + installer: str, ) -> NoReturn: if which(app): logger.warning( @@ -151,7 +155,7 @@ def run_package( venv_dir = _get_temporary_venv_path([package_or_url], python, pip_args, venv_args) - venv = Venv(venv_dir) + venv = Venv(venv_dir, backend=backend, installer=installer) bin_path = venv.bin_path / app_filename _prepare_venv_cache(venv, bin_path, use_cache) @@ -171,6 +175,8 @@ def run_package( venv_args, use_cache, verbose, + backend, + installer, ) @@ -185,6 +191,8 @@ def run( pypackages: bool, verbose: bool, use_cache: bool, + backend: str, + installer: str, ) -> NoReturn: """Installs venv to temporary dir (or reuses cache), then runs app from package @@ -200,7 +208,7 @@ def run( content = None if spec is not None else maybe_script_content(app, is_path) if content is not None: - run_script(content, app_args, python, pip_args, venv_args, verbose, use_cache) + run_script(content, app_args, python, pip_args, venv_args, verbose, use_cache, backend, installer) else: package_or_url = spec if spec is not None else app run_package( @@ -213,6 +221,8 @@ def run( pypackages, verbose, use_cache, + backend, + installer, ) @@ -227,8 +237,10 @@ def _download_and_run( venv_args: List[str], use_cache: bool, verbose: bool, + backend: str, + installer: str, ) -> NoReturn: - venv = Venv(venv_dir, python=python, verbose=verbose) + venv = Venv(venv_dir, python=python, verbose=verbose, backend=backend, installer=installer) venv.check_upgrade_shared_libs(pip_args=pip_args, verbose=verbose) if venv.pipx_metadata.main_package.package is not None: diff --git a/src/pipx/commands/upgrade.py b/src/pipx/commands/upgrade.py index c5ea6f4d24..bbc5490dcf 100644 --- a/src/pipx/commands/upgrade.py +++ b/src/pipx/commands/upgrade.py @@ -108,6 +108,8 @@ def _upgrade_venv( venv_dir: Path, pip_args: List[str], verbose: bool, + backend: str, + installer: str, *, include_injected: bool, upgrading_all: bool, @@ -137,6 +139,8 @@ def _upgrade_venv( include_dependencies=False, preinstall_packages=None, python_flag_passed=python_flag_passed, + backend=backend, + installer=installer, ) return 0 else: @@ -153,6 +157,9 @@ def _upgrade_venv( if python and not install: logger.info("Ignoring --python as not combined with --install") + if (backend or installer) and not install: + logger.info("Ignoring --backend or --installer as not combined with --install") + venv = Venv(venv_dir, verbose=verbose) venv.check_upgrade_shared_libs(pip_args=pip_args, verbose=verbose) @@ -201,6 +208,8 @@ def upgrade( pip_args: List[str], venv_args: List[str], verbose: bool, + backend: str, + installer: str, *, include_injected: bool, force: bool, @@ -214,6 +223,8 @@ def upgrade( venv_dir, pip_args, verbose, + backend, + installer, include_injected=include_injected, upgrading_all=False, force=force, @@ -251,6 +262,8 @@ def upgrade_all( venv_dir, venv.pipx_metadata.main_package.pip_args, verbose=verbose, + backend=venv.pipx_metadata.backend, + installer=venv.pipx_metadata.installer, include_injected=include_injected, upgrading_all=True, force=force, diff --git a/src/pipx/main.py b/src/pipx/main.py index 4800f64313..e17a5f5679 100644 --- a/src/pipx/main.py +++ b/src/pipx/main.py @@ -21,6 +21,7 @@ from pipx import commands, constants, paths from pipx.animate import hide_cursor, show_cursor +from pipx.backend import DEFAULT_BACKEND, DEFAULT_INSTALLER, SUPPORTED_INSTALLERS, SUPPORTED_VENV_BACKENDS from pipx.colors import bold, green from pipx.commands.environment import ENVIRONMENT_VARIABLES from pipx.constants import ( @@ -275,6 +276,8 @@ def run_pipx_command(args: argparse.Namespace, subparsers: Dict[str, argparse.Ar args.pypackages, verbose, not args.no_cache, + args.backend, + args.installer, ) # We should never reach here because run() is NoReturn. return ExitCode(1) @@ -295,6 +298,8 @@ def run_pipx_command(args: argparse.Namespace, subparsers: Dict[str, argparse.Ar preinstall_packages=args.preinstall, suffix=args.suffix, python_flag_passed=python_flag_passed, + backend=args.backend, + installer=args.installer, ) elif args.command == "install-all": return commands.install_all( @@ -336,6 +341,8 @@ def run_pipx_command(args: argparse.Namespace, subparsers: Dict[str, argparse.Ar pip_args, venv_args, verbose, + args.backend, + args.installer, include_injected=args.include_injected, force=args.force, install=args.install, @@ -396,6 +403,8 @@ def run_pipx_command(args: argparse.Namespace, subparsers: Dict[str, argparse.Ar local_man_dir=paths.ctx.man_dir, python=args.python, verbose=verbose, + backend=args.backend, + installer=args.installer, python_flag_passed=python_flag_passed, ) elif args.command == "reinstall-all": @@ -405,6 +414,8 @@ def run_pipx_command(args: argparse.Namespace, subparsers: Dict[str, argparse.Ar paths.ctx.man_dir, args.python, verbose, + args.backend, + args.installer, skip=skip_list, python_flag_passed=python_flag_passed, ) @@ -450,6 +461,13 @@ def add_include_dependencies(parser: argparse.ArgumentParser) -> None: parser.add_argument("--include-deps", help="Include apps of dependent packages", action="store_true") +def add_backend_and_installer(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--backend", help="Virtual environment backend to use", choices=SUPPORTED_VENV_BACKENDS, default=DEFAULT_BACKEND + ) + parser.add_argument("--installer", help="Installer to use", choices=SUPPORTED_INSTALLERS, default=DEFAULT_INSTALLER) + + def add_python_options(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--python", @@ -502,6 +520,7 @@ def _add_install(subparsers: argparse._SubParsersAction, shared_parser: argparse ), ) add_pip_venv_args(p) + add_backend_and_installer(p) def _add_install_all(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None: @@ -659,6 +678,7 @@ def _add_upgrade(subparsers, venv_completer: VenvCompleter, shared_parser: argpa help="Install package spec if missing", ) add_python_options(p) + add_backend_and_installer(p) def _add_upgrade_all(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None: @@ -732,6 +752,7 @@ def _add_reinstall(subparsers, venv_completer: VenvCompleter, shared_parser: arg ) p.add_argument("package").completer = venv_completer add_python_options(p) + add_backend_and_installer(p) def _add_reinstall_all(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None: @@ -753,6 +774,7 @@ def _add_reinstall_all(subparsers: argparse._SubParsersAction, shared_parser: ar parents=[shared_parser], ) add_python_options(p) + add_backend_and_installer(p) p.add_argument("--skip", nargs="+", default=[], help="skip these packages") @@ -849,6 +871,7 @@ def _add_run(subparsers: argparse._SubParsersAction, shared_parser: argparse.Arg p.add_argument("--spec", help=SPEC_HELP) add_python_options(p) add_pip_venv_args(p) + add_backend_and_installer(p) p.set_defaults(subparser=p) # modify usage text to show required app argument diff --git a/src/pipx/pipx_metadata_file.py b/src/pipx/pipx_metadata_file.py index 3362698e9f..f99b7f4d56 100644 --- a/src/pipx/pipx_metadata_file.py +++ b/src/pipx/pipx_metadata_file.py @@ -54,7 +54,8 @@ class PipxMetadata: # V0.3 -> Add man pages fields # V0.4 -> Add source interpreter # V0.5 -> Add pinned - __METADATA_VERSION__: str = "0.5" + # V0.6 -> Add installer, backend + __METADATA_VERSION__: str = "0.6" def __init__(self, venv_dir: Path, read: bool = True): self.venv_dir = venv_dir @@ -83,6 +84,8 @@ def __init__(self, venv_dir: Path, read: bool = True): self.source_interpreter: Optional[Path] = None self.venv_args: List[str] = [] self.injected_packages: Dict[str, PackageInfo] = {} + self.backend: str = "" + self.installer: str = "" if read: self.read() @@ -90,6 +93,8 @@ def __init__(self, venv_dir: Path, read: bool = True): def to_dict(self) -> Dict[str, Any]: return { "main_package": asdict(self.main_package), + "backend": self.backend, + "installer": self.installer, "python_version": self.python_version, "source_interpreter": self.source_interpreter, "venv_args": self.venv_args, @@ -100,6 +105,9 @@ def to_dict(self) -> Dict[str, Any]: def _convert_legacy_metadata(self, metadata_dict: Dict[str, Any]) -> Dict[str, Any]: if metadata_dict["pipx_metadata_version"] in (self.__METADATA_VERSION__): pass + elif metadata_dict["pipx_metadata_version"] == "0.5": + metadata_dict["backend"] = "venv" + metadata_dict["installer"] = "pip" elif metadata_dict["pipx_metadata_version"] == "0.4": metadata_dict["pinned"] = False elif metadata_dict["pipx_metadata_version"] in ("0.2", "0.3"): @@ -132,6 +140,8 @@ def from_dict(self, input_dict: Dict[str, Any]) -> None: f"{name}{data.get('suffix', '')}": PackageInfo(**data) for (name, data) in input_dict["injected_packages"].items() } + self.backend = input_dict["backend"] if input_dict.get("backend") else "venv" + self.installer = input_dict["installer"] if input_dict.get("installer") else "pip" def _validate_before_write(self) -> None: if ( diff --git a/src/pipx/util.py b/src/pipx/util.py index 0951232373..e5edbdedf5 100644 --- a/src/pipx/util.py +++ b/src/pipx/util.py @@ -325,7 +325,8 @@ def analyze_pip_output(pip_stdout: str, pip_stderr: str) -> None: print(f" {relevant_saved[0]}", file=sys.stderr) -def subprocess_post_check_handle_pip_error( +def subprocess_post_check_handle_installer_error( + installer: str, completed_process: "subprocess.CompletedProcess[str]", ) -> None: if completed_process.returncode: @@ -333,19 +334,21 @@ def subprocess_post_check_handle_pip_error( # Save STDOUT and STDERR to file in pipx/logs/ if paths.ctx.log_file is None: raise PipxError("Pipx internal error: No log_file present.") - pip_error_file = paths.ctx.log_file.parent / (paths.ctx.log_file.stem + "_pip_errors.log") - with pip_error_file.open("a", encoding="utf-8") as pip_error_fh: - print("PIP STDOUT", file=pip_error_fh) - print("----------", file=pip_error_fh) + installer_error_file = paths.ctx.log_file.parent / (paths.ctx.log_file.stem + f"_{installer}_errors.log") + with installer_error_file.open("a", encoding="utf-8") as installer_error_fh: + print(f"{installer.upper()} STDOUT", file=installer_error_fh) + print("----------", file=installer_error_fh) if completed_process.stdout is not None: - print(completed_process.stdout, file=pip_error_fh, end="") - print("\nPIP STDERR", file=pip_error_fh) - print("----------", file=pip_error_fh) + print(completed_process.stdout, file=installer_error_fh, end="") + print(f"\n{installer.upper()} STDERR", file=installer_error_fh) + print("----------", file=installer_error_fh) if completed_process.stderr is not None: - print(completed_process.stderr, file=pip_error_fh, end="") - - logger.error(f"Fatal error from pip prevented installation. Full pip output in file:\n {pip_error_file}") + print(completed_process.stderr, file=installer_error_fh, end="") + logger.error( + f"Fatal error from {installer} prevented installation. Full {installer} output in file:\n {installer_error_file}" + ) + # TODO: we need to check whether uv's output is similar to the one in pip, if yes, we can keep this function analyze_pip_output(completed_process.stdout, completed_process.stderr) diff --git a/src/pipx/venv.py b/src/pipx/venv.py index 422de3da08..a157181081 100644 --- a/src/pipx/venv.py +++ b/src/pipx/venv.py @@ -17,6 +17,7 @@ from packaging.utils import canonicalize_name from pipx.animate import animate +from pipx.backend import DEFAULT_BACKEND, DEFAULT_INSTALLER, path_to_exec from pipx.constants import PIPX_SHARED_PTH, ExitCode from pipx.emojis import hazard from pipx.interpreter import DEFAULT_PYTHON @@ -38,7 +39,7 @@ rmdir, run_subprocess, subprocess_post_check, - subprocess_post_check_handle_pip_error, + subprocess_post_check_handle_installer_error, ) from pipx.venv_inspect import VenvMetadata, inspect_venv @@ -83,18 +84,37 @@ def get_venv_dir(self, package_name: str) -> Path: class Venv: """Abstraction for a virtual environment with various useful methods for pipx""" - def __init__(self, path: Path, *, verbose: bool = False, python: str = DEFAULT_PYTHON) -> None: + def __init__( + self, + path: Path, + *, + verbose: bool = False, + backend: str = DEFAULT_BACKEND, + installer: str = DEFAULT_INSTALLER, + python: str = DEFAULT_PYTHON, + ) -> None: self.root = path self.python = python self.bin_path, self.python_path, self.man_path = get_venv_paths(self.root) self.pipx_metadata = PipxMetadata(venv_dir=path) self.verbose = verbose self.do_animation = not verbose + self.backend = backend + self.installer = installer try: self._existing = self.root.exists() and bool(next(self.root.iterdir())) except StopIteration: self._existing = False + if self._existing: + self.backend = self.pipx_metadata.backend + self.installer = self.pipx_metadata.installer + + if not path_to_exec(self.backend): + self.backend = "venv" + if not path_to_exec(self.installer, is_installer=True): + self.installer = "pip" + def check_upgrade_shared_libs(self, verbose: bool, pip_args: List[str], force_upgrade: bool = False): """ If necessary, run maintenance tasks to keep the shared libs up-to-date. @@ -137,8 +157,11 @@ def uses_shared_libs(self) -> bool: if self._existing: pth_files = self.root.glob("**/" + PIPX_SHARED_PTH) return next(pth_files, None) is not None + elif self.backend == "uv" and self.installer == "uv": + # No need to use shared lib for uv if both backend and installer are uv + return False else: - # always use shared libs when creating a new venv + # always use shared libs when creating a new venv with venv and virtualenv return True @property @@ -162,15 +185,22 @@ def create_venv(self, venv_args: List[str], pip_args: List[str], override_shared override_shared -- Override installing shared libraries to the pipx shared directory (default False) """ logger.info("Creating virtual environment") - with animate("creating virtual environment", self.do_animation): - cmd = [self.python, "-m", "venv"] - if not override_shared: - cmd.append("--without-pip") + with animate(f"creating virtual environment using {self.backend}", self.do_animation): + if self.backend == "venv": + cmd = [self.python, "-m", "venv"] + if not override_shared: + cmd.append("--without-pip") + elif self.backend == "uv": + cmd = [path_to_exec("uv"), "venv", "--python", self.python] + elif self.backend == "virtualenv": + cmd = [path_to_exec("virtualenv"), "--python", self.python] + if not override_shared: + cmd.append("--without-pip") venv_process = run_subprocess(cmd + venv_args + [str(self.root)], run_dir=str(self.root)) subprocess_post_check(venv_process) - - shared_libs.create(verbose=self.verbose, pip_args=pip_args) - if not override_shared: + if self.backend != "uv" or self.installer != "uv": + shared_libs.create(verbose=self.verbose, pip_args=pip_args) + if not override_shared and (self.backend != "uv" or self.installer != "uv"): pipx_pth = get_site_packages(self.python_path) / PIPX_SHARED_PTH # write path pointing to the shared libs site-packages directory # example pipx_pth location: @@ -188,6 +218,8 @@ def create_venv(self, venv_args: List[str], pip_args: List[str], override_shared source_interpreter = shutil.which(self.python) if source_interpreter: self.pipx_metadata.source_interpreter = Path(source_interpreter) + if not self._existing: + self.pipx_metadata.backend = self.backend def safe_to_remove(self) -> bool: return not self._existing @@ -212,14 +244,19 @@ def upgrade_packaging_libraries(self, pip_args: List[str]) -> None: else: # TODO: setuptools and wheel? Original code didn't bother # but shared libs code does. - self.upgrade_package_no_metadata("pip", pip_args) + if self.backend != "uv": + self.upgrade_package_no_metadata("pip", pip_args) def uninstall_package(self, package: str, was_injected: bool = False): try: logger.info("Uninstalling %s", package) with animate(f"uninstalling {package}", self.do_animation): - cmd = ["uninstall", "-y"] + [package] - self._run_pip(cmd) + if self.installer == "pip": + cmd = ["uninstall", "-y"] + [package] + self._run_pip(cmd) + else: + cmd = ["uninstall"] + [package] + self._run_uv(cmd) except PipxError as e: logger.info(e) raise PipxError(f"Error uninstalling {package}.") from None @@ -246,24 +283,18 @@ def install_package( logger.info("Installing %s", package_descr := full_package_description(package_name, package_or_url)) with animate(f"installing {package_descr}", self.do_animation): - # do not use -q with `pip install` so subprocess_post_check_pip_errors - # has more information to analyze in case of failure. - cmd = [ - str(self.python_path), - "-m", - "pip", - "--no-input", - "install", - *pip_args, - package_or_url, - ] # no logging because any errors will be specially logged by - # subprocess_post_check_handle_pip_error() - pip_process = run_subprocess(cmd, log_stdout=False, log_stderr=False, run_dir=str(self.root)) - subprocess_post_check_handle_pip_error(pip_process) - if pip_process.returncode: + # subprocess_post_check_handle_installer_error() + install_process = self._run_installer( + [*pip_args, package_or_url], quiet=True, log_stdout=False, log_stderr=False + ) + subprocess_post_check_handle_installer_error(self.installer, install_process) + if install_process.returncode: raise PipxError(f"Error installing {full_package_description(package_name, package_or_url)}.") + if not self._existing: + self.pipx_metadata.installer = self.installer + self.update_package_metadata( package_name=package_name, package_or_url=package_or_url, @@ -291,37 +322,23 @@ def install_unmanaged_packages(self, requirements: List[str], pip_args: List[str # pip resolve conflicts correctly. logger.info("Installing %s", package_descr := ", ".join(requirements)) with animate(f"installing {package_descr}", self.do_animation): - # do not use -q with `pip install` so subprocess_post_check_pip_errors - # has more information to analyze in case of failure. - cmd = [ - str(self.python_path), - "-m", - "pip", - "--no-input", - "install", - *pip_args, - *requirements, - ] # no logging because any errors will be specially logged by - # subprocess_post_check_handle_pip_error() - pip_process = run_subprocess(cmd, log_stdout=False, log_stderr=False, run_dir=str(self.root)) - subprocess_post_check_handle_pip_error(pip_process) - if pip_process.returncode: + # subprocess_post_check_handle_installer_error() + install_process = self._run_installer( + [*pip_args, *requirements], quiet=True, log_stdout=False, log_stderr=False + ) + subprocess_post_check_handle_installer_error(self.installer, install_process) + if install_process.returncode: raise PipxError(f"Error installing {', '.join(requirements)}.") def install_package_no_deps(self, package_or_url: str, pip_args: List[str]) -> str: with animate(f"determining package name from {package_or_url!r}", self.do_animation): old_package_set = self.list_installed_packages() - cmd = [ - "--no-input", - "install", - "--no-dependencies", - *pip_args, - package_or_url, - ] - pip_process = self._run_pip(cmd) - subprocess_post_check(pip_process, raise_error=False) - if pip_process.returncode: + # TODO: rename pip_args to installer_args? But we can keep pip_args as implicit one + install_process = self._run_installer([*pip_args, package_or_url], no_deps=True) + + subprocess_post_check(install_process, raise_error=False) + if install_process.returncode: raise PipxError( f""" Cannot determine package name from spec {package_or_url!r}. @@ -347,7 +364,9 @@ def install_package_no_deps(self, package_or_url: str, pip_args: List[str]) -> s def get_venv_metadata_for_package(self, package_name: str, package_extras: Set[str]) -> VenvMetadata: data_start = time.time() - venv_metadata = inspect_venv(package_name, package_extras, self.bin_path, self.python_path, self.man_path) + venv_metadata = inspect_venv( + package_name, package_extras, self.bin_path, self.python_path, self.man_path, self.backend, self.installer + ) logger.info(f"get_venv_metadata_for_package: {1e3*(time.time()-data_start):.0f}ms") return venv_metadata @@ -441,8 +460,8 @@ def has_package(self, package_name: str) -> bool: def upgrade_package_no_metadata(self, package_name: str, pip_args: List[str]) -> None: logger.info("Upgrading %s", package_descr := full_package_description(package_name, package_name)) with animate(f"upgrading {package_descr}", self.do_animation): - pip_process = self._run_pip(["--no-input", "install"] + pip_args + ["--upgrade", package_name]) - subprocess_post_check(pip_process) + upgrade_process = self._run_installer([*pip_args, "--upgrade", package_name]) + subprocess_post_check(upgrade_process) def upgrade_package( self, @@ -456,8 +475,8 @@ def upgrade_package( ) -> None: logger.info("Upgrading %s", package_descr := full_package_description(package_name, package_or_url)) with animate(f"upgrading {package_descr}", self.do_animation): - pip_process = self._run_pip(["--no-input", "install"] + pip_args + ["--upgrade", package_or_url]) - subprocess_post_check(pip_process) + upgrade_process = self._run_installer([*pip_args, "--upgrade", package_or_url]) + subprocess_post_check(upgrade_process) self.update_package_metadata( package_name=package_name, @@ -469,12 +488,36 @@ def upgrade_package( suffix=suffix, ) - def _run_pip(self, cmd: List[str]) -> "CompletedProcess[str]": - cmd = [str(self.python_path), "-m", "pip"] + cmd - if not self.verbose: + def _run_installer( + self, + cmd: List[str], + quiet: bool = True, + no_deps: bool = False, + log_stdout: bool = True, + log_stderr: bool = True, + ) -> "CompletedProcess[str]": + # do not use -q with `pip install` so subprocess_post_check_pip_errors + # has more information to analyze in case of failure. + install_cmd = ["install"] + (["--no-deps"] if no_deps else []) + cmd + if self.installer == "pip": + return self._run_pip(["--no-input"] + install_cmd, quiet=quiet) + else: + return self._run_uv(["pip"] + install_cmd, quiet=quiet) + + def _run_uv(self, cmd: List[str], quiet: bool = True) -> "CompletedProcess[str]": + cmd = [path_to_exec("uv", is_installer=True)] + cmd + ["--python", str(self.python_path)] + if not self.verbose and quiet: cmd.append("-q") return run_subprocess(cmd, run_dir=str(self.root)) + def _run_pip( + self, cmd: List[str], quiet: bool = True, log_stdout: bool = True, log_stderr: bool = True + ) -> "CompletedProcess[str]": + cmd = [str(self.python_path), "-m", "pip"] + cmd + if not self.verbose and quiet: + cmd.append("-q") + return run_subprocess(cmd, log_stdout=log_stdout, log_stderr=log_stderr, run_dir=str(self.root)) + def run_pip_get_exit_code(self, cmd: List[str]) -> ExitCode: cmd = [str(self.python_path), "-m", "pip"] + cmd if not self.verbose: diff --git a/src/pipx/venv_inspect.py b/src/pipx/venv_inspect.py index 8d6b0ce400..86da1811e3 100644 --- a/src/pipx/venv_inspect.py +++ b/src/pipx/venv_inspect.py @@ -36,6 +36,8 @@ class VenvMetadata(NamedTuple): man_paths_of_dependencies: Dict[str, List[Path]] package_version: str python_version: str + backend: str + installer: str def get_dist(package: str, distributions: Collection[metadata.Distribution]) -> Optional[metadata.Distribution]: @@ -253,6 +255,8 @@ def inspect_venv( venv_bin_path: Path, venv_python_path: Path, venv_man_path: Path, + backend: str, + installer: str, ) -> VenvMetadata: app_paths_of_dependencies: Dict[str, List[Path]] = {} apps_of_dependencies: List[str] = [] @@ -316,4 +320,6 @@ def inspect_venv( man_paths_of_dependencies=man_paths_of_dependencies, package_version=root_dist.version, python_version=venv_python_version, + backend=backend, + installer=installer, ) diff --git a/testdata/pipx_metadata_multiple_errors.json b/testdata/pipx_metadata_multiple_errors.json index 7c00f184b1..ff1756839e 100644 --- a/testdata/pipx_metadata_multiple_errors.json +++ b/testdata/pipx_metadata_multiple_errors.json @@ -3,7 +3,9 @@ "venvs": { "dotenv": { "metadata": { + "backend": "venv", "injected_packages": {}, + "installer": "pip", "main_package": { "app_paths": [ ], @@ -32,7 +34,9 @@ }, "weblate": { "metadata": { + "backend": "venv", "injected_packages": {}, + "installer": "pip", "main_package": { "app_paths": [ ], diff --git a/tests/conftest.py b/tests/conftest.py index ea1b561996..207b359937 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -93,6 +93,12 @@ def pipx_temp_env_helper(pipx_shared_dir, tmp_path, monkeypatch, request, utils_ if "PIPX_DEFAULT_PYTHON" in os.environ: monkeypatch.delenv("PIPX_DEFAULT_PYTHON") + if "PIPX_DEFAULT_BACKEND" in os.environ: + monkeypatch.delenv("PIPX_DEFAULT_BACKEND") + + if "PIPX_DEFAULT_INSTALLER" in os.environ: + monkeypatch.delenv("PIPX_DEFAULT_INSTALLER") + # macOS needs /usr/bin in PATH to compile certain packages, but # applications in /usr/bin cause test_install.py tests to raise warnings # which make tests fail (e.g. on Github ansible apps exist in /usr/bin) @@ -181,7 +187,7 @@ def pipx_session_shared_dir(tmp_path_factory): @pytest.fixture(scope="session") def utils_temp_dir(tmp_path_factory): tmp_path = tmp_path_factory.mktemp("session_utilstempdir") - utils = ["git"] + utils = ["git", "uv", "virtualenv"] for util in utils: at_path = shutil.which(util) assert at_path is not None diff --git a/tests/test_backend.py b/tests/test_backend.py new file mode 100644 index 0000000000..1b8c055765 --- /dev/null +++ b/tests/test_backend.py @@ -0,0 +1,81 @@ +import os + +from helpers import WIN, run_pipx_cli + + +def test_custom_backend_venv(pipx_temp_env, capsys, caplog): + assert not run_pipx_cli(["install", "--backend", "venv", "black"]) + captured = capsys.readouterr() + assert "-m venv --without-pip" in caplog.text + assert "installed package" in captured.out + + +def test_custom_backend_virtualenv(pipx_temp_env, capsys, caplog): + assert not run_pipx_cli(["install", "--backend", "virtualenv", "nox"]) + captured = capsys.readouterr() + assert "virtualenv" in caplog.text + assert "installed package" in captured.out + + +def test_custom_backend_uv(pipx_temp_env, capsys, caplog): + assert not run_pipx_cli(["install", "--backend", "uv", "pylint"]) + captured = capsys.readouterr() + assert "uv" in caplog.text + assert "installed package" in captured.out + + +def test_custom_installer_pip(pipx_temp_env, capsys, caplog): + assert not run_pipx_cli(["install", "--installer", "pip", "pycowsay"]) + captured = capsys.readouterr() + assert "pip --no-input" in caplog.text + assert "installed package" in captured.out + + +def test_custom_installer_uv(pipx_temp_env, capsys, caplog): + assert not run_pipx_cli(["install", "--installer", "uv", "sphinx"]) + captured = capsys.readouterr() + assert "uv" in caplog.text + assert "installed package" in captured.out + + +def test_custom_installer_backend_uv(pipx_temp_env, capsys, caplog): + assert not run_pipx_cli(["install", "--installer", "uv", "--backend", "uv", "black"]) + captured = capsys.readouterr() + assert f"{'uv.EXE' if WIN else 'uv'} venv" in caplog.text + assert f"{'uv.EXE' if WIN else 'uv'} pip install" in caplog.text + assert "installed package" in captured.out + + +def test_custom_installer_uv_backend_venv(pipx_temp_env, capsys, caplog): + assert not run_pipx_cli(["install", "--installer", "uv", "--backend", "venv", "nox"]) + captured = capsys.readouterr() + assert "-m venv --without-pip" in caplog.text + assert f"{'uv.EXE' if WIN else 'uv'} pip install" in caplog.text + assert "installed package" in captured.out + + +def test_custom_installer_uv_backend_virtualenv(pipx_temp_env, capsys, caplog): + assert not run_pipx_cli(["install", "--installer", "uv", "--backend", "virtualenv", "pylint"]) + captured = capsys.readouterr() + assert "virtualenv" in caplog.text + assert f"{'uv.EXE' if WIN else 'uv'} pip install" in caplog.text + assert "installed package" in captured.out + + +def test_custom_installer_pip_backend_uv(pipx_temp_env, capsys, caplog): + assert not run_pipx_cli(["install", "--installer", "pip", "--backend", "uv", "nox"]) + captured = capsys.readouterr() + assert f"{'uv.EXE' if WIN else 'uv'} venv" in caplog.text + assert "-m pip" in caplog.text + assert "installed package" in captured.out + + +def test_fallback_to_default(monkeypatch, pipx_temp_env, capsys, caplog): + monkeypatch.setenv("PATH", os.getenv("PATH_TEST")) + assert not run_pipx_cli(["install", "black", "--backend", "virtualenv", "--installer", "uv"]) + captured = capsys.readouterr() + assert "'uv' not found on PATH" in caplog.text + assert "'virtualenv' not found on PATH" in caplog.text + assert "-m venv" in caplog.text + assert "-m pip" in caplog.text + assert "installed package" in captured.out