diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 28745e526..922d8c5c3 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -1858,6 +1858,11 @@ pub struct PipSyncArgs { /// Install packages into the specified directory, rather than into the virtual or system Python /// environment. The packages will be installed at the top-level of the directory. + /// + /// Unlike other install operations, this command does not require discovery of an existing Python + /// environment and only searches for a Python interpreter to use for package resolution. + /// If a suitable Python interpreter cannot be found, uv will install one. + /// To disable this, add `--no-python-downloads`. #[arg(long, conflicts_with = "prefix")] pub target: Option, @@ -1868,6 +1873,11 @@ pub struct PipSyncArgs { /// scripts and other artifacts installed via `--prefix` will reference the installing /// interpreter, rather than any interpreter added to the `--prefix` directory, rendering them /// non-portable. + /// + /// Unlike other install operations, this command does not require discovery of an existing Python + /// environment and only searches for a Python interpreter to use for package resolution. + /// If a suitable Python interpreter cannot be found, uv will install one. + /// To disable this, add `--no-python-downloads`. #[arg(long, conflicts_with = "target")] pub prefix: Option, @@ -2187,6 +2197,11 @@ pub struct PipInstallArgs { /// Install packages into the specified directory, rather than into the virtual or system Python /// environment. The packages will be installed at the top-level of the directory. + /// + /// Unlike other install operations, this command does not require discovery of an existing Python + /// environment and only searches for a Python interpreter to use for package resolution. + /// If a suitable Python interpreter cannot be found, uv will install one. + /// To disable this, add `--no-python-downloads`. #[arg(long, conflicts_with = "prefix")] pub target: Option, @@ -2197,6 +2212,11 @@ pub struct PipInstallArgs { /// scripts and other artifacts installed via `--prefix` will reference the installing /// interpreter, rather than any interpreter added to the `--prefix` directory, rendering them /// non-portable. + /// + /// Unlike other install operations, this command does not require discovery of an existing Python + /// environment and only searches for a Python interpreter to use for package resolution. + /// If a suitable Python interpreter cannot be found, uv will install one. + /// To disable this, add `--no-python-downloads`. #[arg(long, conflicts_with = "target")] pub prefix: Option, diff --git a/crates/uv/src/commands/pip/install.rs b/crates/uv/src/commands/pip/install.rs index 9f98fe426..dc1bcda6a 100644 --- a/crates/uv/src/commands/pip/install.rs +++ b/crates/uv/src/commands/pip/install.rs @@ -27,14 +27,15 @@ use uv_normalize::{DefaultExtras, DefaultGroups, PackageName}; use uv_preview::{Preview, PreviewFeatures}; use uv_pypi_types::Conflicts; use uv_python::{ - EnvironmentPreference, Prefix, PythonEnvironment, PythonInstallation, PythonPreference, - PythonRequest, PythonVersion, Target, + EnvironmentPreference, Prefix, PythonDownloads, PythonEnvironment, PythonInstallation, + PythonPreference, PythonRequest, PythonVersion, Target, }; use uv_requirements::{GroupsSpecification, RequirementsSource, RequirementsSpecification}; use uv_resolver::{ DependencyMode, ExcludeNewer, FlatIndex, OptionsBuilder, PrereleaseMode, PylockToml, PythonRequirement, ResolutionMode, ResolverEnvironment, }; +use uv_settings::PythonInstallMirrors; use uv_torch::{TorchMode, TorchSource, TorchStrategy}; use uv_types::HashStrategy; use uv_warnings::{warn_user, warn_user_once}; @@ -45,6 +46,7 @@ use crate::commands::pip::loggers::{DefaultInstallLogger, DefaultResolveLogger, use crate::commands::pip::operations::Modifications; use crate::commands::pip::operations::{report_interpreter, report_target_environment}; use crate::commands::pip::{operations, resolution_markers, resolution_tags}; +use crate::commands::reporters::PythonDownloadReporter; use crate::commands::{ExitStatus, diagnostics}; use crate::printer::Printer; @@ -86,6 +88,8 @@ pub(crate) async fn pip_install( modifications: Modifications, python_version: Option, python_platform: Option, + python_downloads: PythonDownloads, + install_mirrors: PythonInstallMirrors, strict: bool, exclude_newer: ExcludeNewer, sources: SourceStrategy, @@ -191,16 +195,23 @@ pub(crate) async fn pip_install( // Detect the current Python interpreter. let environment = if target.is_some() || prefix.is_some() { - let installation = PythonInstallation::find( - &python - .as_deref() - .map(PythonRequest::parse) - .unwrap_or_default(), + let python_request = python.as_deref().map(PythonRequest::parse); + let reporter = PythonDownloadReporter::single(printer); + + let installation = PythonInstallation::find_or_download( + python_request.as_ref(), EnvironmentPreference::from_system_flag(system, false), python_preference.with_system_flag(system), + python_downloads, + &client_builder, &cache, + Some(&reporter), + install_mirrors.python_install_mirror.as_deref(), + install_mirrors.pypy_install_mirror.as_deref(), + install_mirrors.python_downloads_json_url.as_deref(), preview, - )?; + ) + .await?; report_interpreter(&installation, true, printer)?; PythonEnvironment::from_installation(installation) } else { diff --git a/crates/uv/src/commands/pip/sync.rs b/crates/uv/src/commands/pip/sync.rs index 8da323c6e..39c569288 100644 --- a/crates/uv/src/commands/pip/sync.rs +++ b/crates/uv/src/commands/pip/sync.rs @@ -25,14 +25,15 @@ use uv_normalize::{DefaultExtras, DefaultGroups}; use uv_preview::{Preview, PreviewFeatures}; use uv_pypi_types::Conflicts; use uv_python::{ - EnvironmentPreference, Prefix, PythonEnvironment, PythonInstallation, PythonPreference, - PythonRequest, PythonVersion, Target, + EnvironmentPreference, Prefix, PythonDownloads, PythonEnvironment, PythonInstallation, + PythonPreference, PythonRequest, PythonVersion, Target, }; use uv_requirements::{GroupsSpecification, RequirementsSource, RequirementsSpecification}; use uv_resolver::{ DependencyMode, ExcludeNewer, FlatIndex, OptionsBuilder, PrereleaseMode, PylockToml, PythonRequirement, ResolutionMode, ResolverEnvironment, }; +use uv_settings::PythonInstallMirrors; use uv_torch::{TorchMode, TorchSource, TorchStrategy}; use uv_types::HashStrategy; use uv_warnings::{warn_user, warn_user_once}; @@ -43,6 +44,7 @@ use crate::commands::pip::loggers::{DefaultInstallLogger, DefaultResolveLogger}; use crate::commands::pip::operations::Modifications; use crate::commands::pip::operations::{report_interpreter, report_target_environment}; use crate::commands::pip::{operations, resolution_markers, resolution_tags}; +use crate::commands::reporters::PythonDownloadReporter; use crate::commands::{ExitStatus, diagnostics}; use crate::printer::Printer; @@ -74,6 +76,8 @@ pub(crate) async fn pip_sync( build_options: BuildOptions, python_version: Option, python_platform: Option, + python_downloads: PythonDownloads, + install_mirrors: PythonInstallMirrors, strict: bool, exclude_newer: ExcludeNewer, python: Option, @@ -164,16 +168,23 @@ pub(crate) async fn pip_sync( // Detect the current Python interpreter. let environment = if target.is_some() || prefix.is_some() { - let installation = PythonInstallation::find( - &python - .as_deref() - .map(PythonRequest::parse) - .unwrap_or_default(), + let python_request = python.as_deref().map(PythonRequest::parse); + let reporter = PythonDownloadReporter::single(printer); + + let installation = PythonInstallation::find_or_download( + python_request.as_ref(), EnvironmentPreference::from_system_flag(system, false), python_preference.with_system_flag(system), + python_downloads, + &client_builder, &cache, + Some(&reporter), + install_mirrors.python_install_mirror.as_deref(), + install_mirrors.pypy_install_mirror.as_deref(), + install_mirrors.python_downloads_json_url.as_deref(), preview, - )?; + ) + .await?; report_interpreter(&installation, true, printer)?; PythonEnvironment::from_installation(installation) } else { diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 5bd250da7..568d25f9f 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -711,6 +711,8 @@ async fn run(mut cli: Cli) -> Result { args.settings.build_options, args.settings.python_version, args.settings.python_platform, + globals.python_downloads, + args.settings.install_mirrors, args.settings.strict, args.settings.exclude_newer, args.settings.python, @@ -862,6 +864,8 @@ async fn run(mut cli: Cli) -> Result { args.modifications, args.settings.python_version, args.settings.python_platform, + globals.python_downloads, + args.settings.install_mirrors, args.settings.strict, args.settings.exclude_newer, args.settings.sources, diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs index accb2aca4..e6d9b2672 100644 --- a/crates/uv/tests/it/pip_install.rs +++ b/crates/uv/tests/it/pip_install.rs @@ -13145,3 +13145,87 @@ fn install_with_system_interpreter() { " ); } + +/// Test that a missing Python version is not installed when not using `--target` or `--prefix`. +#[cfg(feature = "python-managed")] +#[test] +fn install_missing_python_no_target() { + // Create a context that only has Python 3.11 available. + let context = TestContext::new("3.11") + .with_python_download_cache() + .with_managed_python_dirs(); + + // Request Python 3.12; which should fail + uv_snapshot!(context.filters(), context.pip_install() + .arg("--python").arg("3.12") + .arg("anyio"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No virtual environment found for Python 3.12; run `uv venv` to create an environment, or pass `--system` to install into a non-virtual environment + "### + ); +} + +// If there are no python interpreters available, `uv pip install` into a target should install one. +#[cfg(feature = "python-managed")] +#[test] +fn install_missing_python_with_target() { + // Create a context with no installed python interpreters. + let context = TestContext::new_with_versions(&[]) + .with_python_download_cache() + .with_managed_python_dirs(); + + let target_dir = context.temp_dir.child("target-dir"); + + uv_snapshot!(context.filters(), context.pip_install() + .arg("anyio") + .arg("--target").arg(target_dir.path()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.14.0 + Resolved 3 packages in [TIME] + Prepared 3 packages in [TIME] + Installed 3 packages in [TIME] + + anyio==4.3.0 + + idna==3.6 + + sniffio==1.3.1 + "### + ); +} + +#[cfg(feature = "python-managed")] +#[test] +fn install_missing_python_version_with_target() { + // Create a context that only has Python 3.11 available. + let context = TestContext::new("3.11") + .with_python_download_cache() + .with_managed_python_dirs(); + + let target_dir = context.temp_dir.child("target-dir"); + + // Request Python 3.12 which is not installed in this context. + uv_snapshot!(context.filters(), context.pip_install() + .arg("anyio") + .arg("--python").arg("3.12") + .arg("--target").arg(target_dir.path()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.12 + Resolved 3 packages in [TIME] + Prepared 3 packages in [TIME] + Installed 3 packages in [TIME] + + anyio==4.3.0 + + idna==3.6 + + sniffio==1.3.1 + "### + ); +} diff --git a/crates/uv/tests/it/pip_sync.rs b/crates/uv/tests/it/pip_sync.rs index 6acd31714..57f718a98 100644 --- a/crates/uv/tests/it/pip_sync.rs +++ b/crates/uv/tests/it/pip_sync.rs @@ -6161,3 +6161,62 @@ fn incompatible_platform_direct_url() -> Result<()> { Ok(()) } + +/// Test that a missing Python version is not installed when not using `--target` or `--prefix`. +#[cfg(feature = "python-managed")] +#[test] +fn sync_missing_python_no_target() -> Result<()> { + // Create a context that only has Python 3.11 available. + let context = TestContext::new("3.11") + .with_python_download_cache() + .with_managed_python_dirs(); + + let requirements = context.temp_dir.child("requirements.txt"); + requirements.write_str("anyio")?; + + // Request Python 3.12; which should fail + uv_snapshot!(context.filters(), context.pip_sync() + .arg("--python").arg("3.12") + .arg("requirements.txt"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No virtual environment found for Python 3.12; run `uv venv` to create an environment, or pass `--system` to install into a non-virtual environment + "### + ); + Ok(()) +} + +#[cfg(feature = "python-managed")] +#[test] +fn sync_with_target_installs_missing_python() -> Result<()> { + // Create a context that only has Python 3.11 available. + let context = TestContext::new("3.11") + .with_python_download_cache() + .with_managed_python_dirs(); + + let target_dir = context.temp_dir.child("target-dir"); + let requirements = context.temp_dir.child("requirements.txt"); + requirements.write_str("anyio")?; + + // Request Python 3.12 which is not installed in this context. + uv_snapshot!(context.filters(), context.pip_sync() + .arg("requirements.txt") + .arg("--python").arg("3.12") + .arg("--target").arg(target_dir.path()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.12 + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + anyio==4.3.0 + "### + ); + Ok(()) +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 1d89006d1..1224bfff7 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -4463,6 +4463,7 @@ uv pip sync [OPTIONS] ...

Multiple packages may be provided. Disable binaries for all packages with :all:. Clear previously specified packages with :none:.

--prefix prefix

Install packages into lib, bin, and other top-level folders under the specified directory, as if a virtual environment were present at that location.

In general, prefer the use of --python to install into an alternate environment, as scripts and other artifacts installed via --prefix will reference the installing interpreter, rather than any interpreter added to the --prefix directory, rendering them non-portable.

+

Unlike other install operations, this command does not require discovery of an existing Python environment and only searches for a Python interpreter to use for package resolution. If a suitable Python interpreter cannot be found, uv will install one. To disable this, add --no-python-downloads.

--project project

Run the command within the given project directory.

All pyproject.toml, uv.toml, and .python-version files will be discovered by walking up the directory tree from the project root, as will the project's virtual environment (.venv).

Other command-line arguments (such as relative paths) will be resolved relative to the current working directory.

@@ -4545,7 +4546,8 @@ be used with caution, as it can modify the system Python installation.

--system

Install packages into the system Python environment.

By default, uv installs into the virtual environment in the current working directory or any parent directory. The --system option instructs uv to instead use the first Python found in the system PATH.

WARNING: --system is intended for use in continuous integration (CI) environments and should be used with caution, as it can modify the system Python installation.

-

May also be set with the UV_SYSTEM_PYTHON environment variable.

--target target

Install packages into the specified directory, rather than into the virtual or system Python environment. The packages will be installed at the top-level of the directory

+

May also be set with the UV_SYSTEM_PYTHON environment variable.

--target target

Install packages into the specified directory, rather than into the virtual or system Python environment. The packages will be installed at the top-level of the directory.

+

Unlike other install operations, this command does not require discovery of an existing Python environment and only searches for a Python interpreter to use for package resolution. If a suitable Python interpreter cannot be found, uv will install one. To disable this, add --no-python-downloads.

--torch-backend torch-backend

The backend to use when fetching packages in the PyTorch ecosystem (e.g., cpu, cu126, or auto).

When set, uv will ignore the configured index URLs for packages in the PyTorch ecosystem, and will instead use the defined backend.

For example, when set to cpu, uv will use the CPU-only PyTorch index; when set to cu126, uv will use the PyTorch index for CUDA 12.6.

@@ -4754,6 +4756,7 @@ uv pip install [OPTIONS] |--editable While constraints are additive, in that they're combined with the requirements of the constituent packages, overrides are absolute, in that they completely replace the requirements of the constituent packages.

May also be set with the UV_OVERRIDE environment variable.

--prefix prefix

Install packages into lib, bin, and other top-level folders under the specified directory, as if a virtual environment were present at that location.

In general, prefer the use of --python to install into an alternate environment, as scripts and other artifacts installed via --prefix will reference the installing interpreter, rather than any interpreter added to the --prefix directory, rendering them non-portable.

+

Unlike other install operations, this command does not require discovery of an existing Python environment and only searches for a Python interpreter to use for package resolution. If a suitable Python interpreter cannot be found, uv will install one. To disable this, add --no-python-downloads.

--prerelease prerelease

The strategy to use when considering pre-release versions.

By default, uv will accept pre-releases for packages that only publish pre-releases, along with first-party requirements that contain an explicit pre-release marker in the declared specifiers (if-necessary-or-explicit).

May also be set with the UV_PRERELEASE environment variable.

Possible values:

@@ -4856,7 +4859,8 @@ should be used with caution, as it can modify the system Python installation.

--system

Install packages into the system Python environment.

By default, uv installs into the virtual environment in the current working directory or any parent directory. The --system option instructs uv to instead use the first Python found in the system PATH.

WARNING: --system is intended for use in continuous integration (CI) environments and should be used with caution, as it can modify the system Python installation.

-

May also be set with the UV_SYSTEM_PYTHON environment variable.

--target target

Install packages into the specified directory, rather than into the virtual or system Python environment. The packages will be installed at the top-level of the directory

+

May also be set with the UV_SYSTEM_PYTHON environment variable.

--target target

Install packages into the specified directory, rather than into the virtual or system Python environment. The packages will be installed at the top-level of the directory.

+

Unlike other install operations, this command does not require discovery of an existing Python environment and only searches for a Python interpreter to use for package resolution. If a suitable Python interpreter cannot be found, uv will install one. To disable this, add --no-python-downloads.

--torch-backend torch-backend

The backend to use when fetching packages in the PyTorch ecosystem (e.g., cpu, cu126, or auto)

When set, uv will ignore the configured index URLs for packages in the PyTorch ecosystem, and will instead use the defined backend.

For example, when set to cpu, uv will use the CPU-only PyTorch index; when set to cu126, uv will use the PyTorch index for CUDA 12.6.