Update preview installation of Python executables to be non-fatal (#14612)
Some checks are pending
CI / check system | alpine (push) Blocked by required conditions
CI / Determine changes (push) Waiting to run
CI / lint (push) Waiting to run
CI / cargo clippy | ubuntu (push) Blocked by required conditions
CI / cargo clippy | windows (push) Blocked by required conditions
CI / cargo dev generate-all (push) Blocked by required conditions
CI / cargo shear (push) Waiting to run
CI / mkdocs (push) Waiting to run
CI / cargo test | ubuntu (push) Blocked by required conditions
CI / cargo test | macos (push) Blocked by required conditions
CI / cargo test | windows (push) Blocked by required conditions
CI / check windows trampoline | aarch64 (push) Blocked by required conditions
CI / check windows trampoline | i686 (push) Blocked by required conditions
CI / check windows trampoline | x86_64 (push) Blocked by required conditions
CI / test windows trampoline | i686 (push) Blocked by required conditions
CI / test windows trampoline | x86_64 (push) Blocked by required conditions
CI / typos (push) Waiting to run
CI / build binary | linux libc (push) Blocked by required conditions
CI / build binary | linux aarch64 (push) Blocked by required conditions
CI / build binary | linux musl (push) Blocked by required conditions
CI / build binary | macos aarch64 (push) Blocked by required conditions
CI / build binary | macos x86_64 (push) Blocked by required conditions
CI / build binary | windows x86_64 (push) Blocked by required conditions
CI / build binary | windows aarch64 (push) Blocked by required conditions
CI / cargo build (msrv) (push) Blocked by required conditions
CI / build binary | freebsd (push) Blocked by required conditions
CI / smoke test | linux aarch64 (push) Blocked by required conditions
CI / ecosystem test | pydantic/pydantic-core (push) Blocked by required conditions
CI / ecosystem test | prefecthq/prefect (push) Blocked by required conditions
CI / ecosystem test | pallets/flask (push) Blocked by required conditions
CI / smoke test | linux (push) Blocked by required conditions
CI / smoke test | macos (push) Blocked by required conditions
CI / smoke test | windows x86_64 (push) Blocked by required conditions
CI / smoke test | windows aarch64 (push) Blocked by required conditions
CI / integration test | conda on ubuntu (push) Blocked by required conditions
CI / integration test | deadsnakes python3.9 on ubuntu (push) Blocked by required conditions
CI / integration test | free-threaded on windows (push) Blocked by required conditions
CI / integration test | aarch64 windows implicit (push) Blocked by required conditions
CI / integration test | aarch64 windows explicit (push) Blocked by required conditions
CI / integration test | pypy on ubuntu (push) Blocked by required conditions
CI / integration test | pypy on windows (push) Blocked by required conditions
CI / integration test | graalpy on ubuntu (push) Blocked by required conditions
CI / integration test | graalpy on windows (push) Blocked by required conditions
CI / integration test | pyodide on ubuntu (push) Blocked by required conditions
CI / integration test | github actions (push) Blocked by required conditions
CI / integration test | free-threaded python on github actions (push) Blocked by required conditions
CI / integration test | determine publish changes (push) Blocked by required conditions
CI / integration test | registries (push) Blocked by required conditions
CI / integration test | uv publish (push) Blocked by required conditions
CI / integration test | uv_build (push) Blocked by required conditions
CI / check cache | ubuntu (push) Blocked by required conditions
CI / check cache | macos aarch64 (push) Blocked by required conditions
CI / check system | python on debian (push) Blocked by required conditions
CI / check system | python on fedora (push) Blocked by required conditions
CI / check system | python on ubuntu (push) Blocked by required conditions
CI / check system | python on rocky linux 8 (push) Blocked by required conditions
CI / check system | python on rocky linux 9 (push) Blocked by required conditions
CI / check system | graalpy on ubuntu (push) Blocked by required conditions
CI / check system | pypy on ubuntu (push) Blocked by required conditions
CI / check system | pyston (push) Blocked by required conditions
CI / check system | python on macos aarch64 (push) Blocked by required conditions
CI / check system | homebrew python on macos aarch64 (push) Blocked by required conditions
CI / check system | python3.10 on windows x86-64 (push) Blocked by required conditions
CI / check system | python3.10 on windows x86 (push) Blocked by required conditions
CI / check system | x86-64 python3.13 on windows aarch64 (push) Blocked by required conditions
CI / check system | aarch64 python3.13 on windows aarch64 (push) Blocked by required conditions
CI / check system | windows registry (push) Blocked by required conditions
CI / check system | python3.12 via chocolatey (push) Blocked by required conditions
CI / check system | python3.9 via pyenv (push) Blocked by required conditions
CI / check system | python3.13 (push) Blocked by required conditions
CI / check system | conda3.11 on macos aarch64 (push) Blocked by required conditions
CI / check system | conda3.8 on macos aarch64 (push) Blocked by required conditions
CI / check system | conda3.11 on linux x86-64 (push) Blocked by required conditions
CI / check system | conda3.8 on linux x86-64 (push) Blocked by required conditions
CI / check system | conda3.11 on windows x86-64 (push) Blocked by required conditions
CI / check system | conda3.8 on windows x86-64 (push) Blocked by required conditions
CI / check system | amazonlinux (push) Blocked by required conditions
CI / check system | embedded python3.10 on windows x86-64 (push) Blocked by required conditions
CI / benchmarks | walltime aarch64 linux (push) Blocked by required conditions
CI / benchmarks | instrumented (push) Blocked by required conditions
CI / check system | python on macos x86-64 (push) Blocked by required conditions
CI / check system | python3.13 on windows x86-64 (push) Blocked by required conditions

Previously, if installation of executables into the bin directory failed
we'd with a non-zero code. However, if we make this behavior the default
we don't want it to be fatal. There's a `--bin` opt-in to _require_
successful executable installation and a `--no-bin` opt-out to silence
the warning / opt-out of installation entirely.

Part of https://github.com/astral-sh/uv/issues/14296 — we need this
before we can stabilize the behavior.

In #14614 we do the same for writing entries to the Windows registry.
This commit is contained in:
Zanie Blue 2025-07-15 12:12:36 -05:00 committed by GitHub
parent cd0d5d4748
commit bb1e9a247c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 212 additions and 40 deletions

View file

@ -4941,6 +4941,19 @@ pub struct PythonInstallArgs {
#[arg(long, short, env = EnvVars::UV_PYTHON_INSTALL_DIR)]
pub install_dir: Option<PathBuf>,
/// Install a Python executable into the `bin` directory.
///
/// This is the default behavior. If this flag is provided explicitly, uv will error if the
/// executable cannot be installed.
///
/// See `UV_PYTHON_BIN_DIR` to customize the target directory.
#[arg(long, overrides_with("no_bin"), hide = true)]
pub bin: bool,
/// Do not install a Python executable into the `bin` directory.
#[arg(long, overrides_with("bin"), conflicts_with("default"))]
pub no_bin: bool,
/// The Python version(s) to install.
///
/// If not provided, the requested Python version(s) will be read from the `UV_PYTHON`
@ -5003,7 +5016,7 @@ pub struct PythonInstallArgs {
/// and `python`.
///
/// If multiple Python versions are requested, uv will exit with an error.
#[arg(long)]
#[arg(long, conflicts_with("no_bin"))]
pub default: bool,
}

View file

@ -129,12 +129,13 @@ fn read_registry_entry(company: &str, tag: &str, tag_key: &Key) -> Option<Window
pub enum ManagedPep514Error {
#[error("Windows has an unknown pointer width for arch: `{_0}`")]
InvalidPointerSize(Arch),
#[error("Failed to write registry entry: {0}")]
WriteError(#[from] windows_result::Error),
}
/// Register a managed Python installation in the Windows registry following PEP 514.
pub fn create_registry_entry(
installation: &ManagedPythonInstallation,
errors: &mut Vec<(PythonInstallationKey, anyhow::Error)>,
) -> Result<(), ManagedPep514Error> {
let pointer_width = match installation.key().arch().family().pointer_width() {
Ok(PointerWidth::U32) => 32,
@ -146,9 +147,7 @@ pub fn create_registry_entry(
}
};
if let Err(err) = write_registry_entry(installation, pointer_width) {
errors.push((installation.key().clone(), err.into()));
}
write_registry_entry(installation, pointer_width)?;
Ok(())
}

View file

@ -135,6 +135,14 @@ impl Changelog {
}
}
#[derive(Debug, Clone, Copy)]
enum InstallErrorKind {
DownloadUnpack,
Bin,
#[cfg(windows)]
Registry,
}
/// Download and install Python versions.
#[allow(clippy::fn_params_excessive_bools)]
pub(crate) async fn install(
@ -143,6 +151,7 @@ pub(crate) async fn install(
targets: Vec<String>,
reinstall: bool,
upgrade: bool,
bin: Option<bool>,
force: bool,
python_install_mirror: Option<String>,
pypy_install_mirror: Option<String>,
@ -432,12 +441,16 @@ pub(crate) async fn install(
downloaded.push(installation.clone());
}
Err(err) => {
errors.push((download.key().clone(), anyhow::Error::new(err)));
errors.push((
InstallErrorKind::DownloadUnpack,
download.key().clone(),
anyhow::Error::new(err),
));
}
}
}
let bin = if preview.is_enabled() {
let bin_dir = if matches!(bin, Some(true)) || preview.is_enabled() {
Some(python_executable_dir()?)
} else {
None
@ -460,7 +473,7 @@ pub(crate) async fn install(
continue;
}
let bin = bin
let bin_dir = bin_dir
.as_ref()
.expect("We should have a bin directory with preview enabled")
.as_path();
@ -468,9 +481,10 @@ pub(crate) async fn install(
let upgradeable = (default || is_default_install)
|| requested_minor_versions.contains(&installation.key().version().python_version());
if !matches!(bin, Some(false)) {
create_bin_links(
installation,
bin,
bin_dir,
reinstall,
force,
default,
@ -483,12 +497,22 @@ pub(crate) async fn install(
&mut changelog,
&mut errors,
preview,
)?;
);
}
if preview.is_enabled() {
#[cfg(windows)]
{
uv_python::windows_registry::create_registry_entry(installation, &mut errors)?;
match uv_python::windows_registry::create_registry_entry(installation) {
Ok(()) => {}
Err(err) => {
errors.push((
InstallErrorKind::Registry,
installation.key().clone(),
err.into(),
));
}
}
}
}
}
@ -636,24 +660,47 @@ pub(crate) async fn install(
}
}
if preview.is_enabled() {
let bin = bin
if preview.is_enabled() && !matches!(bin, Some(false)) {
let bin_dir = bin_dir
.as_ref()
.expect("We should have a bin directory with preview enabled")
.as_path();
warn_if_not_on_path(bin);
warn_if_not_on_path(bin_dir);
}
}
if !errors.is_empty() {
for (key, err) in errors
// If there are only bin install errors and the user didn't opt-in, we're only going to warn
let fatal = errors
.iter()
.all(|(kind, _, _)| matches!(kind, InstallErrorKind::Bin))
&& bin.is_none();
for (kind, key, err) in errors
.into_iter()
.sorted_unstable_by(|(key_a, _), (key_b, _)| key_a.cmp(key_b))
.sorted_unstable_by(|(_, key_a, _), (_, key_b, _)| key_a.cmp(key_b))
{
let (level, verb) = match kind {
InstallErrorKind::DownloadUnpack => ("error".red().bold().to_string(), "install"),
InstallErrorKind::Bin => {
let level = match bin {
None => "warning".yellow().bold().to_string(),
Some(false) => continue,
Some(true) => "error".red().bold().to_string(),
};
(level, "install executable for")
}
#[cfg(windows)]
InstallErrorKind::Registry => (
"error".red().bold().to_string(),
"install registry entry for",
),
};
writeln!(
printer.stderr(),
"{}: Failed to install {}",
"error".red().bold(),
"{level}{} Failed to {verb} {}",
":".bold(),
key.green()
)?;
for err in err.chain() {
@ -665,6 +712,11 @@ pub(crate) async fn install(
)?;
}
}
if fatal {
return Ok(ExitStatus::Success);
}
return Ok(ExitStatus::Failure);
}
@ -672,6 +724,8 @@ pub(crate) async fn install(
}
/// Link the binaries of a managed Python installation to the bin directory.
///
/// This function is fallible, but errors are pushed to `errors` instead of being thrown.
#[allow(clippy::fn_params_excessive_bools)]
fn create_bin_links(
installation: &ManagedPythonInstallation,
@ -686,9 +740,9 @@ fn create_bin_links(
existing_installations: &[ManagedPythonInstallation],
installations: &[&ManagedPythonInstallation],
changelog: &mut Changelog,
errors: &mut Vec<(PythonInstallationKey, Error)>,
errors: &mut Vec<(InstallErrorKind, PythonInstallationKey, Error)>,
preview: PreviewMode,
) -> Result<(), Error> {
) {
let targets =
if (default || is_default_install) && first_request.matches_installation(installation) {
vec![
@ -773,6 +827,7 @@ fn create_bin_links(
);
} else {
errors.push((
InstallErrorKind::Bin,
installation.key().clone(),
anyhow::anyhow!(
"Executable already exists at `{}` but is not managed by uv; use `--force` to replace it",
@ -848,7 +903,17 @@ fn create_bin_links(
}
// Replace the existing link
fs_err::remove_file(&to)?;
if let Err(err) = fs_err::remove_file(&to) {
errors.push((
InstallErrorKind::Bin,
installation.key().clone(),
anyhow::anyhow!(
"Executable already exists at `{}` but could not be removed: {err}",
to.simplified_display()
),
));
continue;
}
if let Some(existing) = existing {
// Ensure we do not report installation of this executable for an existing
@ -860,7 +925,18 @@ fn create_bin_links(
.remove(&target);
}
create_link_to_executable(&target, executable)?;
if let Err(err) = create_link_to_executable(&target, executable) {
errors.push((
InstallErrorKind::Bin,
installation.key().clone(),
anyhow::anyhow!(
"Failed to create link at `{}`: {err}",
target.simplified_display()
),
));
continue;
}
debug!(
"Updated executable at `{}` to {}",
target.simplified_display(),
@ -874,11 +950,14 @@ fn create_bin_links(
.insert(target.clone());
}
Err(err) => {
errors.push((installation.key().clone(), anyhow::Error::new(err)));
errors.push((
InstallErrorKind::Bin,
installation.key().clone(),
anyhow::Error::new(err),
));
}
}
}
Ok(())
}
pub(crate) fn format_executables(

View file

@ -1402,6 +1402,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
args.targets,
args.reinstall,
upgrade,
args.bin,
args.force,
args.python_install_mirror,
args.pypy_install_mirror,
@ -1430,6 +1431,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
args.targets,
reinstall,
upgrade,
args.bin,
args.force,
args.python_install_mirror,
args.pypy_install_mirror,

View file

@ -933,6 +933,7 @@ pub(crate) struct PythonInstallSettings {
pub(crate) targets: Vec<String>,
pub(crate) reinstall: bool,
pub(crate) force: bool,
pub(crate) bin: Option<bool>,
pub(crate) python_install_mirror: Option<String>,
pub(crate) pypy_install_mirror: Option<String>,
pub(crate) python_downloads_json_url: Option<String>,
@ -961,6 +962,8 @@ impl PythonInstallSettings {
install_dir,
targets,
reinstall,
bin,
no_bin,
force,
mirror: _,
pypy_mirror: _,
@ -973,6 +976,7 @@ impl PythonInstallSettings {
targets,
reinstall,
force,
bin: flag(bin, no_bin, "bin"),
python_install_mirror: python_mirror,
pypy_install_mirror: pypy_mirror,
python_downloads_json_url,
@ -992,6 +996,7 @@ pub(crate) struct PythonUpgradeSettings {
pub(crate) pypy_install_mirror: Option<String>,
pub(crate) python_downloads_json_url: Option<String>,
pub(crate) default: bool,
pub(crate) bin: Option<bool>,
}
impl PythonUpgradeSettings {
@ -1013,6 +1018,7 @@ impl PythonUpgradeSettings {
args.python_downloads_json_url.or(python_downloads_json_url);
let force = false;
let default = false;
let bin = None;
let PythonUpgradeArgs {
install_dir,
@ -1030,6 +1036,7 @@ impl PythonUpgradeSettings {
pypy_install_mirror: pypy_mirror,
python_downloads_json_url,
default,
bin,
}
}
}

View file

@ -504,6 +504,9 @@ fn help_subsubcommand() {
[env: UV_PYTHON_INSTALL_DIR=]
--no-bin
Do not install a Python executable into the `bin` directory
--mirror <MIRROR>
Set the URL to use as the source for downloading Python installations.
@ -790,6 +793,8 @@ fn help_flag_subsubcommand() {
Options:
-i, --install-dir <INSTALL_DIR>
The directory to store the Python installation in [env: UV_PYTHON_INSTALL_DIR=]
--no-bin
Do not install a Python executable into the `bin` directory
--mirror <MIRROR>
Set the URL to use as the source for downloading Python installations [env:
UV_PYTHON_INSTALL_MIRROR=]

View file

@ -430,15 +430,35 @@ fn python_install_preview() {
bin_python.touch().unwrap();
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.13"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: Failed to install executable for cpython-3.13.5-[PLATFORM]
Caused by: Executable already exists at `[BIN]/python3.13` but is not managed by uv; use `--force` to replace it
");
// With `--bin`, this should error instead of warn
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("--bin").arg("3.13"), @r"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
error: Failed to install cpython-3.13.5-[PLATFORM]
error: Failed to install executable for cpython-3.13.5-[PLATFORM]
Caused by: Executable already exists at `[BIN]/python3.13` but is not managed by uv; use `--force` to replace it
");
// With `--no-bin`, this should be silent
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("--no-bin").arg("3.13"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
");
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("--force").arg("3.13"), @r"
success: true
exit_code: 0
@ -565,6 +585,52 @@ fn python_install_preview() {
}
}
#[test]
fn python_install_preview_no_bin() {
let context: TestContext = TestContext::new_with_versions(&[])
.with_filtered_python_keys()
.with_filtered_exe_suffix()
.with_managed_python_dirs();
// Install the latest version
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("--no-bin"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.13.5 in [TIME]
+ cpython-3.13.5-[PLATFORM]
");
let bin_python = context
.bin_dir
.child(format!("python3.13{}", std::env::consts::EXE_SUFFIX));
// The executable should not be installed in the bin directory
bin_python.assert(predicate::path::missing());
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("--no-bin").arg("--default"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: the argument '--no-bin' cannot be used with '--default'
Usage: uv python install --no-bin --install-dir <INSTALL_DIR> [TARGETS]...
For more information, try '--help'.
");
let bin_python = context
.bin_dir
.child(format!("python{}", std::env::consts::EXE_SUFFIX));
// The executable should not be installed in the bin directory
bin_python.assert(predicate::path::missing());
}
#[test]
fn python_install_preview_upgrade() {
let context = TestContext::new_with_versions(&[])

View file

@ -2795,7 +2795,8 @@ uv python install [OPTIONS] [TARGETS]...
<p>May also be set with the <code>UV_PYTHON_INSTALL_MIRROR</code> environment variable.</p></dd><dt id="uv-python-install--native-tls"><a href="#uv-python-install--native-tls"><code>--native-tls</code></a></dt><dd><p>Whether to load TLS certificates from the platform's native certificate store.</p>
<p>By default, uv loads certificates from the bundled <code>webpki-roots</code> crate. The <code>webpki-roots</code> are a reliable set of trust roots from Mozilla, and including them in uv improves portability and performance (especially on macOS).</p>
<p>However, in some cases, you may want to use the platform's native certificate store, especially if you're relying on a corporate trust root (e.g., for a mandatory proxy) that's included in your system's certificate store.</p>
<p>May also be set with the <code>UV_NATIVE_TLS</code> environment variable.</p></dd><dt id="uv-python-install--no-cache"><a href="#uv-python-install--no-cache"><code>--no-cache</code></a>, <code>--no-cache-dir</code>, <code>-n</code></dt><dd><p>Avoid reading from or writing to the cache, instead using a temporary directory for the duration of the operation</p>
<p>May also be set with the <code>UV_NATIVE_TLS</code> environment variable.</p></dd><dt id="uv-python-install--no-bin"><a href="#uv-python-install--no-bin"><code>--no-bin</code></a></dt><dd><p>Do not install a Python executable into the <code>bin</code> directory</p>
</dd><dt id="uv-python-install--no-cache"><a href="#uv-python-install--no-cache"><code>--no-cache</code></a>, <code>--no-cache-dir</code>, <code>-n</code></dt><dd><p>Avoid reading from or writing to the cache, instead using a temporary directory for the duration of the operation</p>
<p>May also be set with the <code>UV_NO_CACHE</code> environment variable.</p></dd><dt id="uv-python-install--no-config"><a href="#uv-python-install--no-config"><code>--no-config</code></a></dt><dd><p>Avoid discovering configuration files (<code>pyproject.toml</code>, <code>uv.toml</code>).</p>
<p>Normally, configuration files are discovered in the current directory, parent directories, or user configuration directories.</p>
<p>May also be set with the <code>UV_NO_CONFIG</code> environment variable.</p></dd><dt id="uv-python-install--no-managed-python"><a href="#uv-python-install--no-managed-python"><code>--no-managed-python</code></a></dt><dd><p>Disable use of uv-managed Python versions.</p>