Add support for upgrading Python in tool environments (#7605)

This PR adds support for upgrading the build environment of tools with
the addition of a ```--python``` argument to ```uv upgrade```, as
specified in #7471.

Some things to note:
- I added support for individual packages — I didn't think there was a
good reason for ```--python``` to only apply to all packages
- Upgrading with ```--python``` also upgrades the package itself — I
think this is fair as if a user wants to _strictly_ switch the version
of Python being used to build a tool's environment they can use ```uv
install```. This behavior can of course be modified if others don't
agree!

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

Closes https://github.com/astral-sh/uv/issues/7471.
This commit is contained in:
tfsingh 2024-09-25 10:40:28 -07:00 committed by GitHub
parent f5601e2610
commit 106633a5e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 394 additions and 53 deletions

1
Cargo.lock generated
View file

@ -4490,7 +4490,6 @@ dependencies = [
"regex", "regex",
"reqwest", "reqwest",
"rustc-hash", "rustc-hash",
"same-file",
"serde", "serde",
"serde_json", "serde_json",
"similar", "similar",

View file

@ -3396,6 +3396,20 @@ pub struct ToolUpgradeArgs {
#[arg(long, conflicts_with("name"))] #[arg(long, conflicts_with("name"))]
pub all: bool, 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<String>,
#[command(flatten)] #[command(flatten)]
pub installer: ResolverInstallerArgs, pub installer: ResolverInstallerArgs,

View file

@ -300,4 +300,26 @@ impl PythonEnvironment {
pub fn into_interpreter(self) -> Interpreter { pub fn into_interpreter(self) -> Interpreter {
Arc::unwrap_or_clone(self.0).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)
}
}
} }

View file

@ -72,7 +72,6 @@ owo-colors = { workspace = true }
rayon = { workspace = true } rayon = { workspace = true }
regex = { workspace = true } regex = { workspace = true }
rustc-hash = { workspace = true } rustc-hash = { workspace = true }
same-file = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
tempfile = { workspace = true } tempfile = { workspace = true }

View file

@ -277,23 +277,7 @@ pub(crate) async fn install(
installed_tools installed_tools
.get_environment(&from.name, &cache)? .get_environment(&from.name, &cache)?
.filter(|environment| { .filter(|environment| {
// TODO(zanieb): Consider using `sysconfig.get_path("stdlib")` instead, which if environment.uses(&interpreter) {
// 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 {
trace!( trace!(
"Existing interpreter matches the requested interpreter for `{}`: {}", "Existing interpreter matches the requested interpreter for `{}`: {}",
from.name, from.name,

View file

@ -5,16 +5,24 @@ use owo_colors::OwoColorize;
use tracing::debug; use tracing::debug;
use uv_cache::Cache; use uv_cache::Cache;
use uv_client::Connectivity; use uv_client::{BaseClientBuilder, Connectivity};
use uv_configuration::Concurrency; use uv_configuration::Concurrency;
use uv_normalize::PackageName; use uv_normalize::PackageName;
use uv_python::{
EnvironmentPreference, Interpreter, PythonDownloads, PythonInstallation, PythonPreference,
PythonRequest,
};
use uv_requirements::RequirementsSpecification; use uv_requirements::RequirementsSpecification;
use uv_settings::{Combine, ResolverInstallerOptions, ToolOptions}; use uv_settings::{Combine, ResolverInstallerOptions, ToolOptions};
use uv_tool::InstalledTools; use uv_tool::InstalledTools;
use crate::commands::pip::loggers::{SummaryResolveLogger, UpgradeInstallLogger}; use crate::commands::pip::loggers::{
use crate::commands::pip::operations::Changelog; DefaultInstallLogger, SummaryResolveLogger, UpgradeInstallLogger,
use crate::commands::project::{update_environment, EnvironmentUpdate}; };
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::remove_entrypoints;
use crate::commands::{tool::common::install_executables, ExitStatus, SharedState}; use crate::commands::{tool::common::install_executables, ExitStatus, SharedState};
use crate::printer::Printer; use crate::printer::Printer;
@ -23,9 +31,12 @@ use crate::settings::ResolverInstallerSettings;
/// Upgrade a tool. /// Upgrade a tool.
pub(crate) async fn upgrade( pub(crate) async fn upgrade(
name: Vec<PackageName>, name: Vec<PackageName>,
python: Option<String>,
connectivity: Connectivity, connectivity: Connectivity,
args: ResolverInstallerOptions, args: ResolverInstallerOptions,
filesystem: ResolverInstallerOptions, filesystem: ResolverInstallerOptions,
python_preference: PythonPreference,
python_downloads: PythonDownloads,
concurrency: Concurrency, concurrency: Concurrency,
native_tls: bool, native_tls: bool,
cache: &Cache, cache: &Cache,
@ -34,6 +45,7 @@ pub(crate) async fn upgrade(
let installed_tools = InstalledTools::from_settings()?.init()?; let installed_tools = InstalledTools::from_settings()?.init()?;
let _lock = installed_tools.lock().await?; let _lock = installed_tools.lock().await?;
// Collect the tools to upgrade.
let names: BTreeSet<PackageName> = { let names: BTreeSet<PackageName> = {
if name.is_empty() { if name.is_empty() {
installed_tools installed_tools
@ -52,16 +64,45 @@ pub(crate) async fn upgrade(
return Ok(ExitStatus::Success); 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. // 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. // Determine whether any tool upgrade failed.
let mut failed_upgrade = false; let mut failed_upgrade = false;
for name in &names { for name in &names {
debug!("Upgrading tool: `{name}`"); debug!("Upgrading tool: `{name}`");
let changelog = upgrade_tool( let result = upgrade_tool(
name, name,
interpreter.as_ref(),
printer, printer,
&installed_tools, &installed_tools,
&args, &args,
@ -73,9 +114,15 @@ pub(crate) async fn upgrade(
) )
.await; .await;
match changelog { match result {
Ok(changelog) => { Ok(UpgradeOutcome::UpgradeEnvironment) => {
did_upgrade |= !changelog.is_empty(); 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) => { Err(err) => {
// If we have a single tool, return the error directly. // If we have a single tool, return the error directly.
@ -97,15 +144,43 @@ pub(crate) async fn upgrade(
return Ok(ExitStatus::Failure); return Ok(ExitStatus::Failure);
} }
if !did_upgrade { if did_upgrade_tool.is_empty() && did_upgrade_environment.is_empty() {
writeln!(printer.stderr(), "Nothing to upgrade")?; 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::<Vec<_>>();
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) 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( async fn upgrade_tool(
name: &PackageName, name: &PackageName,
interpreter: Option<&Interpreter>,
printer: Printer, printer: Printer,
installed_tools: &InstalledTools, installed_tools: &InstalledTools,
args: &ResolverInstallerOptions, args: &ResolverInstallerOptions,
@ -114,7 +189,7 @@ async fn upgrade_tool(
connectivity: Connectivity, connectivity: Connectivity,
concurrency: Concurrency, concurrency: Concurrency,
native_tls: bool, native_tls: bool,
) -> Result<Changelog> { ) -> Result<UpgradeOutcome> {
// Ensure the tool is installed. // Ensure the tool is installed.
let existing_tool_receipt = match installed_tools.get_tool_receipt(name) { let existing_tool_receipt = match installed_tools.get_tool_receipt(name) {
Ok(Some(receipt)) => receipt, 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(Some(environment)) => environment,
Ok(None) => { Ok(None) => {
let install_command = format!("uv tool install {name}"); let install_command = format!("uv tool install {name}");
@ -170,32 +245,85 @@ async fn upgrade_tool(
// Initialize any shared state. // Initialize any shared state.
let state = SharedState::default(); let state = SharedState::default();
// TODO(zanieb): Build the environment in the cache directory then copy into the tool // Check if we need to create a new environment — if so, resolve it first, then
// directory. // install the requested tool
let EnvironmentUpdate { let (environment, outcome) = if let Some(interpreter) =
environment, interpreter.filter(|interpreter| !environment.uses(interpreter))
changelog, {
} = update_environment( // If we're using a new interpreter, re-create the environment for each tool.
existing_environment, let resolution = resolve_environment(
spec, RequirementsSpecification::from_requirements(requirements.to_vec()).into(),
&settings, interpreter,
&state, settings.as_ref().into(),
Box::new(SummaryResolveLogger), &state,
Box::new(UpgradeInstallLogger::new(name.clone())), Box::new(SummaryResolveLogger),
connectivity, connectivity,
concurrency, concurrency,
native_tls, native_tls,
cache, cache,
printer, printer,
) )
.await?; .await?;
// If we modified the target tool, reinstall the entrypoints. let environment = installed_tools.create_environment(name, interpreter.clone())?;
if changelog.includes(name) {
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 // At this point, we updated the existing environment, so we should remove any of its
// existing executables. // existing executables.
remove_entrypoints(&existing_tool_receipt); remove_entrypoints(&existing_tool_receipt);
// If we modified the target tool, reinstall the entrypoints.
install_executables( install_executables(
&environment, &environment,
name, 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>) -> 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(),
}
} }

View file

@ -940,9 +940,12 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
commands::tool_upgrade( commands::tool_upgrade(
args.name, args.name,
args.python,
globals.connectivity, globals.connectivity,
args.args, args.args,
args.filesystem, args.filesystem,
globals.python_preference,
globals.python_downloads,
globals.concurrency, globals.concurrency,
globals.native_tls, globals.native_tls,
&cache, &cache,

View file

@ -432,6 +432,7 @@ impl ToolInstallSettings {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct ToolUpgradeSettings { pub(crate) struct ToolUpgradeSettings {
pub(crate) name: Vec<PackageName>, pub(crate) name: Vec<PackageName>,
pub(crate) python: Option<String>,
pub(crate) args: ResolverInstallerOptions, pub(crate) args: ResolverInstallerOptions,
pub(crate) filesystem: ResolverInstallerOptions, pub(crate) filesystem: ResolverInstallerOptions,
} }
@ -442,6 +443,7 @@ impl ToolUpgradeSettings {
pub(crate) fn resolve(args: ToolUpgradeArgs, filesystem: Option<FilesystemOptions>) -> Self { pub(crate) fn resolve(args: ToolUpgradeArgs, filesystem: Option<FilesystemOptions>) -> Self {
let ToolUpgradeArgs { let ToolUpgradeArgs {
name, name,
python,
all, all,
mut installer, mut installer,
build, build,
@ -463,6 +465,7 @@ impl ToolUpgradeSettings {
Self { Self {
name: if all { vec![] } else { name }, name: if all { vec![] } else { name },
python,
args, args,
filesystem, filesystem,
} }

View file

@ -3,6 +3,7 @@
use assert_fs::prelude::*; use assert_fs::prelude::*;
use common::{uv_snapshot, TestContext}; use common::{uv_snapshot, TestContext};
use insta::assert_snapshot;
mod common; mod common;
@ -577,3 +578,159 @@ fn test_tool_upgrade_with() {
+ pytz==2024.1 + 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]
"###);
});
}

View file

@ -3248,6 +3248,11 @@ uv tool upgrade [OPTIONS] <NAME>...
<p>This setting has no effect when used in the <code>uv pip</code> interface.</p> <p>This setting has no effect when used in the <code>uv pip</code> interface.</p>
</dd><dt><code>--python</code>, <code>-p</code> <i>python</i></dt><dd><p>Upgrade a tool, and specify it to use the given Python interpreter to build its environment. Use with <code>--all</code> to apply to all tools.</p>
<p>See <a href="#uv-python">uv python</a> for details on Python discovery and supported request formats.</p>
<p>May also be set with the <code>UV_PYTHON</code> environment variable.</p>
</dd><dt><code>--python-preference</code> <i>python-preference</i></dt><dd><p>Whether to prefer uv-managed or system Python installations.</p> </dd><dt><code>--python-preference</code> <i>python-preference</i></dt><dd><p>Whether to prefer uv-managed or system Python installations.</p>
<p>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.</p> <p>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.</p>