diff --git a/.ci/scripts/tests/test_package_install_command.py b/.ci/scripts/tests/test_package_install_command.py new file mode 100644 index 00000000000..4d1e4f39d66 --- /dev/null +++ b/.ci/scripts/tests/test_package_install_command.py @@ -0,0 +1,97 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import importlib.util +import sys +import unittest +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[3] +SPEC = importlib.util.spec_from_file_location( + "install_utils", ROOT / "install_utils.py" +) +assert SPEC is not None and SPEC.loader is not None +INSTALL_UTILS = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(INSTALL_UTILS) + + +class TestPackageInstallCommand(unittest.TestCase): + def setUp(self): + INSTALL_UTILS.get_package_install_command.cache_clear() + self.addCleanup(INSTALL_UTILS.get_package_install_command.cache_clear) + + def test_prefers_pip_when_available(self): + for uv in (None, "/test/bin/uv"): + with self.subTest(uv=uv): + INSTALL_UTILS.get_package_install_command.cache_clear() + with ( + patch.object( + INSTALL_UTILS.importlib.util, "find_spec", return_value=object() + ) as find_spec, + patch.object(INSTALL_UTILS.shutil, "which", return_value=uv), + ): + self.assertEqual( + INSTALL_UTILS.get_package_install_command(), + (sys.executable, "-m", "pip", "install"), + ) + find_spec.assert_called_once_with("pip") + + def test_uv_targets_running_python(self): + with ( + patch.object(INSTALL_UTILS.importlib.util, "find_spec", return_value=None), + patch.object( + INSTALL_UTILS.shutil, "which", return_value="/test/bin/uv" + ) as which, + ): + self.assertEqual( + INSTALL_UTILS.get_package_install_command(), + ( + "/test/bin/uv", + "pip", + "install", + "--python", + sys.executable, + "--index-strategy", + "unsafe-best-match", + ), + ) + which.assert_called_once_with("uv") + + def test_missing_installers_reports_setup_instructions(self): + with ( + patch.object(INSTALL_UTILS.importlib.util, "find_spec", return_value=None), + patch.object(INSTALL_UTILS.shutil, "which", return_value=None), + self.assertRaisesRegex( + RuntimeError, "Neither pip nor uv is available" + ) as error, + ): + INSTALL_UTILS.get_package_install_command() + message = str(error.exception) + self.assertIn(f"{sys.executable} -m ensurepip --upgrade", message) + self.assertIn( + "https://docs.astral.sh/uv/getting-started/installation/", message + ) + + def test_keeps_uv_after_pip_becomes_available(self): + with ( + patch.object( + INSTALL_UTILS.importlib.util, "find_spec", return_value=None + ) as find_spec, + patch.object( + INSTALL_UTILS.shutil, "which", return_value="/test/bin/uv" + ) as which, + ): + command = INSTALL_UTILS.get_package_install_command() + self.assertEqual(command[0], "/test/bin/uv") + find_spec.return_value = object() + self.assertEqual(INSTALL_UTILS.get_package_install_command(), command) + find_spec.assert_called_once_with("pip") + which.assert_called_once_with("uv") + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/source/using-executorch-building-from-source.md b/docs/source/using-executorch-building-from-source.md index e96d643c209..535af51f05d 100644 --- a/docs/source/using-executorch-building-from-source.md +++ b/docs/source/using-executorch-building-from-source.md @@ -61,6 +61,17 @@ portability details. conda activate executorch ``` +Alternatively, on macOS or Linux, create and activate a virtual environment with uv: + +```bash +uv venv --python 3.12 .venv +source .venv/bin/activate +``` + +The installation scripts use pip when it is available in the current Python +environment, or fall back to `uv pip` when uv is on `PATH`. There is no need to +seed pip before running the installer. + > **_NOTE:_** Additional Windows Setup > > ExecuTorch requires symlinks to be enabled to build the Python components. To enable symlinks, run the following command before cloning the repository. Missing symlinks will manifest as an error related to `version.py` when running `pip install .`. See [src/README.md](https://github.com/pytorch/executorch/blob/main/src/README.md) for more information. diff --git a/install_executorch.py b/install_executorch.py index 276841364e6..45714f52c81 100644 --- a/install_executorch.py +++ b/install_executorch.py @@ -20,6 +20,7 @@ install_requirements, python_is_compatible, ) +from install_utils import get_package_install_command # Set up logging logging.basicConfig( @@ -237,12 +238,7 @@ def main(args): extras = ",".join(dict.fromkeys(args.optional_dependency)) package_spec = f".[{extras}]" cmd = ( - [ - sys.executable, - "-m", - "pip", - "install", - ] + [*get_package_install_command()] + (["--editable"] if args.editable else []) + [ package_spec, diff --git a/install_requirements.py b/install_requirements.py index 285c4bf281f..76e9789b577 100644 --- a/install_requirements.py +++ b/install_requirements.py @@ -11,7 +11,12 @@ import subprocess import sys -from install_utils import determine_torch_url, is_intel_mac_os, python_is_compatible +from install_utils import ( + determine_torch_url, + get_package_install_command, + is_intel_mac_os, + python_is_compatible, +) # The pip repository that hosts nightly torch packages. # This will be dynamically set based on CUDA availability and CUDA backend enabled/disabled. @@ -114,10 +119,7 @@ def install_requirements(use_pytorch_nightly): # versions on the provided URL if they aren't available on the default URL. subprocess.run( [ - sys.executable, - "-m", - "pip", - "install", + *get_package_install_command(), "-r", "requirements-dev.txt", *TORCH_PACKAGE, @@ -142,10 +144,7 @@ def install_requirements(use_pytorch_nightly): ) subprocess.run( [ - sys.executable, - "-m", - "pip", - "install", + *get_package_install_command(), "--no-build-isolation", "sentencepiece==0.2.2", ], @@ -170,10 +169,7 @@ def install_requirements(use_pytorch_nightly): new_env["CMAKE_POLICY_VERSION_MINIMUM"] = "3.5" subprocess.run( [ - sys.executable, - "-m", - "pip", - "install", + *get_package_install_command(), # Without --no-build-isolation, setup.py can't find the torch module. "--no-build-isolation", *LOCAL_REQUIREMENTS, @@ -214,10 +210,7 @@ def install_optional_example_requirements(use_pytorch_nightly): # Then install domain libraries subprocess.run( [ - sys.executable, - "-m", - "pip", - "install", + *get_package_install_command(), *DOMAIN_LIBRARIES, "--extra-index-url", torch_url, @@ -229,18 +222,13 @@ def install_optional_example_requirements(use_pytorch_nightly): print("Installing packages in requirements-examples.txt") subprocess.run( [ - sys.executable, - "-m", - "pip", - "install", + *get_package_install_command(), "-r", "requirements-examples.txt", *cu134_packages, "--extra-index-url", torch_url, *torchao_index, - "--upgrade-strategy", - "only-if-needed", ], check=True, ) diff --git a/install_utils.py b/install_utils.py index 4e1397413b3..81b3d7faf6e 100644 --- a/install_utils.py +++ b/install_utils.py @@ -6,6 +6,7 @@ # LICENSE file in the root directory of this source tree. import functools +import importlib.util import os import platform import re @@ -25,6 +26,33 @@ ) +@functools.lru_cache(maxsize=1) +def get_package_install_command() -> Tuple[str, ...]: + """Choose an installer once, even if requirements later install pip.""" + if importlib.util.find_spec("pip") is not None: + return (sys.executable, "-m", "pip", "install") + + uv = shutil.which("uv") + if uv is not None: + return ( + uv, + "pip", + "install", + "--python", + sys.executable, + # Match pip's selection across the PyTorch and PyPI indexes. + "--index-strategy", + "unsafe-best-match", + ) + + raise RuntimeError( + "Neither pip nor uv is available. " + f'Install pip with "{sys.executable} -m ensurepip --upgrade", ' + "or install uv and ensure it is on PATH: " + "https://docs.astral.sh/uv/getting-started/installation/" + ) + + def is_cmake_option_on( cmake_configuration_args: List[str], var_name: str, default: bool ) -> bool: @@ -397,9 +425,7 @@ def python_is_compatible(): try: import packaging except ImportError: - subprocess.run( - [sys.executable, "-m", "pip", "install", "packaging"], check=True - ) + subprocess.run([*get_package_install_command(), "packaging"], check=True) # Compare the current python version to the range in version_specifier. Exits # with status 1 if the version is not compatible, or with status 0 if the # version is compatible or the logic itself fails.