Skip to content

Commit 7d63ef1

Browse files
authored
Surface pinned-version hint when uv tool upgrade can’t move the tool (#16081)
Resolves #15665 `uv tool upgrade` already respects version pins, but when a tool was installed with an exact version the command quietly becomes a no-op even though users expect it to upgrade the executable. This change tweaks the upgrade flow to detect that situation by inspecting the stored receipt and, whenever the tool stays pinned, emit a concise hint explaining why the version didn’t move and how to reinstall without the pin. The message still appears if the run only refreshed supporting packages, so users aren’t misled by dependency churn that leaves the tool itself untouched. I also added an integration test for the scenario end to end by installing `babel==2.6.0`, attempting an upgrade, and asserting that the hint is shown alongside the dependency updates.
1 parent 73e62c0 commit 7d63ef1

2 files changed

Lines changed: 203 additions & 12 deletions

File tree

crates/uv/src/commands/tool/upgrade.rs

Lines changed: 98 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,18 @@ use tracing::{debug, trace};
99
use uv_cache::Cache;
1010
use uv_client::BaseClientBuilder;
1111
use uv_configuration::{Concurrency, Constraints, DryRun, TargetTriple};
12-
use uv_distribution_types::{ExtraBuildRequires, Requirement};
12+
use uv_distribution_types::{ExtraBuildRequires, Requirement, RequirementSource};
1313
use uv_fs::CWD;
1414
use uv_normalize::PackageName;
15+
use uv_pep440::{Operator, Version};
1516
use uv_preview::Preview;
1617
use uv_python::{
1718
EnvironmentPreference, Interpreter, PythonDownloads, PythonInstallation, PythonPreference,
1819
PythonRequest,
1920
};
2021
use uv_requirements::RequirementsSpecification;
2122
use uv_settings::{Combine, PythonInstallMirrors, ResolverInstallerOptions, ToolOptions};
22-
use uv_tool::InstalledTools;
23+
use uv_tool::{InstalledTools, Tool};
2324
use uv_warnings::write_error_chain;
2425
use uv_workspace::WorkspaceCache;
2526

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

118+
// Constraints that caused upgrades to be skipped or altered.
119+
let mut collected_constraints: Vec<(PackageName, UpgradeConstraint)> = Vec::new();
120+
117121
let mut errors = Vec::new();
118122
for (name, constraints) in &names {
119123
debug!("Upgrading tool: `{name}`");
@@ -135,14 +139,22 @@ pub(crate) async fn upgrade(
135139
.await;
136140

137141
match result {
138-
Ok(UpgradeOutcome::UpgradeEnvironment) => {
139-
did_upgrade_environment.push(name);
140-
}
141-
Ok(UpgradeOutcome::UpgradeDependencies | UpgradeOutcome::UpgradeTool) => {
142-
did_upgrade_tool.push(name);
143-
}
144-
Ok(UpgradeOutcome::NoOp) => {
145-
debug!("Upgrading `{name}` was a no-op");
142+
Ok(report) => {
143+
match report.outcome {
144+
UpgradeOutcome::UpgradeEnvironment => {
145+
did_upgrade_environment.push(name);
146+
}
147+
UpgradeOutcome::UpgradeTool | UpgradeOutcome::UpgradeDependencies => {
148+
did_upgrade_tool.push(name);
149+
}
150+
UpgradeOutcome::NoOp => {
151+
debug!("Upgrading `{name}` was a no-op");
152+
}
153+
}
154+
155+
if let Some(constraint) = report.constraint.clone() {
156+
collected_constraints.push((name.clone(), constraint));
157+
}
146158
}
147159
Err(err) => {
148160
errors.push((name, err));
@@ -187,6 +199,14 @@ pub(crate) async fn upgrade(
187199
}
188200
}
189201

202+
if !collected_constraints.is_empty() {
203+
writeln!(printer.stderr())?;
204+
}
205+
206+
for (name, constraint) in collected_constraints {
207+
constraint.print(&name, printer)?;
208+
}
209+
190210
Ok(ExitStatus::Success)
191211
}
192212

@@ -202,6 +222,39 @@ enum UpgradeOutcome {
202222
NoOp,
203223
}
204224

225+
#[derive(Debug, Clone, PartialEq, Eq)]
226+
enum UpgradeConstraint {
227+
/// The tool remains pinned to an exact version, so an upgrade was skipped.
228+
PinnedVersion { version: Version },
229+
}
230+
231+
impl UpgradeConstraint {
232+
fn print(&self, name: &PackageName, printer: Printer) -> Result<()> {
233+
match self {
234+
Self::PinnedVersion { version } => {
235+
let name = name.to_string();
236+
let reinstall_command = format!("uv tool install {name}@latest");
237+
238+
writeln!(
239+
printer.stderr(),
240+
"hint: `{}` is pinned to `{}` (installed with an exact version pin); reinstall with `{}` to upgrade to a new version.",
241+
name.cyan(),
242+
version.to_string().magenta(),
243+
reinstall_command.green(),
244+
)?;
245+
}
246+
}
247+
248+
Ok(())
249+
}
250+
}
251+
252+
#[derive(Debug, Clone, PartialEq, Eq)]
253+
struct UpgradeReport {
254+
outcome: UpgradeOutcome,
255+
constraint: Option<UpgradeConstraint>,
256+
}
257+
205258
/// Upgrade a specific tool.
206259
async fn upgrade_tool(
207260
name: &PackageName,
@@ -217,7 +270,7 @@ async fn upgrade_tool(
217270
installer_metadata: bool,
218271
concurrency: Concurrency,
219272
preview: Preview,
220-
) -> Result<UpgradeOutcome> {
273+
) -> Result<UpgradeReport> {
221274
// Ensure the tool is installed.
222275
let existing_tool_receipt = match installed_tools.get_tool_receipt(name) {
223276
Ok(Some(receipt)) => receipt,
@@ -398,5 +451,38 @@ async fn upgrade_tool(
398451
)?;
399452
}
400453

401-
Ok(outcome)
454+
let constraint = match &outcome {
455+
UpgradeOutcome::UpgradeDependencies | UpgradeOutcome::NoOp => {
456+
pinned_requirement_version(&existing_tool_receipt, name)
457+
.map(|version| UpgradeConstraint::PinnedVersion { version })
458+
}
459+
UpgradeOutcome::UpgradeTool | UpgradeOutcome::UpgradeEnvironment => None,
460+
};
461+
462+
Ok(UpgradeReport {
463+
outcome,
464+
constraint,
465+
})
466+
}
467+
468+
fn pinned_requirement_version(tool: &Tool, name: &PackageName) -> Option<Version> {
469+
pinned_version_from(tool.requirements(), name)
470+
.or_else(|| pinned_version_from(tool.constraints(), name))
471+
}
472+
473+
fn pinned_version_from(requirements: &[Requirement], name: &PackageName) -> Option<Version> {
474+
requirements
475+
.iter()
476+
.filter(|requirement| requirement.name == *name)
477+
.find_map(|requirement| match &requirement.source {
478+
RequirementSource::Registry { specifier, .. } => {
479+
specifier
480+
.iter()
481+
.find_map(|specifier| match specifier.operator() {
482+
Operator::Equal | Operator::ExactEqual => Some(specifier.version().clone()),
483+
_ => None,
484+
})
485+
}
486+
_ => None,
487+
})
402488
}

crates/uv/tests/it/tool_upgrade.rs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,109 @@ fn tool_upgrade_multiple_names() {
215215
"###);
216216
}
217217

218+
#[test]
219+
fn tool_upgrade_pinned_hint() {
220+
let context = TestContext::new("3.12")
221+
.with_filtered_counts()
222+
.with_filtered_exe_suffix();
223+
224+
let tool_dir = context.temp_dir.child("tools");
225+
let bin_dir = context.temp_dir.child("bin");
226+
227+
// Install a specific version of `babel` so the receipt records an exact pin.
228+
uv_snapshot!(context.filters(), context.tool_install()
229+
.arg("babel==2.6.0")
230+
.arg("--index-url")
231+
.arg("https://test.pypi.org/simple/")
232+
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
233+
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
234+
.env(EnvVars::PATH, bin_dir.as_os_str()), @r###"
235+
success: true
236+
exit_code: 0
237+
----- stdout -----
238+
239+
----- stderr -----
240+
Resolved [N] packages in [TIME]
241+
Prepared [N] packages in [TIME]
242+
Installed [N] packages in [TIME]
243+
+ babel==2.6.0
244+
+ pytz==2018.5
245+
Installed 1 executable: pybabel
246+
"###);
247+
248+
// Attempt to upgrade `babel`; it should remain pinned and emit a hint explaining why.
249+
uv_snapshot!(context.filters(), context.tool_upgrade()
250+
.arg("babel")
251+
.arg("--index-url")
252+
.arg("https://pypi.org/simple/")
253+
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
254+
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
255+
.env(EnvVars::PATH, bin_dir.as_os_str()), @r###"
256+
success: true
257+
exit_code: 0
258+
----- stdout -----
259+
260+
----- stderr -----
261+
Modified babel environment
262+
- pytz==2018.5
263+
+ pytz==2024.1
264+
265+
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.
266+
"###);
267+
}
268+
269+
#[test]
270+
fn tool_upgrade_pinned_hint_with_mixed_constraint() {
271+
let context = TestContext::new("3.12")
272+
.with_filtered_counts()
273+
.with_filtered_exe_suffix();
274+
275+
let tool_dir = context.temp_dir.child("tools");
276+
let bin_dir = context.temp_dir.child("bin");
277+
278+
// Install a specific version of `babel` with an additional constraint to ensure the requirement
279+
// contains multiple specifiers while still including an exact pin.
280+
uv_snapshot!(context.filters(), context.tool_install()
281+
.arg("babel>=2.0,==2.6.0")
282+
.arg("--index-url")
283+
.arg("https://test.pypi.org/simple/")
284+
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
285+
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
286+
.env(EnvVars::PATH, bin_dir.as_os_str()), @r###"
287+
success: true
288+
exit_code: 0
289+
----- stdout -----
290+
291+
----- stderr -----
292+
Resolved [N] packages in [TIME]
293+
Prepared [N] packages in [TIME]
294+
Installed [N] packages in [TIME]
295+
+ babel==2.6.0
296+
+ pytz==2018.5
297+
Installed 1 executable: pybabel
298+
"###);
299+
300+
// Attempt to upgrade `babel`; it should remain pinned and emit a hint explaining why.
301+
uv_snapshot!(context.filters(), context.tool_upgrade()
302+
.arg("babel")
303+
.arg("--index-url")
304+
.arg("https://pypi.org/simple/")
305+
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
306+
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
307+
.env(EnvVars::PATH, bin_dir.as_os_str()), @r###"
308+
success: true
309+
exit_code: 0
310+
----- stdout -----
311+
312+
----- stderr -----
313+
Modified babel environment
314+
- pytz==2018.5
315+
+ pytz==2024.1
316+
317+
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.
318+
"###);
319+
}
320+
218321
#[test]
219322
fn tool_upgrade_all() {
220323
let context = TestContext::new("3.12")
@@ -683,6 +786,8 @@ fn tool_upgrade_with() {
683786
Modified babel environment
684787
- pytz==2018.5
685788
+ pytz==2024.1
789+
790+
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.
686791
"###);
687792
}
688793

0 commit comments

Comments
 (0)