From c315a3c6b7a85483015ea32ef3512354b283e18c Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Mon, 16 Jun 2025 08:08:17 +0200 Subject: [PATCH] --packages-only --- crates/uv-cli/src/lib.rs | 16 +++ crates/uv/src/commands/project/update.rs | 137 +++++++++++++++++------ docs/reference/cli.md | 7 +- 3 files changed, 123 insertions(+), 37 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 392d1ec87..1c3c5b25d 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -658,6 +658,10 @@ pub struct UpgradeProjectArgs { value_parser = parse_maybe_string, )] pub python: Option>, + + /// Upgrade only the given requirements (i.e. `uv<0.5`) instead of pyproject.toml files. + #[arg(required = false, value_parser = parse_requirement)] + pub requirements: Vec>, } #[derive(Debug, Copy, Clone, PartialEq, clap::ValueEnum)] @@ -1119,6 +1123,18 @@ fn parse_dependency_type(input: &str) -> Result, String> { } } +/// Parse a string like `uv<0.5` into an [`Requirement`], mapping the empty string to `None`. +fn parse_requirement(input: &str) -> Result, String> { + if input.is_empty() { + Ok(Maybe::None) + } else { + match Requirement::from_str(input) { + Ok(table) => Ok(Maybe::Some(table)), + Err(err) => Err(err.to_string()), + } + } +} + /// Parse a string into an [`usize`], mapping the empty string or unknown digits to `None`. /// /// Allowed: 1, 2, 3 or 4. diff --git a/crates/uv/src/commands/project/update.rs b/crates/uv/src/commands/project/update.rs index 791c9452c..e0f7e6de4 100644 --- a/crates/uv/src/commands/project/update.rs +++ b/crates/uv/src/commands/project/update.rs @@ -56,13 +56,19 @@ pub(crate) async fn upgrade_project_dependencies( allow if !allow.is_empty() => allow, _ => vec![1, 2, 3, 4], }; - let tomls = match args - .recursive - .then(|| search_pyproject_tomls(Path::new("."))) - { - None => vec![".".to_string()], - Some(Ok(tomls)) => tomls, - Some(Err(err)) => return Err(err), + + let only_packages = !args.requirements.is_empty(); + let tomls = if only_packages { + vec![String::new()] + } else { + match args + .recursive + .then(|| search_pyproject_tomls(Path::new("."))) + { + None => vec![String::new()], // recursive=false or no pyproject.toml files found + Some(Ok(tomls)) => tomls, + Some(Err(err)) => return Err(err), // error searching pyproject.toml files + } }; let printer = Printer::Default; @@ -94,11 +100,32 @@ pub(crate) async fn upgrade_project_dependencies( .and_then(|v| RequiresPython::from_str(&v).ok()); let mut all_versioned = FxHashMap::default(); let mut toml_contents = BTreeMap::default(); + let packages: Vec<_> = args + .requirements + .iter() + .filter_map(|r| { + let requirement = r.clone().into_option().expect("no req"); + requirement.version_or_url.as_ref()?; // Skip unversioned requirements + Some(format!("\"{requirement}\"")) + }) + .collect(); for toml_dir in &tomls { - let pyproject_toml = Path::new(toml_dir).join("pyproject.toml"); - let toml = match read_pyproject_toml(&pyproject_toml).await { - Ok(value) => value, - Err(value) => return value, + let toml = if only_packages { + if packages.is_empty() { + warn_user!("No versioned dependencies found in packages"); + return Ok(ExitStatus::Error); + } + let content = format!("[project]\ndependencies = [\n{}\n]", packages.join(",\n")); + match PyProjectTomlMut::from_toml(&content, DependencyTarget::PyProjectToml) { + Ok(p) => p, + Err(err) => { + warn_user!("Couldn't parse packages: {}", err.to_string()); + return Ok(ExitStatus::Error); + } + } + } else { + let pyproject_toml = Path::new(toml_dir).join("pyproject.toml"); + read_pyproject_toml(&pyproject_toml).await? }; let versioned = toml.find_versioned_dependencies(); if versioned.is_empty() { @@ -133,7 +160,7 @@ pub(crate) async fn upgrade_project_dependencies( for (toml_dir, toml) in &mut toml_contents { let pyproject_toml = Path::new(*toml_dir).join("pyproject.toml"); - let relative = if *toml_dir == "." { + let relative = if toml_dir.is_empty() || *toml_dir == "." { String::new() } else { format!("{}/", &toml_dir[2..]) @@ -161,7 +188,8 @@ pub(crate) async fn upgrade_project_dependencies( if !skipped.is_empty() { writeln!( printer.stderr(), - "{info} Skipped {skipped} ({count_skipped} upgrades) of {found} dependencies in {subpath}" + "{info} Skipped {skipped} ({count_skipped} upgrades) of {} in {subpath}", + plural(found, "dependency"), )?; } continue; // Skip intermediate messages if nothing was changed @@ -174,8 +202,12 @@ pub(crate) async fn upgrade_project_dependencies( } else { writeln!( printer.stderr(), - "{info} No upgrades found for {found} dependencies in {subpath}, check manually if not committed yet{}", - skipped.format(" (skipped ", &format!(" of {count_skipped} upgrades)")) + "{info} No upgrades found for {} in {subpath}, check manually if not committed yet{}", + plural(found, "dependency"), + skipped.format( + " (skipped ", + &format!(" of {})", plural(count_skipped, "upgrade")) + ) )?; } continue; @@ -220,30 +252,41 @@ pub(crate) async fn upgrade_project_dependencies( ); }); table.printstd(); - if !args.dry_run { + if only_packages { + writeln!( + printer.stderr(), + "{info} Upgraded {bumped} of {} 🚀{}", + plural(found, "package"), + skipped.format( + " (skipped ", + &format!(" of {})", plural(count_skipped, "upgrade")) + ) + )?; + } else if !args.dry_run { if let Err(err) = fs_err::tokio::write(pyproject_toml, toml.to_string()).await { return Err(err.into()); } writeln!( printer.stderr(), "{info} Upgraded {bumped}/{found} in {subpath} 🚀 Check manually, update {uv_sync} and run tests{}", - skipped.format(" (skipped ", &format!(" of {count_skipped} upgrades)")) + skipped.format( + " (skipped ", + &format!(" of {})", plural(count_skipped, "upgrade")) + ) )?; } else if !skipped.is_empty() { writeln!( printer.stderr(), - "{info} Skipped {skipped} ({count_skipped} upgrades), upgraded {bumped} of {found} dependencies in {subpath}" + "{info} Skipped {skipped} ({}), upgraded {bumped} of {} in {subpath}", + plural(count_skipped, "upgrade"), + plural(found, "dependency"), )?; } if !item_written { item_written = true; } } - let files = format!( - "{} file{}", - tomls.len(), - if tomls.len() == 1 { "" } else { "s" } - ); + let files = plural(tomls.len(), "file"); if args.recursive && files_bumped != 1 { if tomls.is_empty() { warn_user!("No pyproject.toml files found recursively"); @@ -257,26 +300,35 @@ pub(crate) async fn upgrade_project_dependencies( } else if !all_skipped.is_empty() { writeln!( printer.stderr(), - "{info} Skipped {all_skipped} ({all_count_skipped} upgrades), {all_found} dependencies in {files} not upgraded for --allow={}", + "{info} Skipped {all_skipped} ({}), {} in {files} not upgraded for --allow={}", + plural(all_count_skipped, "upgrade"), + plural(all_found, "dependency"), format_allow(&allow) )?; } else { writeln!( printer.stderr(), - "{info} No upgrades in {all_found} dependencies and {files} found, check manually if not committed yet" + "{info} No upgrades in {} and {files} found, check manually if not committed yet", + plural(all_found, "dependency"), )?; } } else if !all_skipped.is_empty() { writeln!( printer.stderr(), - "{info} Total: Skipped {all_skipped} ({all_count_skipped} upgrades), upgraded {all_bumped} of {all_found} dependencies for --allow={}", + "{info} Total: Skipped {all_skipped} ({}), upgraded {all_bumped} of {} for --allow={}", + plural(all_count_skipped, "upgrade"), + plural(all_found, "dependency"), format_allow(&allow) )?; } else { writeln!( printer.stderr(), - "{info} Upgraded {all_bumped}/{all_found} dependencies in {files} 🚀 Check manually, update {uv_sync} and run tests{}", - all_skipped.format(" (skipped ", &format!(" of {all_count_skipped} upgrades)")) + "{info} Total: Upgraded {all_bumped}/{} in {files} 🚀 Check manually, update {uv_sync} and run tests{}", + plural(all_found, "dependency"), + all_skipped.format( + " (skipped ", + &format!(" of {})", plural(all_count_skipped, "upgrade")) + ) )?; } } @@ -284,6 +336,14 @@ pub(crate) async fn upgrade_project_dependencies( Ok(ExitStatus::Success) } +fn plural(count: usize, word: &str) -> String { + if count != 1 && word.ends_with('y') { + format!("{count} {}ies", &word[..word.len() - 1]) + } else { + format!("{count} {word}{}", if count == 1 { "" } else { "s" }) + } +} + fn get_requires_python(toml: &PyProjectTomlMut) -> Option { toml.get_requires_python() .map(RequiresPython::from_str) @@ -309,24 +369,29 @@ fn format_allow(allow: &[usize]) -> String { .join(",") } -async fn read_pyproject_toml( - pyproject_toml: &Path, -) -> Result> { +async fn read_pyproject_toml(pyproject_toml: &Path) -> Result { let content = match fs_err::tokio::read_to_string(pyproject_toml.to_path_buf()).await { Ok(content) => content, Err(err) => { if err.kind() == ErrorKind::NotFound { - warn_user!("No pyproject.toml found in current directory"); - return Err(Ok(ExitStatus::Error)); + warn_user!( + "Could not find {}", + pyproject_toml.to_str().expect("path not UTF-8") + ); + } else { + warn_user!( + "Could not read {}", + pyproject_toml.to_str().expect("path not UTF-8") + ); } - return Err(Err(err.into())); + return Err(anyhow::Error::from(err)); } }; let toml = match PyProjectTomlMut::from_toml(&content, DependencyTarget::PyProjectToml) { Ok(toml) => toml, Err(err) => { - warn_user!("Couldn't read pyproject.toml: {}", err); - return Err(Ok(ExitStatus::Error)); + warn_user!("Could not parse pyproject.toml: {}", err); + return Err(anyhow::Error::from(err)); } }; Ok(toml) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 8099362bd..a5ed89aa6 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -942,9 +942,14 @@ Upgrade the project's dependency constraints

Usage

``` -uv upgrade [OPTIONS] +uv upgrade [OPTIONS] [REQUIREMENTS]... ``` +

Arguments

+ +
REQUIREMENTS

Upgrade only the given requirements (i.e. uv<0.5) instead of pyproject.toml files

+
+

Options

--allow allow

Allow only some version digits to change, others will be skipped: 1,2,3,4 (major, minor, patch, build number)