diff --git a/Cargo.lock b/Cargo.lock index 75bbcbd0c..a515c9dc9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4490,7 +4490,6 @@ dependencies = [ "regex", "reqwest", "rustc-hash", - "same-file", "serde", "serde_json", "similar", diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 9d143d521..0602e3f4a 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -3396,6 +3396,20 @@ pub struct ToolUpgradeArgs { #[arg(long, conflicts_with("name"))] pub all: bool, + /// Upgrade a tool, and specify it to use the given Python interpreter + /// to build its environment. Use with `--all` to apply to all tools. + /// + /// See `uv help python` for details on Python discovery and supported + /// request formats. + #[arg( + long, + short, + env = "UV_PYTHON", + verbatim_doc_comment, + help_heading = "Python options" + )] + pub python: Option, + #[command(flatten)] pub installer: ResolverInstallerArgs, diff --git a/crates/uv-python/src/environment.rs b/crates/uv-python/src/environment.rs index fb181f3e0..aa887444d 100644 --- a/crates/uv-python/src/environment.rs +++ b/crates/uv-python/src/environment.rs @@ -300,4 +300,26 @@ impl PythonEnvironment { pub fn into_interpreter(self) -> Interpreter { Arc::unwrap_or_clone(self.0).interpreter } + + /// Returns `true` if the [`PythonEnvironment`] uses the same underlying [`Interpreter`]. + pub fn uses(&self, interpreter: &Interpreter) -> bool { + // TODO(zanieb): Consider using `sysconfig.get_path("stdlib")` instead, which + // should be generally robust. + if cfg!(windows) { + // On Windows, we can't canonicalize an interpreter based on its executable path + // because the executables are separate shim files (not links). Instead, we + // compare the `sys.base_prefix`. + let old_base_prefix = self.interpreter().sys_base_prefix(); + let selected_base_prefix = interpreter.sys_base_prefix(); + old_base_prefix == selected_base_prefix + } else { + // On Unix, we can see if the canonicalized executable is the same file. + self.interpreter().sys_executable() == interpreter.sys_executable() + || same_file::is_same_file( + self.interpreter().sys_executable(), + interpreter.sys_executable(), + ) + .unwrap_or(false) + } + } } diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index a96f46faa..793ed11d5 100644 --- a/crates/uv/Cargo.toml +++ b/crates/uv/Cargo.toml @@ -72,7 +72,6 @@ owo-colors = { workspace = true } rayon = { workspace = true } regex = { workspace = true } rustc-hash = { workspace = true } -same-file = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tempfile = { workspace = true } diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index ae7c996ea..1723cb869 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -277,23 +277,7 @@ pub(crate) async fn install( installed_tools .get_environment(&from.name, &cache)? .filter(|environment| { - // TODO(zanieb): Consider using `sysconfig.get_path("stdlib")` instead, which - // should be generally robust. - // TODO(zanieb): Move this into a utility on `Interpreter` since it's non-trivial. - let same_interpreter = if cfg!(windows) { - // On Windows, we can't canonicalize an interpreter based on its executable path - // because the executables are separate shim files (not links). Instead, we - // compare the `sys.base_prefix`. - let old_base_prefix = environment.interpreter().sys_base_prefix(); - let selected_base_prefix = interpreter.sys_base_prefix(); - old_base_prefix == selected_base_prefix - } else { - // On Unix, we can see if the canonicalized executable is the same file. - environment.interpreter().sys_executable() == interpreter.sys_executable() - || same_file::is_same_file(environment.interpreter().sys_executable(), interpreter.sys_executable()).unwrap_or(false) - }; - - if same_interpreter { + if environment.uses(&interpreter) { trace!( "Existing interpreter matches the requested interpreter for `{}`: {}", from.name, diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index 7f4ba7172..2296c62f8 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -5,16 +5,24 @@ use owo_colors::OwoColorize; use tracing::debug; use uv_cache::Cache; -use uv_client::Connectivity; +use uv_client::{BaseClientBuilder, Connectivity}; use uv_configuration::Concurrency; use uv_normalize::PackageName; +use uv_python::{ + EnvironmentPreference, Interpreter, PythonDownloads, PythonInstallation, PythonPreference, + PythonRequest, +}; use uv_requirements::RequirementsSpecification; use uv_settings::{Combine, ResolverInstallerOptions, ToolOptions}; use uv_tool::InstalledTools; -use crate::commands::pip::loggers::{SummaryResolveLogger, UpgradeInstallLogger}; -use crate::commands::pip::operations::Changelog; -use crate::commands::project::{update_environment, EnvironmentUpdate}; +use crate::commands::pip::loggers::{ + DefaultInstallLogger, SummaryResolveLogger, UpgradeInstallLogger, +}; +use crate::commands::project::{ + resolve_environment, sync_environment, update_environment, EnvironmentUpdate, +}; +use crate::commands::reporters::PythonDownloadReporter; use crate::commands::tool::common::remove_entrypoints; use crate::commands::{tool::common::install_executables, ExitStatus, SharedState}; use crate::printer::Printer; @@ -23,9 +31,12 @@ use crate::settings::ResolverInstallerSettings; /// Upgrade a tool. pub(crate) async fn upgrade( name: Vec, + python: Option, connectivity: Connectivity, args: ResolverInstallerOptions, filesystem: ResolverInstallerOptions, + python_preference: PythonPreference, + python_downloads: PythonDownloads, concurrency: Concurrency, native_tls: bool, cache: &Cache, @@ -34,6 +45,7 @@ pub(crate) async fn upgrade( let installed_tools = InstalledTools::from_settings()?.init()?; let _lock = installed_tools.lock().await?; + // Collect the tools to upgrade. let names: BTreeSet = { if name.is_empty() { installed_tools @@ -52,16 +64,45 @@ pub(crate) async fn upgrade( return Ok(ExitStatus::Success); } + let reporter = PythonDownloadReporter::single(printer); + let client_builder = BaseClientBuilder::new() + .connectivity(connectivity) + .native_tls(native_tls); + + let python_request = python.as_deref().map(PythonRequest::parse); + + let interpreter = if python_request.is_some() { + Some( + PythonInstallation::find_or_download( + python_request.as_ref(), + EnvironmentPreference::OnlySystem, + python_preference, + python_downloads, + &client_builder, + cache, + Some(&reporter), + ) + .await? + .into_interpreter(), + ) + } else { + None + }; + // Determine whether we applied any upgrades. - let mut did_upgrade = false; + let mut did_upgrade_tool = vec![]; + + // Determine whether we applied any upgrades. + let mut did_upgrade_environment = vec![]; // Determine whether any tool upgrade failed. let mut failed_upgrade = false; for name in &names { debug!("Upgrading tool: `{name}`"); - let changelog = upgrade_tool( + let result = upgrade_tool( name, + interpreter.as_ref(), printer, &installed_tools, &args, @@ -73,9 +114,15 @@ pub(crate) async fn upgrade( ) .await; - match changelog { - Ok(changelog) => { - did_upgrade |= !changelog.is_empty(); + match result { + Ok(UpgradeOutcome::UpgradeEnvironment) => { + did_upgrade_environment.push(name); + } + Ok(UpgradeOutcome::UpgradeDependencies | UpgradeOutcome::UpgradeTool) => { + did_upgrade_tool.push(name); + } + Ok(UpgradeOutcome::NoOp) => { + debug!("Upgrading `{name}` was a no-op"); } Err(err) => { // If we have a single tool, return the error directly. @@ -97,15 +144,43 @@ pub(crate) async fn upgrade( return Ok(ExitStatus::Failure); } - if !did_upgrade { + if did_upgrade_tool.is_empty() && did_upgrade_environment.is_empty() { writeln!(printer.stderr(), "Nothing to upgrade")?; } + if let Some(python_request) = python_request { + let tools = did_upgrade_environment + .iter() + .map(|name| format!("`{}`", name.cyan())) + .collect::>(); + let s = if tools.len() > 1 { "s" } else { "" }; + writeln!( + printer.stderr(), + "Upgraded tool environment{s} for {} to {}", + conjunction(tools), + python_request.cyan(), + )?; + } + Ok(ExitStatus::Success) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum UpgradeOutcome { + /// The tool itself was upgraded. + UpgradeTool, + /// The tool's dependencies were upgraded, but the tool itself was unchanged. + UpgradeDependencies, + /// The tool's environment was upgraded. + UpgradeEnvironment, + /// The tool was already up-to-date. + NoOp, +} + +/// Upgrade a specific tool. async fn upgrade_tool( name: &PackageName, + interpreter: Option<&Interpreter>, printer: Printer, installed_tools: &InstalledTools, args: &ResolverInstallerOptions, @@ -114,7 +189,7 @@ async fn upgrade_tool( connectivity: Connectivity, concurrency: Concurrency, native_tls: bool, -) -> Result { +) -> Result { // Ensure the tool is installed. let existing_tool_receipt = match installed_tools.get_tool_receipt(name) { Ok(Some(receipt)) => receipt, @@ -136,7 +211,7 @@ async fn upgrade_tool( } }; - let existing_environment = match installed_tools.get_environment(name, cache) { + let environment = match installed_tools.get_environment(name, cache) { Ok(Some(environment)) => environment, Ok(None) => { let install_command = format!("uv tool install {name}"); @@ -170,32 +245,85 @@ async fn upgrade_tool( // Initialize any shared state. let state = SharedState::default(); - // TODO(zanieb): Build the environment in the cache directory then copy into the tool - // directory. - let EnvironmentUpdate { - environment, - changelog, - } = update_environment( - existing_environment, - spec, - &settings, - &state, - Box::new(SummaryResolveLogger), - Box::new(UpgradeInstallLogger::new(name.clone())), - connectivity, - concurrency, - native_tls, - cache, - printer, - ) - .await?; + // Check if we need to create a new environment — if so, resolve it first, then + // install the requested tool + let (environment, outcome) = if let Some(interpreter) = + interpreter.filter(|interpreter| !environment.uses(interpreter)) + { + // If we're using a new interpreter, re-create the environment for each tool. + let resolution = resolve_environment( + RequirementsSpecification::from_requirements(requirements.to_vec()).into(), + interpreter, + settings.as_ref().into(), + &state, + Box::new(SummaryResolveLogger), + connectivity, + concurrency, + native_tls, + cache, + printer, + ) + .await?; - // If we modified the target tool, reinstall the entrypoints. - if changelog.includes(name) { + let environment = installed_tools.create_environment(name, interpreter.clone())?; + + let environment = sync_environment( + environment, + &resolution.into(), + settings.as_ref().into(), + &state, + Box::new(DefaultInstallLogger), + connectivity, + concurrency, + native_tls, + cache, + printer, + ) + .await?; + + (environment, UpgradeOutcome::UpgradeEnvironment) + } else { + // Otherwise, upgrade the existing environment. + // TODO(zanieb): Build the environment in the cache directory then copy into the tool + // directory. + let EnvironmentUpdate { + environment, + changelog, + } = update_environment( + environment, + spec, + &settings, + &state, + Box::new(SummaryResolveLogger), + Box::new(UpgradeInstallLogger::new(name.clone())), + connectivity, + concurrency, + native_tls, + cache, + printer, + ) + .await?; + + let outcome = if changelog.includes(name) { + UpgradeOutcome::UpgradeTool + } else if changelog.is_empty() { + UpgradeOutcome::NoOp + } else { + UpgradeOutcome::UpgradeDependencies + }; + + (environment, outcome) + }; + + if matches!( + outcome, + UpgradeOutcome::UpgradeEnvironment | UpgradeOutcome::UpgradeTool + ) { // At this point, we updated the existing environment, so we should remove any of its // existing executables. remove_entrypoints(&existing_tool_receipt); + // If we modified the target tool, reinstall the entrypoints. install_executables( &environment, name, @@ -208,5 +336,32 @@ async fn upgrade_tool( )?; } - Ok(changelog) + Ok(outcome) +} + +/// Given a list of names, return a conjunction of the names (e.g., "Alice, Bob and Charlie"). +fn conjunction(names: Vec) -> String { + let mut names = names.into_iter(); + let first = names.next(); + let last = names.next_back(); + match (first, last) { + (Some(first), Some(last)) => { + let mut result = first; + let mut comma = false; + for name in names { + result.push_str(", "); + result.push_str(&name); + comma = true; + } + if comma { + result.push_str(", and "); + } else { + result.push_str(" and "); + } + result.push_str(&last); + result + } + (Some(first), None) => first, + _ => String::new(), + } } diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index c614905a0..0f7c370f3 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -940,9 +940,12 @@ async fn run(cli: Cli) -> Result { commands::tool_upgrade( args.name, + args.python, globals.connectivity, args.args, args.filesystem, + globals.python_preference, + globals.python_downloads, globals.concurrency, globals.native_tls, &cache, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index c05f5799f..93d192f4a 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -432,6 +432,7 @@ impl ToolInstallSettings { #[derive(Debug, Clone)] pub(crate) struct ToolUpgradeSettings { pub(crate) name: Vec, + pub(crate) python: Option, pub(crate) args: ResolverInstallerOptions, pub(crate) filesystem: ResolverInstallerOptions, } @@ -442,6 +443,7 @@ impl ToolUpgradeSettings { pub(crate) fn resolve(args: ToolUpgradeArgs, filesystem: Option) -> Self { let ToolUpgradeArgs { name, + python, all, mut installer, build, @@ -463,6 +465,7 @@ impl ToolUpgradeSettings { Self { name: if all { vec![] } else { name }, + python, args, filesystem, } diff --git a/crates/uv/tests/tool_upgrade.rs b/crates/uv/tests/tool_upgrade.rs index fbb065cbf..92537534c 100644 --- a/crates/uv/tests/tool_upgrade.rs +++ b/crates/uv/tests/tool_upgrade.rs @@ -3,6 +3,7 @@ use assert_fs::prelude::*; use common::{uv_snapshot, TestContext}; +use insta::assert_snapshot; mod common; @@ -577,3 +578,159 @@ fn test_tool_upgrade_with() { + pytz==2024.1 "###); } + +#[test] +fn test_tool_upgrade_python() { + let context = TestContext::new_with_versions(&["3.11", "3.12"]) + .with_filtered_counts() + .with_filtered_exe_suffix(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + uv_snapshot!(context.filters(), context.tool_install() + .arg("babel==2.6.0") + .arg("--index-url") + .arg("https://test.pypi.org/simple/") + .arg("--python").arg("3.11") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + babel==2.6.0 + + pytz==2018.5 + Installed 1 executable: pybabel + "###); + + uv_snapshot!( + context.filters(), + context.tool_upgrade().arg("babel") + .arg("--python").arg("3.12") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + babel==2.6.0 + + pytz==2018.5 + Installed 1 executable: pybabel + Upgraded tool environment for `babel` to Python 3.12 + "### + ); + + insta::with_settings!({ + filters => context.filters(), + }, { + let content = fs_err::read_to_string(tool_dir.join("babel").join("pyvenv.cfg")).unwrap(); + let lines: Vec<&str> = content.split('\n').collect(); + assert_snapshot!(lines[lines.len() - 3], @r###" + version_info = 3.12.[X] + "###); + }); +} + +#[test] +fn test_tool_upgrade_python_with_all() { + let context = TestContext::new_with_versions(&["3.11", "3.12"]) + .with_filtered_counts() + .with_filtered_exe_suffix(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + uv_snapshot!(context.filters(), context.tool_install() + .arg("babel==2.6.0") + .arg("--index-url") + .arg("https://test.pypi.org/simple/") + .arg("--python").arg("3.11") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + babel==2.6.0 + + pytz==2018.5 + Installed 1 executable: pybabel + "###); + + uv_snapshot!(context.filters(), context.tool_install() + .arg("python-dotenv") + .arg("--index-url") + .arg("https://test.pypi.org/simple/") + .arg("--python").arg("3.11") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + python-dotenv==0.10.2.post2 + Installed 1 executable: dotenv + "###); + + uv_snapshot!( + context.filters(), + context.tool_upgrade().arg("--all") + .arg("--python").arg("3.12") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + babel==2.6.0 + + pytz==2018.5 + Installed 1 executable: pybabel + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + python-dotenv==0.10.2.post2 + Installed 1 executable: dotenv + Upgraded tool environments for `babel` and `python-dotenv` to Python 3.12 + "### + ); + + insta::with_settings!({ + filters => context.filters(), + }, { + let content = fs_err::read_to_string(tool_dir.join("babel").join("pyvenv.cfg")).unwrap(); + let lines: Vec<&str> = content.split('\n').collect(); + assert_snapshot!(lines[lines.len() - 3], @r###" + version_info = 3.12.[X] + "###); + }); + + insta::with_settings!({ + filters => context.filters(), + }, { + let content = fs_err::read_to_string(tool_dir.join("python-dotenv").join("pyvenv.cfg")).unwrap(); + let lines: Vec<&str> = content.split('\n').collect(); + assert_snapshot!(lines[lines.len() - 3], @r###" + version_info = 3.12.[X] + "###); + }); +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 1b8e947bd..0972e9480 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -3248,6 +3248,11 @@ uv tool upgrade [OPTIONS] ...

This setting has no effect when used in the uv pip interface.

+
--python, -p python

Upgrade a tool, and specify it to use the given Python interpreter to build its environment. Use with --all to apply to all tools.

+ +

See uv python for details on Python discovery and supported request formats.

+ +

May also be set with the UV_PYTHON environment variable.

--python-preference python-preference

Whether to prefer uv-managed or system Python installations.

By default, uv prefers using Python versions it manages. However, it will use system Python installations if a uv-managed Python is not installed. This option allows prioritizing or ignoring system Python installations.