`pip install --target` (and `sync`) install python if necessary (#16694)

## Summary

As described in https://github.com/astral-sh/uv/issues/12229, `pip
install` with `--target` or `--prefix` seem like they should install the
necessary python version if it doesn't exist, but they currently don't.

Most minimal reproduction is something like:
```
> uv python uninstall 3.13
...
> uv pip install anyio --target target-dir --python 3.13
error: No interpreter found for Python 3.13 in virtual environments, managed installations, or search path
```

This also fails without `--target`, but a venv is expected in that case,
so the with `--target`/`--prefix` is the only version that needs to be
fixed. The same mechanism occurs for `uv pip sync` as well.

## Test Plan

Added tests for install and sync that failed before fix and now pass.

---------

Signed-off-by: Mikayla Thompson <mrt@mikayla.codes>
This commit is contained in:
Mikayla Thompson 2025-11-12 15:42:52 -07:00 committed by GitHub
parent aec42540a1
commit 88811553e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 211 additions and 18 deletions

View File

@ -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<PathBuf>,
@ -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<PathBuf>,
@ -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<PathBuf>,
@ -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<PathBuf>,

View File

@ -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<PythonVersion>,
python_platform: Option<TargetTriple>,
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 {

View File

@ -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<PythonVersion>,
python_platform: Option<TargetTriple>,
python_downloads: PythonDownloads,
install_mirrors: PythonInstallMirrors,
strict: bool,
exclude_newer: ExcludeNewer,
python: Option<String>,
@ -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 {

View File

@ -711,6 +711,8 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
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<ExitStatus> {
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,

View File

@ -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
"###
);
}

View File

@ -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(())
}

View File

@ -4463,6 +4463,7 @@ uv pip sync [OPTIONS] <SRC_FILE>...
<p>Multiple packages may be provided. Disable binaries for all packages with <code>:all:</code>. Clear previously specified packages with <code>:none:</code>.</p>
</dd><dt id="uv-pip-sync--prefix"><a href="#uv-pip-sync--prefix"><code>--prefix</code></a> <i>prefix</i></dt><dd><p>Install packages into <code>lib</code>, <code>bin</code>, and other top-level folders under the specified directory, as if a virtual environment were present at that location.</p>
<p>In general, prefer the use of <code>--python</code> to install into an alternate environment, as scripts and other artifacts installed via <code>--prefix</code> will reference the installing interpreter, rather than any interpreter added to the <code>--prefix</code> directory, rendering them non-portable.</p>
<p>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 <code>--no-python-downloads</code>.</p>
</dd><dt id="uv-pip-sync--project"><a href="#uv-pip-sync--project"><code>--project</code></a> <i>project</i></dt><dd><p>Run the command within the given project directory.</p>
<p>All <code>pyproject.toml</code>, <code>uv.toml</code>, and <code>.python-version</code> files will be discovered by walking up the directory tree from the project root, as will the project's virtual environment (<code>.venv</code>).</p>
<p>Other command-line arguments (such as relative paths) will be resolved relative to the current working directory.</p>
@ -4545,7 +4546,8 @@ be used with caution, as it can modify the system Python installation.</p>
</dd><dt id="uv-pip-sync--system"><a href="#uv-pip-sync--system"><code>--system</code></a></dt><dd><p>Install packages into the system Python environment.</p>
<p>By default, uv installs into the virtual environment in the current working directory or any parent directory. The <code>--system</code> option instructs uv to instead use the first Python found in the system <code>PATH</code>.</p>
<p>WARNING: <code>--system</code> is intended for use in continuous integration (CI) environments and should be used with caution, as it can modify the system Python installation.</p>
<p>May also be set with the <code>UV_SYSTEM_PYTHON</code> environment variable.</p></dd><dt id="uv-pip-sync--target"><a href="#uv-pip-sync--target"><code>--target</code></a> <i>target</i></dt><dd><p>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</p>
<p>May also be set with the <code>UV_SYSTEM_PYTHON</code> environment variable.</p></dd><dt id="uv-pip-sync--target"><a href="#uv-pip-sync--target"><code>--target</code></a> <i>target</i></dt><dd><p>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.</p>
<p>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 <code>--no-python-downloads</code>.</p>
</dd><dt id="uv-pip-sync--torch-backend"><a href="#uv-pip-sync--torch-backend"><code>--torch-backend</code></a> <i>torch-backend</i></dt><dd><p>The backend to use when fetching packages in the PyTorch ecosystem (e.g., <code>cpu</code>, <code>cu126</code>, or <code>auto</code>).</p>
<p>When set, uv will ignore the configured index URLs for packages in the PyTorch ecosystem, and will instead use the defined backend.</p>
<p>For example, when set to <code>cpu</code>, uv will use the CPU-only PyTorch index; when set to <code>cu126</code>, uv will use the PyTorch index for CUDA 12.6.</p>
@ -4754,6 +4756,7 @@ uv pip install [OPTIONS] <PACKAGE|--requirements <REQUIREMENTS>|--editable <EDIT
<p>While constraints are <em>additive</em>, in that they're combined with the requirements of the constituent packages, overrides are <em>absolute</em>, in that they completely replace the requirements of the constituent packages.</p>
<p>May also be set with the <code>UV_OVERRIDE</code> environment variable.</p></dd><dt id="uv-pip-install--prefix"><a href="#uv-pip-install--prefix"><code>--prefix</code></a> <i>prefix</i></dt><dd><p>Install packages into <code>lib</code>, <code>bin</code>, and other top-level folders under the specified directory, as if a virtual environment were present at that location.</p>
<p>In general, prefer the use of <code>--python</code> to install into an alternate environment, as scripts and other artifacts installed via <code>--prefix</code> will reference the installing interpreter, rather than any interpreter added to the <code>--prefix</code> directory, rendering them non-portable.</p>
<p>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 <code>--no-python-downloads</code>.</p>
</dd><dt id="uv-pip-install--prerelease"><a href="#uv-pip-install--prerelease"><code>--prerelease</code></a> <i>prerelease</i></dt><dd><p>The strategy to use when considering pre-release versions.</p>
<p>By default, uv will accept pre-releases for packages that <em>only</em> publish pre-releases, along with first-party requirements that contain an explicit pre-release marker in the declared specifiers (<code>if-necessary-or-explicit</code>).</p>
<p>May also be set with the <code>UV_PRERELEASE</code> environment variable.</p><p>Possible values:</p>
@ -4856,7 +4859,8 @@ should be used with caution, as it can modify the system Python installation.</p
</dd><dt id="uv-pip-install--system"><a href="#uv-pip-install--system"><code>--system</code></a></dt><dd><p>Install packages into the system Python environment.</p>
<p>By default, uv installs into the virtual environment in the current working directory or any parent directory. The <code>--system</code> option instructs uv to instead use the first Python found in the system <code>PATH</code>.</p>
<p>WARNING: <code>--system</code> is intended for use in continuous integration (CI) environments and should be used with caution, as it can modify the system Python installation.</p>
<p>May also be set with the <code>UV_SYSTEM_PYTHON</code> environment variable.</p></dd><dt id="uv-pip-install--target"><a href="#uv-pip-install--target"><code>--target</code></a> <i>target</i></dt><dd><p>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</p>
<p>May also be set with the <code>UV_SYSTEM_PYTHON</code> environment variable.</p></dd><dt id="uv-pip-install--target"><a href="#uv-pip-install--target"><code>--target</code></a> <i>target</i></dt><dd><p>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.</p>
<p>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 <code>--no-python-downloads</code>.</p>
</dd><dt id="uv-pip-install--torch-backend"><a href="#uv-pip-install--torch-backend"><code>--torch-backend</code></a> <i>torch-backend</i></dt><dd><p>The backend to use when fetching packages in the PyTorch ecosystem (e.g., <code>cpu</code>, <code>cu126</code>, or <code>auto</code>)</p>
<p>When set, uv will ignore the configured index URLs for packages in the PyTorch ecosystem, and will instead use the defined backend.</p>
<p>For example, when set to <code>cpu</code>, uv will use the CPU-only PyTorch index; when set to <code>cu126</code>, uv will use the PyTorch index for CUDA 12.6.</p>