diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index c93708f5c..d3694c530 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -1,11 +1,14 @@ use std::ffi::OsString; +use std::fmt::{self, Display, Formatter}; use std::ops::{Deref, DerefMut}; use std::path::PathBuf; use std::str::FromStr; use anyhow::{Result, anyhow}; -use clap::builder::Styles; +use clap::ValueEnum; use clap::builder::styling::{AnsiColor, Effects, Style}; +use clap::builder::{PossibleValue, Styles, TypedValueParser, ValueParserFactory}; +use clap::error::ErrorKind; use clap::{Args, Parser, Subcommand}; use uv_auth::Service; @@ -587,8 +590,8 @@ pub struct VersionArgs { /// Update the project version using the given semantics /// /// This flag can be passed multiple times. - #[arg(group = "operation", long)] - pub bump: Vec, + #[arg(group = "operation", long, value_name = "BUMP[=VALUE]")] + pub bump: Vec, /// Don't write a new version to the `pyproject.toml` /// @@ -698,8 +701,8 @@ pub enum VersionBump { Dev, } -impl std::fmt::Display for VersionBump { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl Display for VersionBump { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { let string = match self { Self::Major => "major", Self::Minor => "minor", @@ -715,6 +718,110 @@ impl std::fmt::Display for VersionBump { } } +impl FromStr for VersionBump { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "major" => Ok(Self::Major), + "minor" => Ok(Self::Minor), + "patch" => Ok(Self::Patch), + "stable" => Ok(Self::Stable), + "alpha" => Ok(Self::Alpha), + "beta" => Ok(Self::Beta), + "rc" => Ok(Self::Rc), + "post" => Ok(Self::Post), + "dev" => Ok(Self::Dev), + _ => Err(format!("invalid bump component `{value}`")), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct VersionBumpSpec { + pub bump: VersionBump, + pub value: Option, +} + +impl Display for VersionBumpSpec { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self.value { + Some(value) => write!(f, "{}={value}", self.bump), + None => self.bump.fmt(f), + } + } +} + +impl FromStr for VersionBumpSpec { + type Err = String; + + fn from_str(input: &str) -> Result { + let (name, value) = match input.split_once('=') { + Some((name, value)) => (name, Some(value)), + None => (input, None), + }; + + let bump = name.parse::()?; + + if bump == VersionBump::Stable && value.is_some() { + return Err("`--bump stable` does not accept a value".to_string()); + } + + let value = match value { + Some("") => { + return Err("`--bump` values cannot be empty".to_string()); + } + Some(raw) => Some( + raw.parse::() + .map_err(|_| format!("invalid numeric value `{raw}` for `--bump {name}`"))?, + ), + None => None, + }; + + Ok(Self { bump, value }) + } +} + +impl ValueParserFactory for VersionBumpSpec { + type Parser = VersionBumpSpecValueParser; + + fn value_parser() -> Self::Parser { + VersionBumpSpecValueParser + } +} + +#[derive(Clone, Debug)] +pub struct VersionBumpSpecValueParser; + +impl TypedValueParser for VersionBumpSpecValueParser { + type Value = VersionBumpSpec; + + fn parse_ref( + &self, + _cmd: &clap::Command, + _arg: Option<&clap::Arg>, + value: &std::ffi::OsStr, + ) -> Result { + let raw = value.to_str().ok_or_else(|| { + clap::Error::raw( + ErrorKind::InvalidUtf8, + "`--bump` values must be valid UTF-8", + ) + })?; + + VersionBumpSpec::from_str(raw) + .map_err(|message| clap::Error::raw(ErrorKind::InvalidValue, message)) + } + + fn possible_values(&self) -> Option + '_>> { + Some(Box::new( + VersionBump::value_variants() + .iter() + .filter_map(ValueEnum::to_possible_value), + )) + } +} + #[derive(Args)] pub struct SelfNamespace { #[command(subcommand)] diff --git a/crates/uv-pep440/src/version.rs b/crates/uv-pep440/src/version.rs index d518675c7..5c27806e8 100644 --- a/crates/uv-pep440/src/version.rs +++ b/crates/uv-pep440/src/version.rs @@ -676,7 +676,7 @@ impl Version { let full = self.make_full(); match bump { - BumpCommand::BumpRelease { index } => { + BumpCommand::BumpRelease { index, value } => { // Clear all sub-release items full.pre = None; full.post = None; @@ -690,7 +690,9 @@ impl Version { // Everything before the bumped value is preserved (or is an implicit 0) Ordering::Less => old_parts.get(i).copied().unwrap_or(0), // This is the value to bump (could be implicit 0) - Ordering::Equal => old_parts.get(i).copied().unwrap_or(0) + 1, + Ordering::Equal => { + value.unwrap_or_else(|| old_parts.get(i).copied().unwrap_or(0) + 1) + } // Everything after the bumped value becomes 0 Ordering::Greater => 0, }) @@ -703,37 +705,50 @@ impl Version { full.post = None; full.dev = None; } - BumpCommand::BumpPrerelease { kind } => { + BumpCommand::BumpPrerelease { kind, value } => { // Clear all sub-prerelease items full.post = None; full.dev = None; - - // Either bump the matching kind or set to 1 - if let Some(prerelease) = &mut full.pre { - if prerelease.kind == kind { - prerelease.number += 1; - return; + if let Some(value) = value { + full.pre = Some(Prerelease { + kind, + number: value, + }); + } else { + // Either bump the matching kind or set to 1 + if let Some(prerelease) = &mut full.pre { + if prerelease.kind == kind { + prerelease.number += 1; + return; + } } + full.pre = Some(Prerelease { kind, number: 1 }); } - full.pre = Some(Prerelease { kind, number: 1 }); } - BumpCommand::BumpPost => { + BumpCommand::BumpPost { value } => { // Clear sub-post items full.dev = None; - - // Either bump or set to 1 - if let Some(post) = &mut full.post { - *post += 1; + if let Some(value) = value { + full.post = Some(value); } else { - full.post = Some(1); + // Either bump or set to 1 + if let Some(post) = &mut full.post { + *post += 1; + } else { + full.post = Some(1); + } } } - BumpCommand::BumpDev => { - // Either bump or set to 1 - if let Some(dev) = &mut full.dev { - *dev += 1; + BumpCommand::BumpDev { value } => { + if let Some(value) = value { + full.dev = Some(value); } else { - full.dev = Some(1); + // Either bump or set to 1 + if let Some(dev) = &mut full.dev { + *dev += 1; + } else { + full.dev = Some(1); + } } } } @@ -1018,22 +1033,32 @@ impl FromStr for Version { /// Various ways to "bump" a version #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum BumpCommand { - /// Bump the release component + /// Bump or set the release component BumpRelease { /// The release component to bump (0 is major, 1 is minor, 2 is patch) index: usize, + /// Explicit value to set; when absent the component is incremented + value: Option, }, - /// Bump the prerelease component + /// Bump or set the prerelease component BumpPrerelease { /// prerelease component to bump kind: PrereleaseKind, + /// Explicit value to set; when absent the component is incremented + value: Option, }, /// Bump to the associated stable release MakeStable, - /// Bump the post component - BumpPost, - /// Bump the dev component - BumpDev, + /// Bump or set the post component + BumpPost { + /// Explicit value to set; when absent the component is incremented + value: Option, + }, + /// Bump or set the dev component + BumpDev { + /// Explicit value to set; when absent the component is incremented + value: Option, + }, } /// A small representation of a version. @@ -4239,36 +4264,57 @@ mod tests { fn bump_major() { // one digit let mut version = "0".parse::().unwrap(); - version.bump(BumpCommand::BumpRelease { index: 0 }); + version.bump(BumpCommand::BumpRelease { + index: 0, + value: None, + }); assert_eq!(version.to_string().as_str(), "1"); // two digit let mut version = "1.5".parse::().unwrap(); - version.bump(BumpCommand::BumpRelease { index: 0 }); + version.bump(BumpCommand::BumpRelease { + index: 0, + value: None, + }); assert_eq!(version.to_string().as_str(), "2.0"); // three digit (zero major) let mut version = "0.1.2".parse::().unwrap(); - version.bump(BumpCommand::BumpRelease { index: 0 }); + version.bump(BumpCommand::BumpRelease { + index: 0, + value: None, + }); assert_eq!(version.to_string().as_str(), "1.0.0"); // three digit (non-zero major) let mut version = "1.2.3".parse::().unwrap(); - version.bump(BumpCommand::BumpRelease { index: 0 }); + version.bump(BumpCommand::BumpRelease { + index: 0, + value: None, + }); assert_eq!(version.to_string().as_str(), "2.0.0"); // four digit let mut version = "1.2.3.4".parse::().unwrap(); - version.bump(BumpCommand::BumpRelease { index: 0 }); + version.bump(BumpCommand::BumpRelease { + index: 0, + value: None, + }); assert_eq!(version.to_string().as_str(), "2.0.0.0"); // All the version junk let mut version = "5!1.7.3.5b2.post345.dev456+local" .parse::() .unwrap(); - version.bump(BumpCommand::BumpRelease { index: 0 }); + version.bump(BumpCommand::BumpRelease { + index: 0, + value: None, + }); assert_eq!(version.to_string().as_str(), "5!2.0.0.0+local"); - version.bump(BumpCommand::BumpRelease { index: 0 }); + version.bump(BumpCommand::BumpRelease { + index: 0, + value: None, + }); assert_eq!(version.to_string().as_str(), "5!3.0.0.0+local"); } @@ -4278,31 +4324,49 @@ mod tests { fn bump_minor() { // one digit let mut version = "0".parse::().unwrap(); - version.bump(BumpCommand::BumpRelease { index: 1 }); + version.bump(BumpCommand::BumpRelease { + index: 1, + value: None, + }); assert_eq!(version.to_string().as_str(), "0.1"); // two digit let mut version = "1.5".parse::().unwrap(); - version.bump(BumpCommand::BumpRelease { index: 1 }); + version.bump(BumpCommand::BumpRelease { + index: 1, + value: None, + }); assert_eq!(version.to_string().as_str(), "1.6"); // three digit (non-zero major) let mut version = "5.3.6".parse::().unwrap(); - version.bump(BumpCommand::BumpRelease { index: 1 }); + version.bump(BumpCommand::BumpRelease { + index: 1, + value: None, + }); assert_eq!(version.to_string().as_str(), "5.4.0"); // four digit let mut version = "1.2.3.4".parse::().unwrap(); - version.bump(BumpCommand::BumpRelease { index: 1 }); + version.bump(BumpCommand::BumpRelease { + index: 1, + value: None, + }); assert_eq!(version.to_string().as_str(), "1.3.0.0"); // All the version junk let mut version = "5!1.7.3.5b2.post345.dev456+local" .parse::() .unwrap(); - version.bump(BumpCommand::BumpRelease { index: 1 }); + version.bump(BumpCommand::BumpRelease { + index: 1, + value: None, + }); assert_eq!(version.to_string().as_str(), "5!1.8.0.0+local"); - version.bump(BumpCommand::BumpRelease { index: 1 }); + version.bump(BumpCommand::BumpRelease { + index: 1, + value: None, + }); assert_eq!(version.to_string().as_str(), "5!1.9.0.0+local"); } @@ -4312,31 +4376,49 @@ mod tests { fn bump_patch() { // one digit let mut version = "0".parse::().unwrap(); - version.bump(BumpCommand::BumpRelease { index: 2 }); + version.bump(BumpCommand::BumpRelease { + index: 2, + value: None, + }); assert_eq!(version.to_string().as_str(), "0.0.1"); // two digit let mut version = "1.5".parse::().unwrap(); - version.bump(BumpCommand::BumpRelease { index: 2 }); + version.bump(BumpCommand::BumpRelease { + index: 2, + value: None, + }); assert_eq!(version.to_string().as_str(), "1.5.1"); // three digit let mut version = "5.3.6".parse::().unwrap(); - version.bump(BumpCommand::BumpRelease { index: 2 }); + version.bump(BumpCommand::BumpRelease { + index: 2, + value: None, + }); assert_eq!(version.to_string().as_str(), "5.3.7"); // four digit let mut version = "1.2.3.4".parse::().unwrap(); - version.bump(BumpCommand::BumpRelease { index: 2 }); + version.bump(BumpCommand::BumpRelease { + index: 2, + value: None, + }); assert_eq!(version.to_string().as_str(), "1.2.4.0"); // All the version junk let mut version = "5!1.7.3.5b2.post345.dev456+local" .parse::() .unwrap(); - version.bump(BumpCommand::BumpRelease { index: 2 }); + version.bump(BumpCommand::BumpRelease { + index: 2, + value: None, + }); assert_eq!(version.to_string().as_str(), "5!1.7.4.0+local"); - version.bump(BumpCommand::BumpRelease { index: 2 }); + version.bump(BumpCommand::BumpRelease { + index: 2, + value: None, + }); assert_eq!(version.to_string().as_str(), "5!1.7.5.0+local"); } @@ -4348,6 +4430,7 @@ mod tests { let mut version = "0".parse::().unwrap(); version.bump(BumpCommand::BumpPrerelease { kind: PrereleaseKind::Alpha, + value: None, }); assert_eq!(version.to_string().as_str(), "0a1"); @@ -4355,6 +4438,7 @@ mod tests { let mut version = "1.5".parse::().unwrap(); version.bump(BumpCommand::BumpPrerelease { kind: PrereleaseKind::Alpha, + value: None, }); assert_eq!(version.to_string().as_str(), "1.5a1"); @@ -4362,6 +4446,7 @@ mod tests { let mut version = "5.3.6".parse::().unwrap(); version.bump(BumpCommand::BumpPrerelease { kind: PrereleaseKind::Alpha, + value: None, }); assert_eq!(version.to_string().as_str(), "5.3.6a1"); @@ -4369,6 +4454,7 @@ mod tests { let mut version = "1.2.3.4".parse::().unwrap(); version.bump(BumpCommand::BumpPrerelease { kind: PrereleaseKind::Alpha, + value: None, }); assert_eq!(version.to_string().as_str(), "1.2.3.4a1"); @@ -4378,10 +4464,12 @@ mod tests { .unwrap(); version.bump(BumpCommand::BumpPrerelease { kind: PrereleaseKind::Alpha, + value: None, }); assert_eq!(version.to_string().as_str(), "5!1.7.3.5a1+local"); version.bump(BumpCommand::BumpPrerelease { kind: PrereleaseKind::Alpha, + value: None, }); assert_eq!(version.to_string().as_str(), "5!1.7.3.5a2+local"); } @@ -4394,6 +4482,7 @@ mod tests { let mut version = "0".parse::().unwrap(); version.bump(BumpCommand::BumpPrerelease { kind: PrereleaseKind::Beta, + value: None, }); assert_eq!(version.to_string().as_str(), "0b1"); @@ -4401,6 +4490,7 @@ mod tests { let mut version = "1.5".parse::().unwrap(); version.bump(BumpCommand::BumpPrerelease { kind: PrereleaseKind::Beta, + value: None, }); assert_eq!(version.to_string().as_str(), "1.5b1"); @@ -4408,6 +4498,7 @@ mod tests { let mut version = "5.3.6".parse::().unwrap(); version.bump(BumpCommand::BumpPrerelease { kind: PrereleaseKind::Beta, + value: None, }); assert_eq!(version.to_string().as_str(), "5.3.6b1"); @@ -4415,6 +4506,7 @@ mod tests { let mut version = "1.2.3.4".parse::().unwrap(); version.bump(BumpCommand::BumpPrerelease { kind: PrereleaseKind::Beta, + value: None, }); assert_eq!(version.to_string().as_str(), "1.2.3.4b1"); @@ -4424,10 +4516,12 @@ mod tests { .unwrap(); version.bump(BumpCommand::BumpPrerelease { kind: PrereleaseKind::Beta, + value: None, }); assert_eq!(version.to_string().as_str(), "5!1.7.3.5b1+local"); version.bump(BumpCommand::BumpPrerelease { kind: PrereleaseKind::Beta, + value: None, }); assert_eq!(version.to_string().as_str(), "5!1.7.3.5b2+local"); } @@ -4440,6 +4534,7 @@ mod tests { let mut version = "0".parse::().unwrap(); version.bump(BumpCommand::BumpPrerelease { kind: PrereleaseKind::Rc, + value: None, }); assert_eq!(version.to_string().as_str(), "0rc1"); @@ -4447,6 +4542,7 @@ mod tests { let mut version = "1.5".parse::().unwrap(); version.bump(BumpCommand::BumpPrerelease { kind: PrereleaseKind::Rc, + value: None, }); assert_eq!(version.to_string().as_str(), "1.5rc1"); @@ -4454,6 +4550,7 @@ mod tests { let mut version = "5.3.6".parse::().unwrap(); version.bump(BumpCommand::BumpPrerelease { kind: PrereleaseKind::Rc, + value: None, }); assert_eq!(version.to_string().as_str(), "5.3.6rc1"); @@ -4461,6 +4558,7 @@ mod tests { let mut version = "1.2.3.4".parse::().unwrap(); version.bump(BumpCommand::BumpPrerelease { kind: PrereleaseKind::Rc, + value: None, }); assert_eq!(version.to_string().as_str(), "1.2.3.4rc1"); @@ -4470,10 +4568,12 @@ mod tests { .unwrap(); version.bump(BumpCommand::BumpPrerelease { kind: PrereleaseKind::Rc, + value: None, }); assert_eq!(version.to_string().as_str(), "5!1.7.3.5rc1+local"); version.bump(BumpCommand::BumpPrerelease { kind: PrereleaseKind::Rc, + value: None, }); assert_eq!(version.to_string().as_str(), "5!1.7.3.5rc2+local"); } @@ -4484,29 +4584,29 @@ mod tests { fn bump_post() { // one digit let mut version = "0".parse::().unwrap(); - version.bump(BumpCommand::BumpPost); + version.bump(BumpCommand::BumpPost { value: None }); assert_eq!(version.to_string().as_str(), "0.post1"); // two digit let mut version = "1.5".parse::().unwrap(); - version.bump(BumpCommand::BumpPost); + version.bump(BumpCommand::BumpPost { value: None }); assert_eq!(version.to_string().as_str(), "1.5.post1"); // three digit let mut version = "5.3.6".parse::().unwrap(); - version.bump(BumpCommand::BumpPost); + version.bump(BumpCommand::BumpPost { value: None }); assert_eq!(version.to_string().as_str(), "5.3.6.post1"); // four digit let mut version = "1.2.3.4".parse::().unwrap(); - version.bump(BumpCommand::BumpPost); + version.bump(BumpCommand::BumpPost { value: None }); assert_eq!(version.to_string().as_str(), "1.2.3.4.post1"); // All the version junk let mut version = "5!1.7.3.5b2.dev123+local".parse::().unwrap(); - version.bump(BumpCommand::BumpPost); + version.bump(BumpCommand::BumpPost { value: None }); assert_eq!(version.to_string().as_str(), "5!1.7.3.5b2.post1+local"); - version.bump(BumpCommand::BumpPost); + version.bump(BumpCommand::BumpPost { value: None }); assert_eq!(version.to_string().as_str(), "5!1.7.3.5b2.post2+local"); } @@ -4516,32 +4616,32 @@ mod tests { fn bump_dev() { // one digit let mut version = "0".parse::().unwrap(); - version.bump(BumpCommand::BumpDev); + version.bump(BumpCommand::BumpDev { value: None }); assert_eq!(version.to_string().as_str(), "0.dev1"); // two digit let mut version = "1.5".parse::().unwrap(); - version.bump(BumpCommand::BumpDev); + version.bump(BumpCommand::BumpDev { value: None }); assert_eq!(version.to_string().as_str(), "1.5.dev1"); // three digit let mut version = "5.3.6".parse::().unwrap(); - version.bump(BumpCommand::BumpDev); + version.bump(BumpCommand::BumpDev { value: None }); assert_eq!(version.to_string().as_str(), "5.3.6.dev1"); // four digit let mut version = "1.2.3.4".parse::().unwrap(); - version.bump(BumpCommand::BumpDev); + version.bump(BumpCommand::BumpDev { value: None }); assert_eq!(version.to_string().as_str(), "1.2.3.4.dev1"); // All the version junk let mut version = "5!1.7.3.5b2.post345+local".parse::().unwrap(); - version.bump(BumpCommand::BumpDev); + version.bump(BumpCommand::BumpDev { value: None }); assert_eq!( version.to_string().as_str(), "5!1.7.3.5b2.post345.dev1+local" ); - version.bump(BumpCommand::BumpDev); + version.bump(BumpCommand::BumpDev { value: None }); assert_eq!( version.to_string().as_str(), "5!1.7.3.5b2.post345.dev2+local" diff --git a/crates/uv/src/commands/project/version.rs b/crates/uv/src/commands/project/version.rs index 0f5441f2a..fd81992a2 100644 --- a/crates/uv/src/commands/project/version.rs +++ b/crates/uv/src/commands/project/version.rs @@ -8,7 +8,7 @@ use owo_colors::OwoColorize; use tracing::debug; use uv_cache::Cache; use uv_cli::version::VersionInfo; -use uv_cli::{VersionBump, VersionFormat}; +use uv_cli::{VersionBump, VersionBumpSpec, VersionFormat}; use uv_client::BaseClientBuilder; use uv_configuration::{ Concurrency, DependencyGroups, DependencyGroupsWithDefaults, DryRun, ExtrasSpecification, @@ -56,7 +56,7 @@ pub(crate) fn self_version( #[allow(clippy::fn_params_excessive_bools)] pub(crate) async fn project_version( value: Option, - mut bump: Vec, + mut bump: Vec, short: bool, output_format: VersionFormat, project_dir: &Path, @@ -164,29 +164,29 @@ pub(crate) async fn project_version( // because that makes perfect sense and is reasonable to do. let release_components: Vec<_> = bump .iter() - .filter(|bump| { + .filter(|spec| { matches!( - bump, + spec.bump, VersionBump::Major | VersionBump::Minor | VersionBump::Patch ) }) .collect(); let prerelease_components: Vec<_> = bump .iter() - .filter(|bump| { + .filter(|spec| { matches!( - bump, + spec.bump, VersionBump::Alpha | VersionBump::Beta | VersionBump::Rc | VersionBump::Dev ) }) .collect(); let post_count = bump .iter() - .filter(|bump| *bump == &VersionBump::Post) + .filter(|spec| spec.bump == VersionBump::Post) .count(); let stable_count = bump .iter() - .filter(|bump| *bump == &VersionBump::Stable) + .filter(|spec| spec.bump == VersionBump::Stable) .count(); // Very little reason to do "bump to stable" and then do other things, @@ -252,25 +252,37 @@ pub(crate) async fn project_version( // Apply all the bumps let mut new_version = old_version.clone(); - for bump in &bump { - let command = match *bump { - VersionBump::Major => BumpCommand::BumpRelease { index: 0 }, - VersionBump::Minor => BumpCommand::BumpRelease { index: 1 }, - VersionBump::Patch => BumpCommand::BumpRelease { index: 2 }, - VersionBump::Alpha => BumpCommand::BumpPrerelease { + + for spec in &bump { + match spec.bump { + VersionBump::Major => new_version.bump(BumpCommand::BumpRelease { + index: 0, + value: spec.value, + }), + VersionBump::Minor => new_version.bump(BumpCommand::BumpRelease { + index: 1, + value: spec.value, + }), + VersionBump::Patch => new_version.bump(BumpCommand::BumpRelease { + index: 2, + value: spec.value, + }), + VersionBump::Stable => new_version.bump(BumpCommand::MakeStable), + VersionBump::Alpha => new_version.bump(BumpCommand::BumpPrerelease { kind: PrereleaseKind::Alpha, - }, - VersionBump::Beta => BumpCommand::BumpPrerelease { + value: spec.value, + }), + VersionBump::Beta => new_version.bump(BumpCommand::BumpPrerelease { kind: PrereleaseKind::Beta, - }, - VersionBump::Rc => BumpCommand::BumpPrerelease { + value: spec.value, + }), + VersionBump::Rc => new_version.bump(BumpCommand::BumpPrerelease { kind: PrereleaseKind::Rc, - }, - VersionBump::Post => BumpCommand::BumpPost, - VersionBump::Dev => BumpCommand::BumpDev, - VersionBump::Stable => BumpCommand::MakeStable, - }; - new_version.bump(command); + value: spec.value, + }), + VersionBump::Post => new_version.bump(BumpCommand::BumpPost { value: spec.value }), + VersionBump::Dev => new_version.bump(BumpCommand::BumpDev { value: spec.value }), + } } if new_version <= old_version { diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 548c5c238..461e60be7 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -15,7 +15,7 @@ use uv_cli::{ PythonFindArgs, PythonInstallArgs, PythonListArgs, PythonListFormat, PythonPinArgs, PythonUninstallArgs, PythonUpgradeArgs, RemoveArgs, RunArgs, SyncArgs, SyncFormat, ToolDirArgs, ToolInstallArgs, ToolListArgs, ToolRunArgs, ToolUninstallArgs, TreeArgs, VenvArgs, VersionArgs, - VersionBump, VersionFormat, + VersionBumpSpec, VersionFormat, }; use uv_cli::{ AuthorFrom, BuildArgs, ExportArgs, FormatArgs, PublishArgs, PythonDirArgs, @@ -1792,7 +1792,7 @@ impl RemoveSettings { #[derive(Debug, Clone)] pub(crate) struct VersionSettings { pub(crate) value: Option, - pub(crate) bump: Vec, + pub(crate) bump: Vec, pub(crate) short: bool, pub(crate) output_format: VersionFormat, pub(crate) dry_run: bool, diff --git a/crates/uv/tests/it/version.rs b/crates/uv/tests/it/version.rs index b25f5bbce..a7fe58f9a 100644 --- a/crates/uv/tests/it/version.rs +++ b/crates/uv/tests/it/version.rs @@ -248,6 +248,123 @@ requires-python = ">=3.12" Ok(()) } +#[test] +fn version_bump_patch_value() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" +[project] +name = "myproject" +version = "1.10.31" +requires-python = ">=3.12" +"#, + )?; + + uv_snapshot!(context.filters(), context.version() + .arg("--bump").arg("patch=40"), @r" + success: true + exit_code: 0 + ----- stdout ----- + myproject 1.10.31 => 1.10.40 + + ----- stderr ----- + Resolved 1 package in [TIME] + Audited in [TIME] + "); + + let pyproject = fs_err::read_to_string(&pyproject_toml)?; + assert_snapshot!( + pyproject, + @r#" + [project] + name = "myproject" + version = "1.10.40" + requires-python = ">=3.12" + "# + ); + Ok(()) +} + +#[test] +fn version_bump_minor_value() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" +[project] +name = "myproject" +version = "1.2.3" +requires-python = ">=3.12" +"#, + )?; + + uv_snapshot!(context.filters(), context.version() + .arg("--bump").arg("minor=10"), @r" + success: true + exit_code: 0 + ----- stdout ----- + myproject 1.2.3 => 1.10.0 + + ----- stderr ----- + Resolved 1 package in [TIME] + Audited in [TIME] + "); + + let pyproject = fs_err::read_to_string(&pyproject_toml)?; + assert_snapshot!( + pyproject, + @r#" + [project] + name = "myproject" + version = "1.10.0" + requires-python = ">=3.12" + "# + ); + Ok(()) +} + +#[test] +fn version_bump_major_value() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" +[project] +name = "myproject" +version = "2.3.4" +requires-python = ">=3.12" +"#, + )?; + + uv_snapshot!(context.filters(), context.version() + .arg("--bump").arg("major=7"), @r" + success: true + exit_code: 0 + ----- stdout ----- + myproject 2.3.4 => 7.0.0 + + ----- stderr ----- + Resolved 1 package in [TIME] + Audited in [TIME] + "); + + let pyproject = fs_err::read_to_string(&pyproject_toml)?; + assert_snapshot!( + pyproject, + @r#" + [project] + name = "myproject" + version = "7.0.0" + requires-python = ">=3.12" + "# + ); + Ok(()) +} + // Bump patch version (--short) #[test] fn version_bump_patch_short() -> Result<()> { @@ -289,6 +406,43 @@ requires-python = ">=3.12" Ok(()) } +#[test] +fn version_bump_patch_value_must_increase() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" +[project] +name = "myproject" +version = "0.0.12" +requires-python = ">=3.12" +"#, + )?; + + uv_snapshot!(context.filters(), context.version() + .arg("--bump").arg("patch=11"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: 0.0.12 => 0.0.11 didn't increase the version; provide the exact version to force an update + "); + + let pyproject = fs_err::read_to_string(&pyproject_toml)?; + assert_snapshot!( + pyproject, + @r#" + [project] + name = "myproject" + version = "0.0.12" + requires-python = ">=3.12" + "# + ); + Ok(()) +} + /// Preserve comments immediately preceding the version when bumping #[test] fn version_bump_preserves_preceding_comments() -> Result<()> { @@ -781,6 +935,85 @@ requires-python = ">=3.12" Ok(()) } +#[test] +fn bump_beta_with_value_existing() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" +[project] +name = "myproject" +version = "1.2.3b4" +requires-python = ">=3.12" +"#, + )?; + + uv_snapshot!(context.filters(), context.version() + .arg("--bump").arg("beta=42"), @r" + success: true + exit_code: 0 + ----- stdout ----- + myproject 1.2.3b4 => 1.2.3b42 + + ----- stderr ----- + Resolved 1 package in [TIME] + Audited in [TIME] + "); + + let pyproject = fs_err::read_to_string(&pyproject_toml)?; + assert_snapshot!( + pyproject, + @r#" + [project] + name = "myproject" + version = "1.2.3b42" + requires-python = ">=3.12" + "# + ); + Ok(()) +} + +#[test] +fn bump_beta_with_value_new() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" +[project] +name = "myproject" +version = "1.2.3" +requires-python = ">=3.12" +"#, + )?; + + uv_snapshot!(context.filters(), context.version() + .arg("--bump").arg("beta=5") + .arg("--bump").arg("patch"), @r" + success: true + exit_code: 0 + ----- stdout ----- + myproject 1.2.3 => 1.2.4b5 + + ----- stderr ----- + Resolved 1 package in [TIME] + Audited in [TIME] + "); + + let pyproject = fs_err::read_to_string(&pyproject_toml)?; + assert_snapshot!( + pyproject, + @r#" + [project] + name = "myproject" + version = "1.2.4b5" + requires-python = ">=3.12" + "# + ); + Ok(()) +} + // --bump rc #[test] fn bump_rc() -> Result<()> { @@ -861,6 +1094,45 @@ requires-python = ">=3.12" Ok(()) } +#[test] +fn bump_post_with_value_clears_dev() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" +[project] +name = "myproject" +version = "1.2.3.post4.dev9" +requires-python = ">=3.12" +"#, + )?; + + uv_snapshot!(context.filters(), context.version() + .arg("--bump").arg("post=10"), @r" + success: true + exit_code: 0 + ----- stdout ----- + myproject 1.2.3.post4.dev9 => 1.2.3.post10 + + ----- stderr ----- + Resolved 1 package in [TIME] + Audited in [TIME] + "); + + let pyproject = fs_err::read_to_string(&pyproject_toml)?; + assert_snapshot!( + pyproject, + @r#" + [project] + name = "myproject" + version = "1.2.3.post10" + requires-python = ">=3.12" + "# + ); + Ok(()) +} + // --bump dev #[test] fn bump_dev() -> Result<()> { @@ -901,6 +1173,125 @@ requires-python = ">=3.12" Ok(()) } +#[test] +fn bump_dev_with_value() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" +[project] +name = "myproject" +version = "0.1.0.dev4" +requires-python = ">=3.12" +"#, + )?; + + uv_snapshot!(context.filters(), context.version() + .arg("--bump").arg("dev=42"), @r" + success: true + exit_code: 0 + ----- stdout ----- + myproject 0.1.0.dev4 => 0.1.0.dev42 + + ----- stderr ----- + Resolved 1 package in [TIME] + Audited in [TIME] + "); + + let pyproject = fs_err::read_to_string(&pyproject_toml)?; + assert_snapshot!( + pyproject, + @r#" + [project] + name = "myproject" + version = "0.1.0.dev42" + requires-python = ">=3.12" + "# + ); + Ok(()) +} + +#[test] +fn bump_patch_and_dev_value() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" +[project] +name = "myproject" +version = "0.0.1" +requires-python = ">=3.12" +"#, + )?; + + uv_snapshot!(context.filters(), context.version() + .arg("--bump").arg("patch") + .arg("--bump").arg("dev=66463664"), @r" + success: true + exit_code: 0 + ----- stdout ----- + myproject 0.0.1 => 0.0.2.dev66463664 + + ----- stderr ----- + Resolved 1 package in [TIME] + Audited in [TIME] + "); + + let pyproject = fs_err::read_to_string(&pyproject_toml)?; + assert_snapshot!( + pyproject, + @r#" + [project] + name = "myproject" + version = "0.0.2.dev66463664" + requires-python = ">=3.12" + "# + ); + Ok(()) +} + +#[test] +fn bump_patch_and_dev_explicit_values_sorted() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" +[project] +name = "myproject" +version = "0.1.2.dev3" +requires-python = ">=3.12" +"#, + )?; + + uv_snapshot!(context.filters(), context.version() + .arg("--bump").arg("dev=0") + .arg("--bump").arg("patch=10"), @r" + success: true + exit_code: 0 + ----- stdout ----- + myproject 0.1.2.dev3 => 0.1.10.dev0 + + ----- stderr ----- + Resolved 1 package in [TIME] + Audited in [TIME] + "); + + let pyproject = fs_err::read_to_string(&pyproject_toml)?; + assert_snapshot!( + pyproject, + @r#" + [project] + name = "myproject" + version = "0.1.10.dev0" + requires-python = ">=3.12" + "# + ); + Ok(()) +} + // Bump major but the input version is .post #[test] fn version_major_post() -> Result<()> { @@ -941,6 +1332,84 @@ requires-python = ">=3.12" Ok(()) } +#[test] +fn bump_stable_with_value_fails() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" +[project] +name = "myproject" +version = "1.2.3" +requires-python = ">=3.12" +"#, + )?; + + uv_snapshot!(context.filters(), context.version() + .arg("--bump").arg("stable=1"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: `--bump stable` does not accept a value + "); + Ok(()) +} + +#[test] +fn bump_empty_value_fails() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" +[project] +name = "myproject" +version = "1.2.3" +requires-python = ">=3.12" +"#, + )?; + + uv_snapshot!(context.filters(), context.version() + .arg("--bump").arg("patch="), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: `--bump` values cannot be empty + "); + Ok(()) +} + +#[test] +fn bump_invalid_numeric_value_fails() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" +[project] +name = "myproject" +version = "1.2.3" +requires-python = ">=3.12" +"#, + )?; + + uv_snapshot!(context.filters(), context.version() + .arg("--bump").arg("dev=foo"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: invalid numeric value `foo` for `--bump dev` + "); + Ok(()) +} + // --bump stable but it decreases the version #[test] fn bump_decrease_stable() -> Result<()> { diff --git a/docs/guides/package.md b/docs/guides/package.md index 3d4400897..cf14390c0 100644 --- a/docs/guides/package.md +++ b/docs/guides/package.md @@ -88,6 +88,14 @@ The `--bump` option supports the following common version components: `major`, ` `stable`, `alpha`, `beta`, `rc`, `post`, and `dev`. When provided more than once, the components will be applied in order, from largest (`major`) to smallest (`dev`). +You can optionally provide a numeric value with `--bump =` to set the resulting +component explicitly: + +```console +$ uv version --bump patch --bump dev=66463664 +hello-world 0.0.1 => 0.0.2.dev66463664 +``` + To move from a stable to pre-release version, bump one of the major, minor, or patch components in addition to the pre-release component: diff --git a/docs/reference/cli.md b/docs/reference/cli.md index a99926388..e1af75d22 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1168,7 +1168,7 @@ uv version [OPTIONS] [VALUE]

Can be provided multiple times.

Expects to receive either a hostname (e.g., localhost), a host-port pair (e.g., localhost:8080), or a URL (e.g., https://localhost).

WARNING: Hosts included in this list will not be verified against the system's certificate store. Only use --allow-insecure-host in a secure network with verified sources, as it bypasses SSL verification and could expose you to MITM attacks.

-

May also be set with the UV_INSECURE_HOST environment variable.

--bump bump

Update the project version using the given semantics

+

May also be set with the UV_INSECURE_HOST environment variable.

--bump bump[=value]

Update the project version using the given semantics

This flag can be passed multiple times.

Possible values: