diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index fd05d1ee6181a..4fe00b50ef159 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -3907,6 +3907,21 @@ pub struct PythonInstallArgs { /// installed. #[arg(long, short, alias = "force")] pub reinstall: bool, + + /// Use as the default Python version. + /// + /// By default, only a `python{major}.{minor}` executable is installed, e.g., `python3.10`. When + /// the `--default` flag is used, `python{major}`, e.g., `python3`, and `python` executables are + /// also installed. + /// + /// Alternative Python variants will still include their tag. For example, installing + /// 3.13+freethreaded with `--default` will include in `python3t` and `pythont`, not `python3` + /// and `python`. + /// + /// If multiple Python versions are requested during the installation, the first request will be + /// the default. + #[arg(long)] + pub default: bool, } #[derive(Args)] diff --git a/crates/uv-python/src/installation.rs b/crates/uv-python/src/installation.rs index 9faef3438abbe..d0e6f1afab38a 100644 --- a/crates/uv-python/src/installation.rs +++ b/crates/uv-python/src/installation.rs @@ -306,8 +306,8 @@ impl PythonInstallationKey { &self.libc } - /// Return a canonical name for a versioned executable. - pub fn versioned_executable_name(&self) -> String { + /// Return a canonical name for a minor versioned executable. + pub fn executable_name_minor(&self) -> String { format!( "python{maj}.{min}{var}{exe}", maj = self.major, @@ -316,6 +316,25 @@ impl PythonInstallationKey { exe = std::env::consts::EXE_SUFFIX ) } + + /// Return a canonical name for a major versioned executable. + pub fn executable_name_major(&self) -> String { + format!( + "python{maj}{var}{exe}", + maj = self.major, + var = self.variant.suffix(), + exe = std::env::consts::EXE_SUFFIX + ) + } + + /// Return a canonical name for an un-versioned executable. + pub fn executable_name(&self) -> String { + format!( + "python{var}{exe}", + var = self.variant.suffix(), + exe = std::env::consts::EXE_SUFFIX + ) + } } impl fmt::Display for PythonInstallationKey { diff --git a/crates/uv-python/src/managed.rs b/crates/uv-python/src/managed.rs index 751adbf4b0d2c..7e1198b30bbcd 100644 --- a/crates/uv-python/src/managed.rs +++ b/crates/uv-python/src/managed.rs @@ -475,26 +475,24 @@ impl ManagedPythonInstallation { Ok(()) } - /// Create a link to the Python executable in the given `bin` directory. - pub fn create_bin_link(&self, bin: &Path) -> Result { + /// Create a link to the managed Python executable. + pub fn create_bin_link(&self, target: &Path) -> Result<(), Error> { let python = self.executable(); + let bin = target.parent().ok_or(Error::NoExecutableDirectory)?; fs_err::create_dir_all(bin).map_err(|err| Error::ExecutableDirectory { to: bin.to_path_buf(), err, })?; - // TODO(zanieb): Add support for a "default" which - let python_in_bin = bin.join(self.key.versioned_executable_name()); - - match uv_fs::symlink_copy_fallback_file(&python, &python_in_bin) { - Ok(()) => Ok(python_in_bin), + match uv_fs::symlink_copy_fallback_file(&python, target) { + Ok(()) => Ok(()), Err(err) if err.kind() == io::ErrorKind::NotFound => { Err(Error::MissingExecutable(python.clone())) } Err(err) => Err(Error::LinkExecutable { from: python, - to: python_in_bin, + to: target.to_path_buf(), err, }), } diff --git a/crates/uv/src/commands/python/install.rs b/crates/uv/src/commands/python/install.rs index 366effad0a1a7..a48e818a14e47 100644 --- a/crates/uv/src/commands/python/install.rs +++ b/crates/uv/src/commands/python/install.rs @@ -104,10 +104,12 @@ impl Changelog { } /// Download and install Python versions. +#[allow(clippy::fn_params_excessive_bools)] pub(crate) async fn install( project_dir: &Path, targets: Vec, reinstall: bool, + default: bool, python_downloads: PythonDownloads, native_tls: bool, connectivity: Connectivity, @@ -117,6 +119,11 @@ pub(crate) async fn install( ) -> Result { let start = std::time::Instant::now(); + 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); + } + // Resolve the requests let mut is_default_install = false; let requests: Vec<_> = if targets.is_empty() { @@ -274,29 +281,24 @@ pub(crate) async fn install( .expect("We should have a bin directory with preview enabled") .as_path(); - match installation.create_bin_link(bin) { - Ok(target) => { - debug!( - "Installed executable at {} for {}", - target.user_display(), - installation.key(), - ); - changelog.installed.insert(installation.key().clone()); - changelog - .installed_executables - .entry(installation.key().clone()) - .or_default() - .push(target.clone()); - } - Err(uv_python::managed::Error::LinkExecutable { from, to, err }) - if err.kind() == ErrorKind::AlreadyExists => - { - // TODO(zanieb): Add `--force` - if reinstall { - fs_err::remove_file(&to)?; - let target = installation.create_bin_link(bin)?; + 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()] + }; + + for target in targets { + let target = bin.join(target); + match installation.create_bin_link(&target) { + Ok(()) => { debug!( - "Updated executable at {} to {}", + "Installed executable at {} for {}", target.user_display(), installation.key(), ); @@ -306,20 +308,40 @@ pub(crate) async fn install( .entry(installation.key().clone()) .or_default() .push(target.clone()); - changelog.uninstalled_executables.insert(target); - } else { - if !is_same_file(&to, &from).unwrap_or_default() { - errors.push(( + } + Err(uv_python::managed::Error::LinkExecutable { from, to, err }) + if err.kind() == ErrorKind::AlreadyExists => + { + // TODO(zanieb): Add `--force` + if reinstall { + fs_err::remove_file(&to)?; + installation.create_bin_link(&target)?; + debug!( + "Updated executable at {} to {}", + target.user_display(), installation.key(), - anyhow::anyhow!( - "Executable already exists at `{}`. Use `--reinstall` to force replacement.", - to.user_display() - ), - )); + ); + changelog.installed.insert(installation.key().clone()); + changelog + .installed_executables + .entry(installation.key().clone()) + .or_default() + .push(target.clone()); + changelog.uninstalled_executables.insert(target); + } else { + if !is_same_file(&to, &from).unwrap_or_default() { + errors.push(( + installation.key(), + anyhow::anyhow!( + "Executable already exists at `{}`. Use `--reinstall` to force replacement.", + to.user_display() + ), + )); + } } } + Err(err) => return Err(err.into()), } - Err(err) => return Err(err.into()), } } diff --git a/crates/uv/src/commands/python/uninstall.rs b/crates/uv/src/commands/python/uninstall.rs index f74085cf47683..37197a2d7b41e 100644 --- a/crates/uv/src/commands/python/uninstall.rs +++ b/crates/uv/src/commands/python/uninstall.rs @@ -141,8 +141,10 @@ async fn do_uninstall( // leave broken links behind, i.e., if the user created them. .filter(|path| { matching_installations.iter().any(|installation| { - path.file_name().and_then(|name| name.to_str()) - == Some(&installation.key().versioned_executable_name()) + let name = path.file_name().and_then(|name| name.to_str()); + name == Some(&installation.key().executable_name_minor()) + || name == Some(&installation.key().executable_name_major()) + || name == Some(&installation.key().executable_name()) }) }) // Only include Python executables that match the installations @@ -222,7 +224,7 @@ async fn do_uninstall( " {} {} ({})", "-".red(), event.key.bold(), - event.key.versioned_executable_name() + event.key.executable_name_minor() )?; } _ => unreachable!(), diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 7055a9bfb8d8e..bf54dac226758 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1053,6 +1053,7 @@ async fn run(mut cli: Cli) -> Result { &project_dir, args.targets, args.reinstall, + args.default, globals.python_downloads, globals.native_tls, globals.connectivity, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index b4259fe6e7d2c..a16bffb2ca4b4 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -620,15 +620,24 @@ impl PythonDirSettings { pub(crate) struct PythonInstallSettings { pub(crate) targets: Vec, pub(crate) reinstall: bool, + pub(crate) default: bool, } impl PythonInstallSettings { /// Resolve the [`PythonInstallSettings`] from the CLI and filesystem configuration. #[allow(clippy::needless_pass_by_value)] pub(crate) fn resolve(args: PythonInstallArgs, _filesystem: Option) -> Self { - let PythonInstallArgs { targets, reinstall } = args; + let PythonInstallArgs { + targets, + reinstall, + default, + } = args; - Self { targets, reinstall } + Self { + targets, + reinstall, + default, + } } } diff --git a/crates/uv/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index 166f0f600d792..fe01baf50279b 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -215,10 +215,16 @@ impl TestContext { /// Adds a filter that ignores platform information in a Python installation key. pub fn with_filtered_python_keys(mut self) -> Self { + // Filter platform keys self.filters.push(( - r"((?:cpython|pypy)-\d+\.\d+(:?\.\d+)?[a-z]?(:?\+[a-z]+)?)-.*".to_string(), + r"((?:cpython|pypy)-\d+\.\d+(?:\.(?:\[X\]|\d+))?[a-z]?(?:\+[a-z]+)?)-.*".to_string(), "$1-[PLATFORM]".to_string(), )); + // Filter patch versions + self.filters.push(( + r"((?:cpython|pypy)-\d+\.\d+)\.\d+([a-z])?".to_string(), + "$1.[X]$2".to_string(), + )); self } diff --git a/crates/uv/tests/it/help.rs b/crates/uv/tests/it/help.rs index faf837a7a3588..ef4ee3b0708c7 100644 --- a/crates/uv/tests/it/help.rs +++ b/crates/uv/tests/it/help.rs @@ -441,6 +441,20 @@ fn help_subsubcommand() { By default, uv will exit successfully if the version is already installed. + --default + Use as the default Python version. + + By default, only a `python{major}.{minor}` executable is installed, e.g., `python3.10`. + When the `--default` flag is used, `python{major}`, e.g., `python3`, and `python` + executables are also installed. + + Alternative Python variants will still include their tag. For example, installing + 3.13+freethreaded with `--default` will include in `python3t` and `pythont`, not `python3` + and `python`. + + If multiple Python versions are requested during the installation, the first request will + be the default. + Cache options: -n, --no-cache Avoid reading from or writing to the cache, instead using a temporary directory for the @@ -646,6 +660,7 @@ fn help_flag_subsubcommand() { Options: -r, --reinstall Reinstall the requested Python version, if it's already installed + --default Use as the default Python version Cache options: -n, --no-cache Avoid reading from or writing to the cache, instead using a temporary diff --git a/crates/uv/tests/it/python_install.rs b/crates/uv/tests/it/python_install.rs index ca939a502a7fb..f09d705fc07f5 100644 --- a/crates/uv/tests/it/python_install.rs +++ b/crates/uv/tests/it/python_install.rs @@ -2,6 +2,7 @@ use std::process::Command; use assert_fs::{assert::PathAssert, prelude::PathChild}; use predicates::prelude::predicate; +use same_file::is_same_file; use crate::common::{uv_snapshot, TestContext}; @@ -17,7 +18,7 @@ fn python_install() { ----- stderr ----- Installed Python 3.13.0 in [TIME] - + cpython-3.13.0-[PLATFORM] + + cpython-3.13.[X]-[PLATFORM] "###); let bin_python = context @@ -55,7 +56,7 @@ fn python_install() { ----- stderr ----- Installed Python 3.13.0 in [TIME] - ~ cpython-3.13.0-[PLATFORM] + ~ cpython-3.13.[X]-[PLATFORM] "###); // Uninstallation requires an argument @@ -96,7 +97,7 @@ fn python_install_preview() { ----- stderr ----- Installed Python 3.13.0 in [TIME] - + cpython-3.13.0-[PLATFORM] + + cpython-3.13.[X]-[PLATFORM] warning: `[TEMP_DIR]/bin` is not on your PATH. To use the installed Python executable, run `export PATH="[TEMP_DIR]/bin:$PATH"`. "###); @@ -141,7 +142,7 @@ fn python_install_preview() { ----- stderr ----- Installed Python 3.13.0 in [TIME] - ~ cpython-3.13.0-[PLATFORM] + ~ cpython-3.13.[X]-[PLATFORM] warning: `[TEMP_DIR]/bin` is not on your PATH. To use the installed Python executable, run `export PATH="[TEMP_DIR]/bin:$PATH"`. "###); @@ -171,7 +172,7 @@ fn python_install_preview() { ----- stderr ----- Searching for Python versions matching: Python 3.13 Uninstalled Python 3.13.0 in [TIME] - - cpython-3.13.0-[PLATFORM] + - cpython-3.13.[X]-[PLATFORM] "###); // The executable should be removed @@ -190,7 +191,7 @@ fn python_install_freethreaded() { ----- stderr ----- Installed Python 3.13.0 in [TIME] - + cpython-3.13.0+freethreaded-[PLATFORM] + + cpython-3.13.[X]+freethreaded-[PLATFORM] warning: `[TEMP_DIR]/bin` is not on your PATH. To use the installed Python executable, run `export PATH="[TEMP_DIR]/bin:$PATH"`. "###); @@ -225,7 +226,7 @@ fn python_install_freethreaded() { ----- stderr ----- Installed Python 3.13.0 in [TIME] - + cpython-3.13.0-[PLATFORM] + + cpython-3.13.[X]-[PLATFORM] "###); // Should not work with older Python versions @@ -246,8 +247,8 @@ fn python_install_freethreaded() { ----- stderr ----- Searching for Python installations Uninstalled 2 versions in [TIME] - - cpython-3.13.0-[PLATFORM] - - cpython-3.13.0+freethreaded-[PLATFORM] + - cpython-3.13.[X]-[PLATFORM] + - cpython-3.13.[X]+freethreaded-[PLATFORM] "###); } @@ -285,3 +286,142 @@ fn python_install_invalid_request() { error: No download found for request: cpython-3.8.0-[PLATFORM] "###); } + +#[test] +fn python_install_default() { + let context: TestContext = TestContext::new_with_versions(&[]).with_filtered_python_keys(); + + let bin_python_minor = context + .temp_dir + .child("bin") + .child(format!("python3.13{}", std::env::consts::EXE_SUFFIX)); + + let bin_python_major = context + .temp_dir + .child("bin") + .child(format!("python3{}", std::env::consts::EXE_SUFFIX)); + + let bin_python_default = context + .temp_dir + .child("bin") + .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 + ----- stdout ----- + + ----- stderr ----- + The `--default` flag is only available in preview mode; add the `--preview` flag to use `--default. + "###); + + // Install a specific version + uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.13"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Installed Python 3.13.0 in [TIME] + + cpython-3.13.[X]-[PLATFORM] + warning: `[TEMP_DIR]/bin` is not on your PATH. To use the installed Python executable, run `export PATH="[TEMP_DIR]/bin:$PATH"`. + "###); + + // Only the minor versioned executable should be installed + bin_python_minor.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("--preview").arg("--default").arg("3.13"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Installed Python 3.13.0 in [TIME] + + cpython-3.13.[X]-[PLATFORM] + warning: `[TEMP_DIR]/bin` is not on your PATH. To use the installed Python executable, run `export PATH="[TEMP_DIR]/bin:$PATH"`. + "###); + + // Now all the executables should be installed + bin_python_minor.assert(predicate::path::exists()); + bin_python_major.assert(predicate::path::exists()); + bin_python_default.assert(predicate::path::exists()); + + 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.0 in [TIME] + - cpython-3.13.[X]-[PLATFORM] + "###); + + // The executables should be removed + bin_python_minor.assert(predicate::path::missing()); + bin_python_major.assert(predicate::path::missing()); + bin_python_default.assert(predicate::path::missing()); + + // Install the latest version + uv_snapshot!(context.filters(), context.python_install().arg("--preview"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Installed Python 3.13.0 in [TIME] + + cpython-3.13.[X]-[PLATFORM] + warning: `[TEMP_DIR]/bin` is not on your PATH. To use the installed Python executable, run `export PATH="[TEMP_DIR]/bin:$PATH"`. + "###); + + // Since it's a bare install, we should include all of the executables + bin_python_minor.assert(predicate::path::exists()); + bin_python_major.assert(predicate::path::exists()); + bin_python_default.assert(predicate::path::exists()); + + 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.0 in [TIME] + - cpython-3.13.[X]-[PLATFORM] + "###); + + // We should remove all the executables + bin_python_minor.assert(predicate::path::missing()); + bin_python_major.assert(predicate::path::missing()); + bin_python_default.assert(predicate::path::missing()); + + // Install multiple versions + uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.12").arg("3.13").arg("--default"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Installed 2 versions in [TIME] + + cpython-3.12.[X]-[PLATFORM] + + cpython-3.13.[X]-[PLATFORM] + warning: `[TEMP_DIR]/bin` is not on your PATH. To use the installed Python executable, run `export PATH="[TEMP_DIR]/bin:$PATH"`. + "###); + + bin_python_minor.assert(predicate::path::exists()); + bin_python_major.assert(predicate::path::exists()); + bin_python_default.assert(predicate::path::exists()); + + let bin_python_minor_12 = context + .temp_dir + .child("bin") + .child(format!("python3.12{}", std::env::consts::EXE_SUFFIX)); + + bin_python_minor_12.assert(predicate::path::exists()); + assert!(is_same_file(&bin_python_minor_12, &bin_python_major).unwrap()); + assert!(is_same_file(&bin_python_minor_12, &bin_python_default).unwrap()); +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 55e5f26466523..daa390ef950f2 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -4352,6 +4352,14 @@ uv python install [OPTIONS] [TARGETS]...

While uv configuration can be included in a pyproject.toml file, it is not allowed in this context.

May also be set with the UV_CONFIG_FILE environment variable.

+
--default

Use as the default Python version.

+ +

By default, only a python{major}.{minor} executable is installed, e.g., python3.10. When the --default flag is used, python{major}, e.g., python3, and python executables are also installed.

+ +

Alternative Python variants will still include their tag. For example, installing 3.13+freethreaded with --default will include in python3t and pythont, not python3 and python.

+ +

If multiple Python versions are requested during the installation, the first request will be the default.

+
--directory directory

Change to the given directory prior to running the command.

Relative paths are resolved with the given directory as the base.