Stabilize addition of Python executables to the bin (#14626)

Closes https://github.com/astral-sh/uv/issues/14296

As mentioned in #14681, this does not stabilize the `--default`
behavior.
This commit is contained in:
Zanie Blue 2025-07-17 11:09:13 -05:00
parent ff30f14d50
commit 0077f2357f
8 changed files with 593 additions and 113 deletions

View file

@ -4810,10 +4810,9 @@ pub enum PythonCommand {
/// Python versions are installed into the uv Python directory, which can be retrieved with `uv
/// python dir`.
///
/// A `python` executable is not made globally available, managed Python versions are only used
/// in uv commands or in active virtual environments. There is experimental support for adding
/// Python executables to a directory on the path — use the `--preview` flag to enable this
/// behavior and `uv python dir --bin` to retrieve the target directory.
/// By default, Python executables are added to a directory on the path with a minor version
/// suffix, e.g., `python3.13`. To install `python3` and `python`, use the `--default` flag. Use
/// `uv python dir --bin` to see the target directory.
///
/// Multiple Python versions may be requested.
///

View file

@ -166,12 +166,14 @@ pub(crate) async fn install(
) -> Result<ExitStatus> {
let start = std::time::Instant::now();
// TODO(zanieb): We should consider marking the Python installation as the default when
// `--default` is used. It's not clear how this overlaps with a global Python pin, but I'd be
// surprised if `uv python find` returned the "newest" Python version rather than the one I just
// installed with the `--default` flag.
if default && !preview.is_enabled() {
writeln!(
printer.stderr(),
"The `--default` flag is only available in preview mode; add the `--preview` flag to use `--default`"
)?;
return Ok(ExitStatus::Failure);
warn_user!(
"The `--default` option is experimental and may change without warning. Pass `--preview` to disable this warning"
);
}
if upgrade && preview.is_disabled() {
@ -222,6 +224,8 @@ pub(crate) async fn install(
.map(PythonVersionFile::into_versions)
.unwrap_or_else(|| {
// If no version file is found and no requests were made
// TODO(zanieb): We should consider differentiating between a global Python version
// file here, allowing a request from there to enable `is_default_install`.
is_default_install = true;
vec![if reinstall {
// On bare `--reinstall`, reinstall all Python versions
@ -451,10 +455,10 @@ pub(crate) async fn install(
}
}
let bin_dir = if matches!(bin, Some(true)) || preview.is_enabled() {
Some(python_executable_dir()?)
} else {
let bin_dir = if matches!(bin, Some(false)) {
None
} else {
Some(python_executable_dir()?)
};
let installations: Vec<_> = downloaded.iter().chain(satisfied.iter().copied()).collect();
@ -469,20 +473,10 @@ pub(crate) async fn install(
e.warn_user(installation);
}
if preview.is_disabled() {
debug!("Skipping installation of Python executables, use `--preview` to enable.");
continue;
}
let bin_dir = bin_dir
.as_ref()
.expect("We should have a bin directory with preview enabled")
.as_path();
let upgradeable = (default || is_default_install)
|| requested_minor_versions.contains(&installation.key().version().python_version());
if !matches!(bin, Some(false)) {
if let Some(bin_dir) = bin_dir.as_ref() {
create_bin_links(
installation,
bin_dir,
@ -661,11 +655,7 @@ pub(crate) async fn install(
}
}
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();
if let Some(bin_dir) = bin_dir.as_ref() {
warn_if_not_on_path(bin_dir);
}
}
@ -749,16 +739,20 @@ fn create_bin_links(
errors: &mut Vec<(InstallErrorKind, PythonInstallationKey, Error)>,
preview: PreviewMode,
) {
let targets =
if (default || is_default_install) && first_request.matches_installation(installation) {
vec![
installation.key().executable_name_minor(),
installation.key().executable_name_major(),
installation.key().executable_name(),
]
} else {
vec![installation.key().executable_name_minor()]
};
// TODO(zanieb): We want more feedback on the `is_default_install` behavior before stabilizing
// it. In particular, it may be confusing because it does not apply when versions are loaded
// from a `.python-version` file.
let targets = if (default || (is_default_install && preview.is_enabled()))
&& first_request.matches_installation(installation)
{
vec![
installation.key().executable_name_minor(),
installation.key().executable_name_major(),
installation.key().executable_name(),
]
} else {
vec![installation.key().executable_name_minor()]
};
for target in targets {
let target = bin.join(target);

View file

@ -220,17 +220,30 @@ impl TestContext {
/// and `.exe` suffixes.
#[must_use]
pub fn with_filtered_python_names(mut self) -> Self {
use env::consts::EXE_SUFFIX;
let exe_suffix = regex::escape(EXE_SUFFIX);
self.filters.push((
format!(r"python\d.\d\d{exe_suffix}"),
"[PYTHON]".to_string(),
));
self.filters
.push((format!(r"python\d{exe_suffix}"), "[PYTHON]".to_string()));
if cfg!(windows) {
// On Windows, we want to filter out all `python.exe` instances
self.filters
.push((r"python\.exe".to_string(), "[PYTHON]".to_string()));
.push((format!(r"python{exe_suffix}"), "[PYTHON]".to_string()));
// Including ones where we'd already stripped the `.exe` in another filter
self.filters
.push((r"[\\/]python".to_string(), "/[PYTHON]".to_string()));
} else {
// On Unix, it's a little trickier — we don't want to clobber use of `python` in the
// middle of something else, e.g., `cpython`. For this reason, we require a leading `/`.
self.filters
.push((r"python\d.\d\d".to_string(), "[PYTHON]".to_string()));
self.filters
.push((r"python\d".to_string(), "[PYTHON]".to_string()));
self.filters
.push((r"/python".to_string(), "/[PYTHON]".to_string()));
.push((format!(r"/python{exe_suffix}"), "/[PYTHON]".to_string()));
}
self
}

View file

@ -469,10 +469,9 @@ fn help_subsubcommand() {
Python versions are installed into the uv Python directory, which can be retrieved with `uv python
dir`.
A `python` executable is not made globally available, managed Python versions are only used in uv
commands or in active virtual environments. There is experimental support for adding Python
executables to a directory on the path use the `--preview` flag to enable this behavior and `uv
python dir --bin` to retrieve the target directory.
By default, Python executables are added to a directory on the path with a minor version suffix,
e.g., `python3.13`. To install `python3` and `python`, use the `--default` flag. Use `uv python dir
--bin` to see the target directory.
Multiple Python versions may be requested.

View file

@ -30,15 +30,49 @@ fn python_install() {
----- stderr -----
Installed Python 3.13.5 in [TIME]
+ cpython-3.13.5-[PLATFORM]
+ cpython-3.13.5-[PLATFORM] (python3.13)
");
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 (requires preview)
bin_python.assert(predicate::path::missing());
// The executable should be installed in the bin directory
bin_python.assert(predicate::path::exists());
// On Unix, it should be a link
#[cfg(unix)]
bin_python.assert(predicate::path::is_symlink());
// The link should be a path to the binary
if cfg!(unix) {
insta::with_settings!({
filters => context.filters(),
}, {
insta::assert_snapshot!(
read_link(&bin_python), @"[TEMP_DIR]/managed/cpython-3.13.5-[PLATFORM]/bin/python3.13"
);
});
} else if cfg!(windows) {
insta::with_settings!({
filters => context.filters(),
}, {
insta::assert_snapshot!(
read_link(&bin_python), @"[TEMP_DIR]/managed/cpython-3.13.5-[PLATFORM]/python"
);
});
}
// The executable should "work"
uv_snapshot!(context.filters(), Command::new(bin_python.as_os_str())
.arg("-c").arg("import subprocess; print('hello world')"), @r###"
success: true
exit_code: 0
----- stdout -----
hello world
----- stderr -----
"###);
// Should be a no-op when already installed
uv_snapshot!(context.filters(), context.python_install(), @r###"
@ -67,9 +101,12 @@ fn python_install() {
----- stderr -----
Installed Python 3.13.5 in [TIME]
~ cpython-3.13.5-[PLATFORM]
~ cpython-3.13.5-[PLATFORM] (python3.13)
");
// The executable should still be present in the bin directory
bin_python.assert(predicate::path::exists());
// Uninstallation requires an argument
uv_snapshot!(context.filters(), context.python_uninstall(), @r###"
success: false
@ -93,8 +130,11 @@ fn python_install() {
----- stderr -----
Searching for Python versions matching: Python 3.13
Uninstalled Python 3.13.5 in [TIME]
- cpython-3.13.5-[PLATFORM]
- cpython-3.13.5-[PLATFORM] (python3.13)
");
// The executable should be removed
bin_python.assert(predicate::path::missing());
}
#[test]
@ -112,8 +152,8 @@ fn python_reinstall() {
----- stderr -----
Installed 2 versions in [TIME]
+ cpython-3.12.11-[PLATFORM]
+ cpython-3.13.5-[PLATFORM]
+ cpython-3.12.11-[PLATFORM] (python3.12)
+ cpython-3.13.5-[PLATFORM] (python3.13)
");
// Reinstall a single version
@ -124,7 +164,7 @@ fn python_reinstall() {
----- stderr -----
Installed Python 3.13.5 in [TIME]
~ cpython-3.13.5-[PLATFORM]
~ cpython-3.13.5-[PLATFORM] (python3.13)
");
// Reinstall multiple versions
@ -135,8 +175,8 @@ fn python_reinstall() {
----- stderr -----
Installed 2 versions in [TIME]
~ cpython-3.12.11-[PLATFORM]
~ cpython-3.13.5-[PLATFORM]
~ cpython-3.12.11-[PLATFORM] (python3.12)
~ cpython-3.13.5-[PLATFORM] (python3.13)
");
// Reinstalling a version that is not installed should also work
@ -147,7 +187,7 @@ fn python_reinstall() {
----- stderr -----
Installed Python 3.11.13 in [TIME]
+ cpython-3.11.13-[PLATFORM]
+ cpython-3.11.13-[PLATFORM] (python3.11)
");
}
@ -167,7 +207,7 @@ fn python_reinstall_patch() {
----- stderr -----
Installed 2 versions in [TIME]
+ cpython-3.12.6-[PLATFORM]
+ cpython-3.12.7-[PLATFORM]
+ cpython-3.12.7-[PLATFORM] (python3.12)
");
// Reinstall all "3.12" versions
@ -180,7 +220,7 @@ fn python_reinstall_patch() {
----- stderr -----
Installed Python 3.12.11 in [TIME]
+ cpython-3.12.11-[PLATFORM]
+ cpython-3.12.11-[PLATFORM] (python3.12)
");
}
@ -328,6 +368,208 @@ fn regression_cpython() {
"###);
}
#[test]
fn python_install_force() {
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(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.13.5 in [TIME]
+ cpython-3.13.5-[PLATFORM] (python3.13)
");
let bin_python = context
.bin_dir
.child(format!("python3.13{}", std::env::consts::EXE_SUFFIX));
// You can force replacement of the executables
uv_snapshot!(context.filters(), context.python_install().arg("--force"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.13.5 in [TIME]
+ cpython-3.13.5-[PLATFORM] (python3.13)
");
// The executable should still be present in the bin directory
bin_python.assert(predicate::path::exists());
// If an unmanaged executable is present, `--force` is required
fs_err::remove_file(bin_python.path()).unwrap();
bin_python.touch().unwrap();
uv_snapshot!(context.filters(), context.python_install().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
");
uv_snapshot!(context.filters(), context.python_install().arg("--force").arg("3.13"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.13.5 in [TIME]
+ cpython-3.13.5-[PLATFORM] (python3.13)
");
bin_python.assert(predicate::path::exists());
}
#[test]
fn python_install_minor() {
let context: TestContext = TestContext::new_with_versions(&[])
.with_filtered_python_keys()
.with_filtered_exe_suffix()
.with_managed_python_dirs();
// Install a minor version
uv_snapshot!(context.filters(), context.python_install().arg("3.11"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.11.13 in [TIME]
+ cpython-3.11.13-[PLATFORM] (python3.11)
");
let bin_python = context
.bin_dir
.child(format!("python3.11{}", std::env::consts::EXE_SUFFIX));
// The executable should be installed in the bin directory
bin_python.assert(predicate::path::exists());
// It should be a link to the minor version
if cfg!(unix) {
insta::with_settings!({
filters => context.filters(),
}, {
insta::assert_snapshot!(
read_link(&bin_python), @"[TEMP_DIR]/managed/cpython-3.11.13-[PLATFORM]/bin/python3.11"
);
});
} else if cfg!(windows) {
insta::with_settings!({
filters => context.filters(),
}, {
insta::assert_snapshot!(
read_link(&bin_python), @"[TEMP_DIR]/managed/cpython-3.11.13-[PLATFORM]/python"
);
});
}
uv_snapshot!(context.filters(), context.python_uninstall().arg("3.11"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Searching for Python versions matching: Python 3.11
Uninstalled Python 3.11.13 in [TIME]
- cpython-3.11.13-[PLATFORM] (python3.11)
");
// The executable should be removed
bin_python.assert(predicate::path::missing());
}
#[test]
fn python_install_multiple_patch() {
let context: TestContext = TestContext::new_with_versions(&[])
.with_filtered_python_keys()
.with_filtered_exe_suffix()
.with_managed_python_dirs();
// Install multiple patch versions
uv_snapshot!(context.filters(), context.python_install().arg("3.12.8").arg("3.12.6"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed 2 versions in [TIME]
+ cpython-3.12.6-[PLATFORM]
+ cpython-3.12.8-[PLATFORM] (python3.12)
");
let bin_python = context
.bin_dir
.child(format!("python3.12{}", std::env::consts::EXE_SUFFIX));
// The executable should be installed in the bin directory
bin_python.assert(predicate::path::exists());
// The link should resolve to the newer patch version
if cfg!(unix) {
insta::with_settings!({
filters => context.filters(),
}, {
insta::assert_snapshot!(
canonicalize_link_path(&bin_python), @"[TEMP_DIR]/managed/cpython-3.12.8-[PLATFORM]/bin/python3.12"
);
});
} else if cfg!(windows) {
insta::with_settings!({
filters => context.filters(),
}, {
insta::assert_snapshot!(
canonicalize_link_path(&bin_python), @"[TEMP_DIR]/managed/cpython-3.12.8-[PLATFORM]/python"
);
});
}
uv_snapshot!(context.filters(), context.python_uninstall().arg("3.12.8"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Searching for Python versions matching: Python 3.12.8
Uninstalled Python 3.12.8 in [TIME]
- cpython-3.12.8-[PLATFORM] (python3.12)
");
// TODO(zanieb): This behavior is not implemented yet
// // The executable should be installed in the bin directory
// bin_python.assert(predicate::path::exists());
// // When the version is removed, the link should point to the other patch version
// if cfg!(unix) {
// insta::with_settings!({
// filters => context.filters(),
// }, {
// insta::assert_snapshot!(
// canonicalize_link_path(&bin_python), @"[TEMP_DIR]/managed/cpython-3.12.6-[PLATFORM]/bin/python3.12"
// );
// });
// } else if cfg!(windows) {
// insta::with_settings!({
// filters => context.filters(),
// }, {
// insta::assert_snapshot!(
// canonicalize_link_path(&bin_python), @"[TEMP_DIR]/managed/cpython-3.12.6-[PLATFORM]/python"
// );
// });
// }
}
#[test]
fn python_install_preview() {
let context: TestContext = TestContext::new_with_versions(&[])
@ -853,7 +1095,7 @@ fn python_install_freethreaded() {
----- stderr -----
Installed Python 3.13.5 in [TIME]
+ cpython-3.13.5-[PLATFORM]
+ cpython-3.13.5-[PLATFORM] (python3.13)
");
// Should not work with older Python versions
@ -875,7 +1117,7 @@ fn python_install_freethreaded() {
Searching for Python installations
Uninstalled 2 versions in [TIME]
- cpython-3.13.5+freethreaded-[PLATFORM] (python3.13t)
- cpython-3.13.5-[PLATFORM]
- cpython-3.13.5-[PLATFORM] (python3.13)
");
}
@ -936,15 +1178,243 @@ fn python_install_default() {
.bin_dir
.child(format!("python{}", std::env::consts::EXE_SUFFIX));
// `--preview` is required for `--default`
uv_snapshot!(context.filters(), context.python_install().arg("--default"), @r###"
success: false
exit_code: 1
// Install a specific version
uv_snapshot!(context.filters(), context.python_install().arg("3.13"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
The `--default` flag is only available in preview mode; add the `--preview` flag to use `--default`
"###);
Installed Python 3.13.5 in [TIME]
+ cpython-3.13.5-[PLATFORM] (python3.13)
");
// Only the minor versioned executable should be installed
bin_python_minor_13.assert(predicate::path::exists());
bin_python_major.assert(predicate::path::missing());
bin_python_default.assert(predicate::path::missing());
// Install again, with `--default`
uv_snapshot!(context.filters(), context.python_install().arg("--default").arg("3.13"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: The `--default` option is experimental and may change without warning. Pass `--preview` to disable this warning
Installed Python 3.13.5 in [TIME]
+ cpython-3.13.5-[PLATFORM] (python, python3)
");
// Now all the executables should be installed
bin_python_minor_13.assert(predicate::path::exists());
bin_python_major.assert(predicate::path::exists());
bin_python_default.assert(predicate::path::exists());
// Uninstall
uv_snapshot!(context.filters(), context.python_uninstall().arg("--all"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Searching for Python installations
Uninstalled Python 3.13.5 in [TIME]
- cpython-3.13.5-[PLATFORM] (python, python3, python3.13)
");
// The executables should be removed
bin_python_minor_13.assert(predicate::path::missing());
bin_python_major.assert(predicate::path::missing());
bin_python_default.assert(predicate::path::missing());
// Install the latest version, i.e., a "default install"
uv_snapshot!(context.filters(), context.python_install().arg("--default"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: The `--default` option is experimental and may change without warning. Pass `--preview` to disable this warning
Installed Python 3.13.5 in [TIME]
+ cpython-3.13.5-[PLATFORM] (python, python3, python3.13)
");
// Since it's a default install, we should include all of the executables
bin_python_minor_13.assert(predicate::path::exists());
bin_python_major.assert(predicate::path::exists());
bin_python_default.assert(predicate::path::exists());
// And 3.13 should be the default
if cfg!(unix) {
insta::with_settings!({
filters => context.filters(),
}, {
insta::assert_snapshot!(
read_link(&bin_python_major), @"[TEMP_DIR]/managed/cpython-3.13.5-[PLATFORM]/bin/python3.13"
);
});
insta::with_settings!({
filters => context.filters(),
}, {
insta::assert_snapshot!(
read_link(&bin_python_minor_13), @"[TEMP_DIR]/managed/cpython-3.13.5-[PLATFORM]/bin/python3.13"
);
});
insta::with_settings!({
filters => context.filters(),
}, {
insta::assert_snapshot!(
read_link(&bin_python_default), @"[TEMP_DIR]/managed/cpython-3.13.5-[PLATFORM]/bin/python3.13"
);
});
} else if cfg!(windows) {
insta::with_settings!({
filters => context.filters(),
}, {
insta::assert_snapshot!(
read_link(&bin_python_major), @"[TEMP_DIR]/managed/cpython-3.13.5-[PLATFORM]/python"
);
});
insta::with_settings!({
filters => context.filters(),
}, {
insta::assert_snapshot!(
read_link(&bin_python_minor_13), @"[TEMP_DIR]/managed/cpython-3.13.5-[PLATFORM]/python"
);
});
insta::with_settings!({
filters => context.filters(),
}, {
insta::assert_snapshot!(
read_link(&bin_python_default), @"[TEMP_DIR]/managed/cpython-3.13.5-[PLATFORM]/python"
);
});
}
// Uninstall again
uv_snapshot!(context.filters(), context.python_uninstall().arg("3.13"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Searching for Python versions matching: Python 3.13
Uninstalled Python 3.13.5 in [TIME]
- cpython-3.13.5-[PLATFORM] (python, python3, python3.13)
");
// We should remove all the executables
bin_python_minor_13.assert(predicate::path::missing());
bin_python_major.assert(predicate::path::missing());
bin_python_default.assert(predicate::path::missing());
// Install multiple versions, with the `--default` flag
uv_snapshot!(context.filters(), context.python_install().arg("3.12").arg("3.13").arg("--default"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
warning: The `--default` option is experimental and may change without warning. Pass `--preview` to disable this warning
error: The `--default` flag cannot be used with multiple targets
");
// Install 3.12 as a new default
uv_snapshot!(context.filters(), context.python_install().arg("3.12").arg("--default"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: The `--default` option is experimental and may change without warning. Pass `--preview` to disable this warning
Installed Python 3.12.11 in [TIME]
+ cpython-3.12.11-[PLATFORM] (python, python3, python3.12)
");
let bin_python_minor_12 = context
.bin_dir
.child(format!("python3.12{}", std::env::consts::EXE_SUFFIX));
// All the executables should exist
bin_python_minor_12.assert(predicate::path::exists());
bin_python_major.assert(predicate::path::exists());
bin_python_default.assert(predicate::path::exists());
// And 3.12 should be the default
if cfg!(unix) {
insta::with_settings!({
filters => context.filters(),
}, {
insta::assert_snapshot!(
read_link(&bin_python_major), @"[TEMP_DIR]/managed/cpython-3.12.11-[PLATFORM]/bin/python3.12"
);
});
insta::with_settings!({
filters => context.filters(),
}, {
insta::assert_snapshot!(
read_link(&bin_python_minor_12), @"[TEMP_DIR]/managed/cpython-3.12.11-[PLATFORM]/bin/python3.12"
);
});
insta::with_settings!({
filters => context.filters(),
}, {
insta::assert_snapshot!(
read_link(&bin_python_default), @"[TEMP_DIR]/managed/cpython-3.12.11-[PLATFORM]/bin/python3.12"
);
});
} else {
insta::with_settings!({
filters => context.filters(),
}, {
insta::assert_snapshot!(
read_link(&bin_python_major), @"[TEMP_DIR]/managed/cpython-3.12.11-[PLATFORM]/python"
);
});
insta::with_settings!({
filters => context.filters(),
}, {
insta::assert_snapshot!(
read_link(&bin_python_minor_12), @"[TEMP_DIR]/managed/cpython-3.12.11-[PLATFORM]/python"
);
});
insta::with_settings!({
filters => context.filters(),
}, {
insta::assert_snapshot!(
read_link(&bin_python_default), @"[TEMP_DIR]/managed/cpython-3.12.11-[PLATFORM]/python"
);
});
}
}
#[test]
fn python_install_default_preview() {
let context: TestContext = TestContext::new_with_versions(&[])
.with_filtered_python_keys()
.with_filtered_exe_suffix()
.with_managed_python_dirs();
let bin_python_minor_13 = context
.bin_dir
.child(format!("python3.13{}", std::env::consts::EXE_SUFFIX));
let bin_python_major = context
.bin_dir
.child(format!("python3{}", std::env::consts::EXE_SUFFIX));
let bin_python_default = context
.bin_dir
.child(format!("python{}", std::env::consts::EXE_SUFFIX));
// Install a specific version
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.13"), @r"
@ -1342,7 +1812,7 @@ fn python_install_unknown() {
#[cfg(unix)]
#[test]
fn python_install_preview_broken_link() {
fn python_install_broken_link() {
use assert_fs::prelude::PathCreateDir;
use fs_err::os::unix::fs::symlink;
@ -1358,7 +1828,7 @@ fn python_install_preview_broken_link() {
symlink(context.temp_dir.join("does-not-exist"), &bin_python).unwrap();
// Install
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.13"), @r"
uv_snapshot!(context.filters(), context.python_install().arg("3.13"), @r"
success: true
exit_code: 0
----- stdout -----
@ -1393,7 +1863,7 @@ fn python_install_default_from_env() {
----- stderr -----
Installed Python 3.12.11 in [TIME]
+ cpython-3.12.11-[PLATFORM]
+ cpython-3.12.11-[PLATFORM] (python3.12)
");
// But prefer explicit requests
@ -1404,7 +1874,7 @@ fn python_install_default_from_env() {
----- stderr -----
Installed Python 3.11.13 in [TIME]
+ cpython-3.11.13-[PLATFORM]
+ cpython-3.11.13-[PLATFORM] (python3.11)
");
// We should ignore `UV_PYTHON` here and complain there is not a target
@ -1431,8 +1901,8 @@ fn python_install_default_from_env() {
----- stderr -----
Searching for Python installations
Uninstalled 2 versions in [TIME]
- cpython-3.11.13-[PLATFORM]
- cpython-3.12.11-[PLATFORM]
- cpython-3.11.13-[PLATFORM] (python3.11)
- cpython-3.12.11-[PLATFORM] (python3.12)
");
// Uninstall with no targets should error
@ -1516,8 +1986,6 @@ fn python_install_314() {
let context: TestContext = TestContext::new_with_versions(&[])
.with_filtered_python_keys()
.with_managed_python_dirs()
.with_filtered_python_install_bin()
.with_filtered_python_names()
.with_filtered_exe_suffix();
// Install 3.14
@ -1529,7 +1997,7 @@ fn python_install_314() {
----- stderr -----
Installed Python 3.14.0b4 in [TIME]
+ cpython-3.14.0b4-[PLATFORM]
+ cpython-3.14.0b4-[PLATFORM] (python3.14)
");
// Install a specific pre-release
@ -1543,6 +2011,17 @@ fn python_install_314() {
+ cpython-3.14.0a4-[PLATFORM]
");
// Add name filtering for the `find` tests, we avoid it in `install` tests because it clobbers
// the version suffixes which matter in the install logs
let filters = context
.filters()
.iter()
.map(|(a, b)| ((*a).to_string(), (*b).to_string()))
.collect::<Vec<_>>();
let context = context
.with_filtered_python_install_bin()
.with_filtered_python_names();
// We should be able to find this version without opt-in, because there is no stable release
// installed
uv_snapshot!(context.filters(), context.python_find().arg("3.14"), @r"
@ -1574,14 +2053,14 @@ fn python_install_314() {
");
// If we install a stable version, that should be preferred though
uv_snapshot!(context.filters(), context.python_install().arg("3.13"), @r"
uv_snapshot!(filters, context.python_install().arg("3.13"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.13.5 in [TIME]
+ cpython-3.13.5-[PLATFORM]
+ cpython-3.13.5-[PLATFORM] (python3.13)
");
uv_snapshot!(context.filters(), context.python_find().arg("3"), @r"
@ -1621,15 +2100,15 @@ fn python_install_cached() {
----- stderr -----
Installed Python 3.13.5 in [TIME]
+ cpython-3.13.5-[PLATFORM]
+ cpython-3.13.5-[PLATFORM] (python3.13)
");
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 (requires preview)
bin_python.assert(predicate::path::missing());
// The executable should be installed in the bin directory
bin_python.assert(predicate::path::exists());
// Should be a no-op when already installed
uv_snapshot!(context.filters(), context
@ -1651,7 +2130,7 @@ fn python_install_cached() {
----- stderr -----
Searching for Python versions matching: Python 3.13
Uninstalled Python 3.13.5 in [TIME]
- cpython-3.13.5-[PLATFORM]
- cpython-3.13.5-[PLATFORM] (python3.13)
");
// The cached archive can be installed offline
@ -1665,7 +2144,7 @@ fn python_install_cached() {
----- stderr -----
Installed Python 3.13.5 in [TIME]
+ cpython-3.13.5-[PLATFORM]
+ cpython-3.13.5-[PLATFORM] (python3.13)
");
// 3.12 isn't cached, so it can't be installed
@ -1714,7 +2193,7 @@ fn python_install_emulated_macos() {
----- stderr -----
Installed Python 3.13.5 in [TIME]
+ cpython-3.13.5-macos-x86_64-none
+ cpython-3.13.5-macos-x86_64-none (python3.13)
");
// It should be discoverable with `uv python find`

View file

@ -121,28 +121,17 @@ present, uv will install all the Python versions listed in the file.
### Installing Python executables
!!! important
Support for installing Python executables is in _preview_. This means the behavior is experimental
and subject to change.
To install Python executables into your `PATH`, provide the `--preview` option:
```console
$ uv python install 3.12 --preview
```
This will install a Python executable for the requested version into `~/.local/bin`, e.g., as
`python3.12`.
uv installs Python executables into your `PATH` by default, e.g., `uv python install 3.12` will
install a Python executable into `~/.local/bin`, e.g., as `python3.12`.
!!! tip
If `~/.local/bin` is not in your `PATH`, you can add it with `uv tool update-shell`.
To install `python` and `python3` executables, include the `--default` option:
To install `python` and `python3` executables, include the experimental `--default` option:
```console
$ uv python install 3.12 --default --preview
$ uv python install 3.12 --default
```
When installing Python executables, uv will only overwrite an existing executable if it is managed
@ -153,9 +142,9 @@ uv will update executables that it manages. However, it will prefer the latest p
Python minor version by default. For example:
```console
$ uv python install 3.12.7 --preview # Adds `python3.12` to `~/.local/bin`
$ uv python install 3.12.6 --preview # Does not update `python3.12`
$ uv python install 3.12.8 --preview # Updates `python3.12` to point to 3.12.8
$ uv python install 3.12.7 # Adds `python3.12` to `~/.local/bin`
$ uv python install 3.12.6 # Does not update `python3.12`
$ uv python install 3.12.8 # Updates `python3.12` to point to 3.12.8
```
## Upgrading Python versions

View file

@ -24,17 +24,24 @@ $ uv python install
Python does not publish official distributable binaries. As such, uv uses distributions from the Astral [`python-build-standalone`](https://github.com/astral-sh/python-build-standalone) project. See the [Python distributions](../concepts/python-versions.md#managed-python-distributions) documentation for more details.
Once Python is installed, it will be used by `uv` commands automatically.
Once Python is installed, it will be used by `uv` commands automatically. uv also adds the installed
version to your `PATH`:
!!! important
```console
$ python3.13
```
When Python is installed by uv, it will not be available globally (i.e. via the `python` command).
Support for this feature is in _preview_. See [Installing Python executables](../concepts/python-versions.md#installing-python-executables)
for details.
uv only installs a _versioned_ executable by default. To install `python` and `python3` executables,
include the experimental `--default` option:
You can still use
[`uv run`](../guides/scripts.md#using-different-python-versions) or
[create and activate a virtual environment](../pip/environments.md) to use `python` directly.
```console
$ uv python install --default
```
!!! tip
See the documentation on [installing Python executables](../concepts/python-versions.md#installing-python-executables)
for more details.
## Installing a specific version

View file

@ -2739,7 +2739,7 @@ Supports CPython and PyPy. CPython distributions are downloaded from the Astral
Python versions are installed into the uv Python directory, which can be retrieved with `uv python dir`.
A `python` executable is not made globally available, managed Python versions are only used in uv commands or in active virtual environments. There is experimental support for adding Python executables to a directory on the path — use the `--preview` flag to enable this behavior and `uv python dir --bin` to retrieve the target directory.
By default, Python executables are added to a directory on the path with a minor version suffix, e.g., `python3.13`. To install `python3` and `python`, use the `--default` flag. Use `uv python dir --bin` to see the target directory.
Multiple Python versions may be requested.