Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 98 additions & 12 deletions crates/uv/src/commands/tool/upgrade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,18 @@ use tracing::{debug, trace};
use uv_cache::Cache;
use uv_client::BaseClientBuilder;
use uv_configuration::{Concurrency, Constraints, DryRun, TargetTriple};
use uv_distribution_types::{ExtraBuildRequires, Requirement};
use uv_distribution_types::{ExtraBuildRequires, Requirement, RequirementSource};
use uv_fs::CWD;
use uv_normalize::PackageName;
use uv_pep440::{Operator, Version};
use uv_preview::Preview;
use uv_python::{
EnvironmentPreference, Interpreter, PythonDownloads, PythonInstallation, PythonPreference,
PythonRequest,
};
use uv_requirements::RequirementsSpecification;
use uv_settings::{Combine, PythonInstallMirrors, ResolverInstallerOptions, ToolOptions};
use uv_tool::InstalledTools;
use uv_tool::{InstalledTools, Tool};
use uv_warnings::write_error_chain;
use uv_workspace::WorkspaceCache;

Expand Down Expand Up @@ -114,6 +115,9 @@ pub(crate) async fn upgrade(
// Determine whether we applied any upgrades.
let mut did_upgrade_environment = vec![];

// Constraints that caused upgrades to be skipped or altered.
let mut collected_constraints: Vec<(PackageName, UpgradeConstraint)> = Vec::new();

let mut errors = Vec::new();
for (name, constraints) in &names {
debug!("Upgrading tool: `{name}`");
Expand All @@ -135,14 +139,22 @@ pub(crate) async fn upgrade(
.await;

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");
Ok(report) => {
match report.outcome {
UpgradeOutcome::UpgradeEnvironment => {
did_upgrade_environment.push(name);
}
UpgradeOutcome::UpgradeTool | UpgradeOutcome::UpgradeDependencies => {
did_upgrade_tool.push(name);
}
UpgradeOutcome::NoOp => {
debug!("Upgrading `{name}` was a no-op");
}
}

if let Some(constraint) = report.constraint.clone() {
collected_constraints.push((name.clone(), constraint));
}
}
Err(err) => {
errors.push((name, err));
Expand Down Expand Up @@ -187,6 +199,14 @@ pub(crate) async fn upgrade(
}
}

if !collected_constraints.is_empty() {
writeln!(printer.stderr())?;
}

for (name, constraint) in collected_constraints {
constraint.print(&name, printer)?;
}
Comment thread
terror marked this conversation as resolved.

Ok(ExitStatus::Success)
}

Expand All @@ -202,6 +222,39 @@ enum UpgradeOutcome {
NoOp,
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum UpgradeConstraint {
/// The tool remains pinned to an exact version, so an upgrade was skipped.
PinnedVersion { version: Version },
}

impl UpgradeConstraint {
fn print(&self, name: &PackageName, printer: Printer) -> Result<()> {
match self {
Self::PinnedVersion { version } => {
let name = name.to_string();
let reinstall_command = format!("uv tool install {name}@latest");

writeln!(
printer.stderr(),
"hint: `{}` is pinned to `{}` (installed with an exact version pin); reinstall with `{}` to upgrade to a new version.",
name.cyan(),
version.to_string().magenta(),
reinstall_command.green(),
)?;
}
}

Ok(())
}
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct UpgradeReport {
outcome: UpgradeOutcome,
constraint: Option<UpgradeConstraint>,
}
Comment on lines +252 to +256
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's still something awkward about this abstraction, but 🤷‍♀️ I'm not having any great ideas so we can leave it until we have another use-case that helps us refactor.

@konstin might have an idea.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous conversation at #16081 (comment)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have a better idea either.


/// Upgrade a specific tool.
async fn upgrade_tool(
name: &PackageName,
Expand All @@ -217,7 +270,7 @@ async fn upgrade_tool(
installer_metadata: bool,
concurrency: Concurrency,
preview: Preview,
) -> Result<UpgradeOutcome> {
) -> Result<UpgradeReport> {
// Ensure the tool is installed.
let existing_tool_receipt = match installed_tools.get_tool_receipt(name) {
Ok(Some(receipt)) => receipt,
Expand Down Expand Up @@ -398,5 +451,38 @@ async fn upgrade_tool(
)?;
}

Ok(outcome)
let constraint = match &outcome {
UpgradeOutcome::UpgradeDependencies | UpgradeOutcome::NoOp => {
pinned_requirement_version(&existing_tool_receipt, name)
.map(|version| UpgradeConstraint::PinnedVersion { version })
}
UpgradeOutcome::UpgradeTool | UpgradeOutcome::UpgradeEnvironment => None,
};

Ok(UpgradeReport {
outcome,
constraint,
})
}

fn pinned_requirement_version(tool: &Tool, name: &PackageName) -> Option<Version> {
pinned_version_from(tool.requirements(), name)
.or_else(|| pinned_version_from(tool.constraints(), name))
}

fn pinned_version_from(requirements: &[Requirement], name: &PackageName) -> Option<Version> {
requirements
.iter()
.filter(|requirement| requirement.name == *name)
.find_map(|requirement| match &requirement.source {
RequirementSource::Registry { specifier, .. } => {
specifier
.iter()
.find_map(|specifier| match specifier.operator() {
Operator::Equal | Operator::ExactEqual => Some(specifier.version().clone()),
_ => None,
})
}
_ => None,
})
}
105 changes: 105 additions & 0 deletions crates/uv/tests/it/tool_upgrade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,109 @@ fn tool_upgrade_multiple_names() {
"###);
}

#[test]
fn tool_upgrade_pinned_hint() {
let context = TestContext::new("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");

// Install a specific version of `babel` so the receipt records an exact pin.
uv_snapshot!(context.filters(), context.tool_install()
.arg("babel==2.6.0")
.arg("--index-url")
.arg("https://test.pypi.org/simple/")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::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
"###);

// Attempt to upgrade `babel`; it should remain pinned and emit a hint explaining why.
uv_snapshot!(context.filters(), context.tool_upgrade()
.arg("babel")
.arg("--index-url")
.arg("https://pypi.org/simple/")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, bin_dir.as_os_str()), @r###"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Modified babel environment
- pytz==2018.5
+ pytz==2024.1

hint: `babel` is pinned to `2.6.0` (installed with an exact version pin); reinstall with `uv tool install babel@latest` to upgrade to a new version.
"###);
}

#[test]
fn tool_upgrade_pinned_hint_with_mixed_constraint() {
let context = TestContext::new("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");

// Install a specific version of `babel` with an additional constraint to ensure the requirement
// contains multiple specifiers while still including an exact pin.
uv_snapshot!(context.filters(), context.tool_install()
.arg("babel>=2.0,==2.6.0")
.arg("--index-url")
.arg("https://test.pypi.org/simple/")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::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
"###);

// Attempt to upgrade `babel`; it should remain pinned and emit a hint explaining why.
uv_snapshot!(context.filters(), context.tool_upgrade()
.arg("babel")
.arg("--index-url")
.arg("https://pypi.org/simple/")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, bin_dir.as_os_str()), @r###"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Modified babel environment
- pytz==2018.5
+ pytz==2024.1

hint: `babel` is pinned to `2.6.0` (installed with an exact version pin); reinstall with `uv tool install babel@latest` to upgrade to a new version.
"###);
}

#[test]
fn tool_upgrade_all() {
let context = TestContext::new("3.12")
Expand Down Expand Up @@ -683,6 +786,8 @@ fn tool_upgrade_with() {
Modified babel environment
- pytz==2018.5
+ pytz==2024.1

hint: `babel` is pinned to `2.6.0` (installed with an exact version pin); reinstall with `uv tool install babel@latest` to upgrade to a new version.
"###);
}

Expand Down
Loading