diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 5df818654..9d7cfa6e0 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -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. /// diff --git a/crates/uv/src/commands/python/install.rs b/crates/uv/src/commands/python/install.rs index b9d4660df..37d6a6777 100644 --- a/crates/uv/src/commands/python/install.rs +++ b/crates/uv/src/commands/python/install.rs @@ -166,12 +166,14 @@ pub(crate) async fn install( ) -> Result { 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); diff --git a/crates/uv/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index 08eeec3aa..ab4c38247 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -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 } diff --git a/crates/uv/tests/it/help.rs b/crates/uv/tests/it/help.rs index d9353f7c3..d4f46b0cb 100644 --- a/crates/uv/tests/it/help.rs +++ b/crates/uv/tests/it/help.rs @@ -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. diff --git a/crates/uv/tests/it/python_install.rs b/crates/uv/tests/it/python_install.rs index 50b0b3cf5..51e394aad 100644 --- a/crates/uv/tests/it/python_install.rs +++ b/crates/uv/tests/it/python_install.rs @@ -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::>(); + 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` diff --git a/docs/concepts/python-versions.md b/docs/concepts/python-versions.md index ee18fa9da..0c16218d4 100644 --- a/docs/concepts/python-versions.md +++ b/docs/concepts/python-versions.md @@ -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 diff --git a/docs/guides/install-python.md b/docs/guides/install-python.md index da841eac6..374ab29fd 100644 --- a/docs/guides/install-python.md +++ b/docs/guides/install-python.md @@ -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 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 9be647449..4fc832cdb 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -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.