diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index bf605198f..fb8a5d668 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -532,8 +532,11 @@ pub struct VersionArgs { pub value: Option, /// Update the project version using the given semantics + /// + /// This flag can be passed multiple times to allow going to a new release and entering + /// a prerelease: `--bump patch --bump beta` #[arg(group = "operation", long)] - pub bump: Option, + pub bump: Vec, /// Don't write a new version to the `pyproject.toml` /// @@ -608,7 +611,9 @@ pub struct VersionArgs { pub python: Option>, } -#[derive(Debug, Copy, Clone, PartialEq, clap::ValueEnum)] +// Note that the ordering of the variants is significant, as when given a list of operations +// to perform, we sort them and apply them in order, so users don't have to think too hard about it. +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, clap::ValueEnum)] pub enum VersionBump { /// Increase the major version (1.2.3 => 2.0.0) Major, @@ -616,6 +621,37 @@ pub enum VersionBump { Minor, /// Increase the patch version (1.2.3 => 1.2.4) Patch, + /// Make the version stable (1.2.3b4.post5.dev6 => 1.2.3) + /// + /// This intentionally clears `.postN` and preserves `+local` + Stable, + /// Increase the alpha version (1.2.3a4 => 1.2.3a5) + Alpha, + /// Increase the beta version (1.2.3b4 => 1.2.3b5) + Beta, + /// Increase the rc version (1.2.3rc4 => 1.2.3rc5) + Rc, + /// Increase the post version (1.2.3.post5 => 1.2.3.post6) + Post, + /// Increase the dev version (1.2.3a4.dev6 => 1.2.3.dev7) + Dev, +} + +impl std::fmt::Display for VersionBump { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let string = match self { + VersionBump::Major => "major", + VersionBump::Minor => "minor", + VersionBump::Patch => "patch", + VersionBump::Stable => "stable", + VersionBump::Alpha => "alpha", + VersionBump::Beta => "beta", + VersionBump::Rc => "rc", + VersionBump::Post => "post", + VersionBump::Dev => "dev", + }; + string.fmt(f) + } } #[derive(Args)] diff --git a/crates/uv-pep440/src/lib.rs b/crates/uv-pep440/src/lib.rs index 0e8b50e72..40c7d97c6 100644 --- a/crates/uv-pep440/src/lib.rs +++ b/crates/uv-pep440/src/lib.rs @@ -29,8 +29,8 @@ pub use version_ranges::{ }; pub use { version::{ - LocalSegment, LocalVersion, LocalVersionSlice, MIN_VERSION, Operator, OperatorParseError, - Prerelease, PrereleaseKind, Version, VersionParseError, VersionPattern, + BumpCommand, LocalSegment, LocalVersion, LocalVersionSlice, MIN_VERSION, Operator, + OperatorParseError, Prerelease, PrereleaseKind, Version, VersionParseError, VersionPattern, VersionPatternParseError, }, version_specifier::{ diff --git a/crates/uv-pep440/src/version.rs b/crates/uv-pep440/src/version.rs index a496f95a2..223701692 100644 --- a/crates/uv-pep440/src/version.rs +++ b/crates/uv-pep440/src/version.rs @@ -643,6 +643,90 @@ impl Version { self.with_release(release) } + /// Various "increment the version" operations + pub fn bump(&mut self, bump: BumpCommand) { + // This code operates on the understanding that the components of a version form + // the following hierarchy: + // + // major > minor > patch > stable > pre > post > dev + // + // Any updates to something earlier in the hierarchy should clear all values lower + // in the hierarchy. So for instance: + // + // if you bump `minor`, then clear: patch, pre, post, dev + // if you bump `pre`, then clear: post, dev + // + // ...and so on. + // + // If you bump a value that doesn't exist, it will be set to "1". + // + // The special "stable" mode has no value, bumping it clears: pre, post, dev. + let full = self.make_full(); + + match bump { + BumpCommand::BumpRelease { index } => { + // Clear all sub-release items + full.pre = None; + full.post = None; + full.dev = None; + + // Use `max` here to try to do 0.2 => 0.3 instead of 0.2 => 0.3.0 + let old_parts = &full.release; + let len = old_parts.len().max(index + 1); + let new_release_vec = (0..len) + .map(|i| match i.cmp(&index) { + // 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, + // Everything after the bumped value becomes 0 + Ordering::Greater => 0, + }) + .collect::>(); + full.release = new_release_vec; + } + BumpCommand::MakeStable => { + // Clear all sub-release items + full.pre = None; + full.post = None; + full.dev = None; + } + BumpCommand::BumpPrerelease { kind } => { + // 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; + } + } + full.pre = Some(Prerelease { kind, number: 1 }); + } + BumpCommand::BumpPost => { + // Clear sub-post items + full.dev = None; + + // 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; + } else { + full.dev = Some(1); + } + } + } + } + /// Set the min-release component and return the updated version. /// /// The "min" component is internal-only, and does not exist in PEP 440. @@ -879,6 +963,27 @@ 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 + BumpRelease { + /// The release component to bump (0 is major, 1 is minor, 2 is patch) + index: usize, + }, + /// Bump the prerelease component + BumpPrerelease { + /// prerelease component to bump + kind: PrereleaseKind, + }, + /// Bump to the associated stable release + MakeStable, + /// Bump the post component + BumpPost, + /// Bump the dev component + BumpDev, +} + /// A small representation of a version. /// /// This representation is used for a (very common) subset of versions: the @@ -4043,4 +4148,351 @@ mod tests { assert_eq!(size_of::(), size_of::() * 2); assert_eq!(size_of::(), size_of::() * 2); } + + /// Test major bumping + /// Explicitly using the string display because we want to preserve formatting where possible! + #[test] + fn bump_major() { + // one digit + let mut version = "0".parse::().unwrap(); + version.bump(BumpCommand::BumpRelease { index: 0 }); + assert_eq!(version.to_string().as_str(), "1"); + + // two digit + let mut version = "1.5".parse::().unwrap(); + version.bump(BumpCommand::BumpRelease { index: 0 }); + 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 }); + 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 }); + 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 }); + 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 }); + assert_eq!(version.to_string().as_str(), "5!2.0.0.0+local"); + version.bump(BumpCommand::BumpRelease { index: 0 }); + assert_eq!(version.to_string().as_str(), "5!3.0.0.0+local"); + } + + /// Test minor bumping + /// Explicitly using the string display because we want to preserve formatting where possible! + #[test] + fn bump_minor() { + // one digit + let mut version = "0".parse::().unwrap(); + version.bump(BumpCommand::BumpRelease { index: 1 }); + assert_eq!(version.to_string().as_str(), "0.1"); + + // two digit + let mut version = "1.5".parse::().unwrap(); + version.bump(BumpCommand::BumpRelease { index: 1 }); + 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 }); + 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 }); + 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 }); + assert_eq!(version.to_string().as_str(), "5!1.8.0.0+local"); + version.bump(BumpCommand::BumpRelease { index: 1 }); + assert_eq!(version.to_string().as_str(), "5!1.9.0.0+local"); + } + + /// Test patch bumping + /// Explicitly using the string display because we want to preserve formatting where possible! + #[test] + fn bump_patch() { + // one digit + let mut version = "0".parse::().unwrap(); + version.bump(BumpCommand::BumpRelease { index: 2 }); + 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 }); + 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 }); + 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 }); + 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 }); + assert_eq!(version.to_string().as_str(), "5!1.7.4.0+local"); + version.bump(BumpCommand::BumpRelease { index: 2 }); + assert_eq!(version.to_string().as_str(), "5!1.7.5.0+local"); + } + + /// Test alpha bumping + /// Explicitly using the string display because we want to preserve formatting where possible! + #[test] + fn bump_alpha() { + // one digit + let mut version = "0".parse::().unwrap(); + version.bump(BumpCommand::BumpPrerelease { + kind: PrereleaseKind::Alpha, + }); + assert_eq!(version.to_string().as_str(), "0a1"); + + // two digit + let mut version = "1.5".parse::().unwrap(); + version.bump(BumpCommand::BumpPrerelease { + kind: PrereleaseKind::Alpha, + }); + assert_eq!(version.to_string().as_str(), "1.5a1"); + + // three digit + let mut version = "5.3.6".parse::().unwrap(); + version.bump(BumpCommand::BumpPrerelease { + kind: PrereleaseKind::Alpha, + }); + assert_eq!(version.to_string().as_str(), "5.3.6a1"); + + // four digit + let mut version = "1.2.3.4".parse::().unwrap(); + version.bump(BumpCommand::BumpPrerelease { + kind: PrereleaseKind::Alpha, + }); + assert_eq!(version.to_string().as_str(), "1.2.3.4a1"); + + // All the version junk + let mut version = "5!1.7.3.5b2.post345.dev456+local" + .parse::() + .unwrap(); + version.bump(BumpCommand::BumpPrerelease { + kind: PrereleaseKind::Alpha, + }); + assert_eq!(version.to_string().as_str(), "5!1.7.3.5a1+local"); + version.bump(BumpCommand::BumpPrerelease { + kind: PrereleaseKind::Alpha, + }); + assert_eq!(version.to_string().as_str(), "5!1.7.3.5a2+local"); + } + + /// Test beta bumping + /// Explicitly using the string display because we want to preserve formatting where possible! + #[test] + fn bump_beta() { + // one digit + let mut version = "0".parse::().unwrap(); + version.bump(BumpCommand::BumpPrerelease { + kind: PrereleaseKind::Beta, + }); + assert_eq!(version.to_string().as_str(), "0b1"); + + // two digit + let mut version = "1.5".parse::().unwrap(); + version.bump(BumpCommand::BumpPrerelease { + kind: PrereleaseKind::Beta, + }); + assert_eq!(version.to_string().as_str(), "1.5b1"); + + // three digit + let mut version = "5.3.6".parse::().unwrap(); + version.bump(BumpCommand::BumpPrerelease { + kind: PrereleaseKind::Beta, + }); + assert_eq!(version.to_string().as_str(), "5.3.6b1"); + + // four digit + let mut version = "1.2.3.4".parse::().unwrap(); + version.bump(BumpCommand::BumpPrerelease { + kind: PrereleaseKind::Beta, + }); + assert_eq!(version.to_string().as_str(), "1.2.3.4b1"); + + // All the version junk + let mut version = "5!1.7.3.5a2.post345.dev456+local" + .parse::() + .unwrap(); + version.bump(BumpCommand::BumpPrerelease { + kind: PrereleaseKind::Beta, + }); + assert_eq!(version.to_string().as_str(), "5!1.7.3.5b1+local"); + version.bump(BumpCommand::BumpPrerelease { + kind: PrereleaseKind::Beta, + }); + assert_eq!(version.to_string().as_str(), "5!1.7.3.5b2+local"); + } + + /// Test rc bumping + /// Explicitly using the string display because we want to preserve formatting where possible! + #[test] + fn bump_rc() { + // one digit + let mut version = "0".parse::().unwrap(); + version.bump(BumpCommand::BumpPrerelease { + kind: PrereleaseKind::Rc, + }); + assert_eq!(version.to_string().as_str(), "0rc1"); + + // two digit + let mut version = "1.5".parse::().unwrap(); + version.bump(BumpCommand::BumpPrerelease { + kind: PrereleaseKind::Rc, + }); + assert_eq!(version.to_string().as_str(), "1.5rc1"); + + // three digit + let mut version = "5.3.6".parse::().unwrap(); + version.bump(BumpCommand::BumpPrerelease { + kind: PrereleaseKind::Rc, + }); + assert_eq!(version.to_string().as_str(), "5.3.6rc1"); + + // four digit + let mut version = "1.2.3.4".parse::().unwrap(); + version.bump(BumpCommand::BumpPrerelease { + kind: PrereleaseKind::Rc, + }); + assert_eq!(version.to_string().as_str(), "1.2.3.4rc1"); + + // All the version junk + let mut version = "5!1.7.3.5b2.post345.dev456+local" + .parse::() + .unwrap(); + version.bump(BumpCommand::BumpPrerelease { + kind: PrereleaseKind::Rc, + }); + assert_eq!(version.to_string().as_str(), "5!1.7.3.5rc1+local"); + version.bump(BumpCommand::BumpPrerelease { + kind: PrereleaseKind::Rc, + }); + assert_eq!(version.to_string().as_str(), "5!1.7.3.5rc2+local"); + } + + /// Test post bumping + /// Explicitly using the string display because we want to preserve formatting where possible! + #[test] + fn bump_post() { + // one digit + let mut version = "0".parse::().unwrap(); + version.bump(BumpCommand::BumpPost); + assert_eq!(version.to_string().as_str(), "0.post1"); + + // two digit + let mut version = "1.5".parse::().unwrap(); + version.bump(BumpCommand::BumpPost); + assert_eq!(version.to_string().as_str(), "1.5.post1"); + + // three digit + let mut version = "5.3.6".parse::().unwrap(); + version.bump(BumpCommand::BumpPost); + 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); + 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); + assert_eq!(version.to_string().as_str(), "5!1.7.3.5b2.post1+local"); + version.bump(BumpCommand::BumpPost); + assert_eq!(version.to_string().as_str(), "5!1.7.3.5b2.post2+local"); + } + + /// Test dev bumping + /// Explicitly using the string display because we want to preserve formatting where possible! + #[test] + fn bump_dev() { + // one digit + let mut version = "0".parse::().unwrap(); + version.bump(BumpCommand::BumpDev); + assert_eq!(version.to_string().as_str(), "0.dev1"); + + // two digit + let mut version = "1.5".parse::().unwrap(); + version.bump(BumpCommand::BumpDev); + assert_eq!(version.to_string().as_str(), "1.5.dev1"); + + // three digit + let mut version = "5.3.6".parse::().unwrap(); + version.bump(BumpCommand::BumpDev); + 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); + 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); + assert_eq!( + version.to_string().as_str(), + "5!1.7.3.5b2.post345.dev1+local" + ); + version.bump(BumpCommand::BumpDev); + assert_eq!( + version.to_string().as_str(), + "5!1.7.3.5b2.post345.dev2+local" + ); + } + + /// Test stable setting + /// Explicitly using the string display because we want to preserve formatting where possible! + #[test] + fn make_stable() { + // one digit + let mut version = "0".parse::().unwrap(); + version.bump(BumpCommand::MakeStable); + assert_eq!(version.to_string().as_str(), "0"); + + // two digit + let mut version = "1.5".parse::().unwrap(); + version.bump(BumpCommand::MakeStable); + assert_eq!(version.to_string().as_str(), "1.5"); + + // three digit + let mut version = "5.3.6".parse::().unwrap(); + version.bump(BumpCommand::MakeStable); + assert_eq!(version.to_string().as_str(), "5.3.6"); + + // four digit + let mut version = "1.2.3.4".parse::().unwrap(); + version.bump(BumpCommand::MakeStable); + assert_eq!(version.to_string().as_str(), "1.2.3.4"); + + // All the version junk + let mut version = "5!1.7.3.5b2.post345+local".parse::().unwrap(); + version.bump(BumpCommand::MakeStable); + assert_eq!(version.to_string().as_str(), "5!1.7.3.5+local"); + version.bump(BumpCommand::MakeStable); + assert_eq!(version.to_string().as_str(), "5!1.7.3.5+local"); + } } diff --git a/crates/uv/src/commands/project/version.rs b/crates/uv/src/commands/project/version.rs index bc79f8eb9..b032cfae8 100644 --- a/crates/uv/src/commands/project/version.rs +++ b/crates/uv/src/commands/project/version.rs @@ -1,6 +1,6 @@ use std::fmt::Write; +use std::path::Path; use std::str::FromStr; -use std::{cmp::Ordering, path::Path}; use anyhow::{Context, Result, anyhow}; use owo_colors::OwoColorize; @@ -15,7 +15,7 @@ use uv_configuration::{ }; use uv_fs::Simplified; use uv_normalize::DefaultExtras; -use uv_pep440::Version; +use uv_pep440::{BumpCommand, PrereleaseKind, Version}; use uv_pep508::PackageName; use uv_python::{PythonDownloads, PythonPreference, PythonRequest}; use uv_settings::PythonInstallMirrors; @@ -55,7 +55,7 @@ pub(crate) fn self_version( #[allow(clippy::fn_params_excessive_bools)] pub(crate) async fn project_version( value: Option, - bump: Option, + mut bump: Vec, short: bool, output_format: VersionFormat, strict: bool, @@ -105,7 +105,7 @@ pub(crate) async fn project_version( }; // Short-circuit early for a frozen read - let is_read_only = value.is_none() && bump.is_none(); + let is_read_only = value.is_none() && bump.is_empty(); if frozen && is_read_only { return Box::pin(print_frozen_version( project, @@ -158,7 +158,8 @@ pub(crate) async fn project_version( match Version::from_str(&value) { Ok(version) => Some(version), Err(err) => match &*value { - "major" | "minor" | "patch" => { + "major" | "minor" | "patch" | "alpha" | "beta" | "rc" | "dev" | "post" + | "stable" => { return Err(anyhow!( "Invalid version `{value}`, did you mean to pass `--bump {value}`?" )); @@ -168,8 +169,120 @@ pub(crate) async fn project_version( } }, } - } else if let Some(bump) = bump { - Some(bumped_version(&old_version, bump, printer)?) + } else if !bump.is_empty() { + // While we can rationalize many of these combinations of operations together, + // we want to conservatively refuse to support any of them until users demand it. + // + // The most complex thing we *do* allow is `--bump major --bump beta --bump dev` + // because that makes perfect sense and is reasonable to do. + let release_components: Vec<_> = bump + .iter() + .filter(|bump| { + matches!( + bump, + VersionBump::Major | VersionBump::Minor | VersionBump::Patch + ) + }) + .collect(); + let prerelease_components: Vec<_> = bump + .iter() + .filter(|bump| { + matches!( + bump, + VersionBump::Alpha | VersionBump::Beta | VersionBump::Rc | VersionBump::Dev + ) + }) + .collect(); + let post_count = bump + .iter() + .filter(|bump| *bump == &VersionBump::Post) + .count(); + let stable_count = bump + .iter() + .filter(|bump| *bump == &VersionBump::Stable) + .count(); + + // Very little reason to do "bump to stable" and then do other things, + // even if we can make sense of it. + if stable_count > 0 && bump.len() > 1 { + if let Some(component) = release_components.first() { + return Err(anyhow!( + "`--bump stable` isn't needed if you're already passing `--bump {component}`" + )); + } + return Err(anyhow!( + "`--bump stable` cannot be combined with any other `--bump`" + )); + } + + // Very little reason to "bump to post" and then do other things, + // how is it a post-release otherwise? + if post_count > 0 && bump.len() > 1 { + return Err(anyhow!( + "`--bump post` cannot be combined with any other `--bump`" + )); + } + + // `--bump major --bump minor` makes perfect sense (1.2.3 => 2.1.0) + // ...but it's weird and probably a mistake? + // `--bump major --bump major` perfect sense (1.2.3 => 3.0.0) + // ...but it's weird and probably a mistake? + if release_components.len() > 1 { + return Err(anyhow!( + "`--bump` can only take one of `major`, `minor`, `patch`" + )); + } + + // `--bump alpha --bump beta` is basically completely incoherent + // `--bump beta --bump beta` makes perfect sense (1.2.3b4 => 1.2.3b6) + // ...but it's weird and probably a mistake? + // `--bump beta --bump dev` makes perfect sense (1.2.3 => 1.2.3b1.dev1) + // ...but we want to discourage mixing `dev` with prereleases + if prerelease_components.len() > 1 { + return Err(anyhow!( + "`--bump` can only take one of `alpha`, `beta`, `rc`, `dev`" + )); + } + + // Sort the given commands so the user doesn't have to care about + // the ordering of `--bump minor --bump beta` (only one ordering is ever useful) + bump.sort(); + + // 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 { + kind: PrereleaseKind::Alpha, + }, + VersionBump::Beta => BumpCommand::BumpPrerelease { + kind: PrereleaseKind::Beta, + }, + VersionBump::Rc => BumpCommand::BumpPrerelease { + kind: PrereleaseKind::Rc, + }, + VersionBump::Post => BumpCommand::BumpPost, + VersionBump::Dev => BumpCommand::BumpDev, + VersionBump::Stable => BumpCommand::MakeStable, + }; + new_version.bump(command); + } + + if new_version <= old_version { + if old_version.is_stable() && new_version.is_pre() { + return Err(anyhow!( + "{old_version} => {new_version} didn't increase the version; when moving to a prerelease you also need to increase the release `--bump patch`?" + )); + } + return Err(anyhow!( + "{old_version} => {new_version} didn't increase the version" + )); + } + + Some(new_version) } else { None }; @@ -569,35 +682,3 @@ fn print_version( } Ok(()) } - -fn bumped_version(from: &Version, bump: VersionBump, printer: Printer) -> Result { - // All prereleasey details "carry to 0" with every currently supported mode of `--bump` - // We could go out of our way to preserve epoch information but no one uses those... - if from.any_prerelease() || from.is_post() || from.is_local() || from.epoch() > 0 { - writeln!( - printer.stderr(), - "warning: prerelease information will be cleared as part of the version bump" - )?; - } - - let index = match bump { - VersionBump::Major => 0, - VersionBump::Minor => 1, - VersionBump::Patch => 2, - }; - - // Use `max` here to try to do 0.2 => 0.3 instead of 0.2 => 0.3.0 - let old_parts = from.release(); - let len = old_parts.len().max(index + 1); - let new_release_vec = (0..len) - .map(|i| match i.cmp(&index) { - // 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, - // Everything after the bumped value becomes 0 - Ordering::Greater => 0, - }) - .collect::>(); - Ok(Version::new(new_release_vec)) -} diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index ab4aee9e9..36962b8a5 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -2040,7 +2040,7 @@ async fn run_project( let strict = project_was_explicit || globals.preview.is_enabled() || args.dry_run - || args.bump.is_some() + || !args.bump.is_empty() || args.value.is_some() || args.package.is_some(); Box::pin(commands::project_version( diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 004ce5053..458020726 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -1561,7 +1561,7 @@ impl RemoveSettings { #[derive(Debug, Clone)] pub(crate) struct VersionSettings { pub(crate) value: Option, - pub(crate) bump: Option, + 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 97d30f4f4..8e15f2a56 100644 --- a/crates/uv/tests/it/version.rs +++ b/crates/uv/tests/it/version.rs @@ -512,7 +512,6 @@ requires-python = ">=3.12" myproject 1.10.31.dev10 => 2.0.0 ----- stderr ----- - warning: prerelease information will be cleared as part of the version bump Resolved 1 package in [TIME] Audited in [TIME] "); @@ -550,10 +549,9 @@ requires-python = ">=3.12" success: true exit_code: 0 ----- stdout ----- - myproject 1!2a3.post4.dev5+deadbeef6 => 3 + myproject 1!2a3.post4.dev5+deadbeef6 => 1!3+deadbeef6 ----- stderr ----- - warning: prerelease information will be cleared as part of the version bump Resolved 1 package in [TIME] Audited in [TIME] "); @@ -564,7 +562,295 @@ requires-python = ">=3.12" @r#" [project] name = "myproject" - version = "3" + version = "1!3+deadbeef6" + requires-python = ">=3.12" + "# + ); + Ok(()) +} + +// Pass a ton of bump flags to a complex version +// The flags are in a messy order and some are duplicated, +// Under extremely permissive semantics this could be allowed, but right +// now it fails for a dozen reasons! +#[test] +fn many_bump_complex() -> 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 = "9!2.3.4a5.post6.dev7+deadbeef6" +requires-python = ">=3.12" +"#, + )?; + + uv_snapshot!(context.filters(), context.version() + .arg("--bump").arg("major") + .arg("--bump").arg("patch") + .arg("--bump").arg("alpha") + .arg("--bump").arg("minor") + .arg("--bump").arg("dev") + .arg("--bump").arg("minor") + .arg("--bump").arg("post") + .arg("--bump").arg("post"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: `--bump post` cannot be combined with any other `--bump` + "); + + let pyproject = fs_err::read_to_string(&pyproject_toml)?; + assert_snapshot!( + pyproject, + @r#" + [project] + name = "myproject" + version = "9!2.3.4a5.post6.dev7+deadbeef6" + requires-python = ">=3.12" + "# + ); + Ok(()) +} + +// --bump stable +#[test] +fn bump_stable() -> 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 = "9!2.3.4a5.post6.dev7+deadbeef6" +requires-python = ">=3.12" +"#, + )?; + + uv_snapshot!(context.filters(), context.version() + .arg("--bump").arg("stable"), @r" + success: true + exit_code: 0 + ----- stdout ----- + myproject 9!2.3.4a5.post6.dev7+deadbeef6 => 9!2.3.4+deadbeef6 + + ----- 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 = "9!2.3.4+deadbeef6" + requires-python = ">=3.12" + "# + ); + Ok(()) +} + +// --bump alpha +#[test] +fn bump_alpha() -> 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 = "9!2.3.4a5.post6.dev7+deadbeef6" +requires-python = ">=3.12" +"#, + )?; + + uv_snapshot!(context.filters(), context.version() + .arg("--bump").arg("alpha"), @r" + success: true + exit_code: 0 + ----- stdout ----- + myproject 9!2.3.4a5.post6.dev7+deadbeef6 => 9!2.3.4a6+deadbeef6 + + ----- 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 = "9!2.3.4a6+deadbeef6" + requires-python = ">=3.12" + "# + ); + Ok(()) +} + +// --bump beta +#[test] +fn bump_beta() -> 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 = "9!2.3.4a5.post6.dev7+deadbeef6" +requires-python = ">=3.12" +"#, + )?; + + uv_snapshot!(context.filters(), context.version() + .arg("--bump").arg("beta"), @r" + success: true + exit_code: 0 + ----- stdout ----- + myproject 9!2.3.4a5.post6.dev7+deadbeef6 => 9!2.3.4b1+deadbeef6 + + ----- 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 = "9!2.3.4b1+deadbeef6" + requires-python = ">=3.12" + "# + ); + Ok(()) +} + +// --bump rc +#[test] +fn bump_rc() -> 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 = "9!2.3.4a5.post6.dev7+deadbeef6" +requires-python = ">=3.12" +"#, + )?; + + uv_snapshot!(context.filters(), context.version() + .arg("--bump").arg("rc"), @r" + success: true + exit_code: 0 + ----- stdout ----- + myproject 9!2.3.4a5.post6.dev7+deadbeef6 => 9!2.3.4rc1+deadbeef6 + + ----- 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 = "9!2.3.4rc1+deadbeef6" + requires-python = ">=3.12" + "# + ); + Ok(()) +} + +// --bump post +#[test] +fn bump_post() -> 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 = "9!2.3.4a5.post6.dev7+deadbeef6" +requires-python = ">=3.12" +"#, + )?; + + uv_snapshot!(context.filters(), context.version() + .arg("--bump").arg("post"), @r" + success: true + exit_code: 0 + ----- stdout ----- + myproject 9!2.3.4a5.post6.dev7+deadbeef6 => 9!2.3.4a5.post7+deadbeef6 + + ----- 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 = "9!2.3.4a5.post7+deadbeef6" + requires-python = ">=3.12" + "# + ); + Ok(()) +} + +// --bump dev +#[test] +fn bump_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 = "9!2.3.4a5.post6.dev7+deadbeef6" +requires-python = ">=3.12" +"#, + )?; + + uv_snapshot!(context.filters(), context.version() + .arg("--bump").arg("dev"), @r" + success: true + exit_code: 0 + ----- stdout ----- + myproject 9!2.3.4a5.post6.dev7+deadbeef6 => 9!2.3.4a5.post6.dev8+deadbeef6 + + ----- 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 = "9!2.3.4a5.post6.dev8+deadbeef6" requires-python = ">=3.12" "# ); @@ -594,7 +880,6 @@ requires-python = ">=3.12" myproject 1.10.31.post10 => 2.0.0 ----- stderr ----- - warning: prerelease information will be cleared as part of the version bump Resolved 1 package in [TIME] Audited in [TIME] "); @@ -612,6 +897,317 @@ requires-python = ">=3.12" Ok(()) } +// --bump stable but it decreases the version +#[test] +fn bump_decrease_stable() -> 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.post6" +requires-python = ">=3.12" +"#, + )?; + + uv_snapshot!(context.filters(), context.version() + .arg("--bump").arg("stable"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: 2.3.4.post6 => 2.3.4 didn't increase the version + "); + Ok(()) +} + +// --bump alpha but it decreases the version by reverting beta +#[test] +fn bump_decrease_alpha_beta() -> 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.4b5" +requires-python = ">=3.12" +"#, + )?; + + uv_snapshot!(context.filters(), context.version() + .arg("--bump").arg("alpha"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: 2.3.4b5 => 2.3.4a1 didn't increase the version + "); + Ok(()) +} + +// --bump alpha but it decreases the version from a stable +#[test] +fn bump_decrease_alpha_stable() -> 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("alpha"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: 2.3.4 => 2.3.4a1 didn't increase the version; when moving to a prerelease you also need to increase the release `--bump patch`? + "); + Ok(()) +} + +// --bump major twice +#[test] +fn bump_double_major() -> 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") + .arg("--bump").arg("major"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: `--bump` can only take one of `major`, `minor`, `patch` + "); + Ok(()) +} + +// --bump alpha twice +#[test] +fn bump_double_alpha() -> 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("alpha") + .arg("--bump").arg("alpha"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: `--bump` can only take one of `alpha`, `beta`, `rc`, `dev` + "); + Ok(()) +} + +// --bump stable --bump major +#[test] +fn bump_stable_major() -> 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("stable") + .arg("--bump").arg("major"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: `--bump stable` isn't needed if you're already passing `--bump major` + "); + Ok(()) +} + +// --bump major --bump alpha +#[test] +fn bump_alpha_major() -> 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") + .arg("--bump").arg("alpha"), @r" + success: true + exit_code: 0 + ----- stdout ----- + myproject 2.3.4 => 3.0.0a1 + + ----- stderr ----- + Resolved 1 package in [TIME] + Audited in [TIME] + "); + Ok(()) +} + +// --bump major --bump minor +#[test] +fn bump_minor_major() -> 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") + .arg("--bump").arg("alpha"), @r" + success: true + exit_code: 0 + ----- stdout ----- + myproject 2.3.4 => 3.0.0a1 + + ----- stderr ----- + Resolved 1 package in [TIME] + Audited in [TIME] + "); + Ok(()) +} + +// --bump alpha --bump dev +#[test] +fn bump_alpha_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 = "2.3.4" +requires-python = ">=3.12" +"#, + )?; + + uv_snapshot!(context.filters(), context.version() + .arg("--bump").arg("alpha") + .arg("--bump").arg("dev"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: `--bump` can only take one of `alpha`, `beta`, `rc`, `dev` + "); + Ok(()) +} + +// --bump major --bump dev +#[test] +fn bump_dev_major() -> 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") + .arg("--bump").arg("dev"), @r" + success: true + exit_code: 0 + ----- stdout ----- + myproject 2.3.4 => 3.0.0.dev1 + + ----- stderr ----- + Resolved 1 package in [TIME] + Audited in [TIME] + "); + Ok(()) +} + +// --bump major --bump post +#[test] +fn bump_post_major() -> 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") + .arg("--bump").arg("post"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: `--bump post` cannot be combined with any other `--bump` + "); + Ok(()) +} + // Set version --dry-run #[test] fn version_set_dry() -> Result<()> { diff --git a/docs/concepts/projects/config.md b/docs/concepts/projects/config.md index f9d33ed90..a2bca9415 100644 --- a/docs/concepts/projects/config.md +++ b/docs/concepts/projects/config.md @@ -160,6 +160,8 @@ Setting `tool.uv.package = false` will force a project package _not_ to be built the project environment. uv will ignore a declared build system when interacting with the project; however, uv will still respect explicit attempts to build the project such as invoking `uv build`. +## Project versioning + ## Project environment path The `UV_PROJECT_ENVIRONMENT` environment variable can be used to configure the project virtual diff --git a/docs/guides/package.md b/docs/guides/package.md index ce5bae7f9..47a7772d9 100644 --- a/docs/guides/package.md +++ b/docs/guides/package.md @@ -55,6 +55,83 @@ Alternatively, `uv build ` will build the package in the specified director running `uv build --no-sources` to ensure that the package builds correctly when `tool.uv.sources` is disabled, as is the case when using other build tools, like [`pypa/build`](https://github.com/pypa/build). +## Updating your version + +The `uv version` command provides conveniences for updating the version of your package before you +publish it. +[See the project docs for reading your package's version](./projects.md#managing-version). + +To set the the exact version of your package, just pass that version: + +```console +$ uv version 1.0.0 +hello-world 0.7.0 => 1.0.0 +``` + +If you want to preview the change without actually applying it, use the `--dry-run` flag: + +```console +$ uv version 2.0.0 --dry-run +hello-world 1.0.0 => 2.0.0 +$ uv version +hello-world 1.0.0 +``` + +If you want to change the version of a particular package, use the `--package` flag: + +```console +$ uv version --package hello-world 1.2.3 +hello-world 1.0.0 => 1.2.3 +``` + +To increase the version of your package, use the `--bump` flag: + +```console +$ uv version --bump minor +hello-world 1.2.3 => 1.3.0 +``` + +The `--bump` flag can be passed multiple times, and uv will run them in the following order that +prevents bumps from clobbering eachother: + +```text + major > minor > patch > stable > alpha > beta > rc > post > dev +``` + +When you're on a stable version and want to start shipping prereleases, you'll want to bump the +release and the prerelease: + +```console +$ uv version --bump patch --bump beta +hello-world 1.3.0 => 1.3.1b1 +``` + +!!! Note + + If you only bump the prerelease here it will actually decrease the current version. + `uv version` will error if that ever happens. If you intended to do that, you can pass + `--allow-decreases` to disable the check. + +When you're on a prerelease and want to ship another, you can just bump the prerelease: + +```console +uv version --bump beta +hello-world 1.3.0b1 => 1.3.1b2 +``` + +When you're on a prerelease and want to ship a stable version, you can bump to stable: + +```console +uv version --bump stable +hello-world 1.3.1b2 => 1.3.1 +``` + +!!! info + + By default, when `uv version` modifies your package it will lock and sync your project to + ensure everything sees the change. To prevent locking and syncing, pass `--frozen`. To just + prevent syncing, pass `--no-sync`. + ## Publishing your package Publish your package with `uv publish`: diff --git a/docs/guides/projects.md b/docs/guides/projects.md index 0fd97eb0d..9075bf5e9 100644 --- a/docs/guides/projects.md +++ b/docs/guides/projects.md @@ -160,6 +160,43 @@ version, while keeping the rest of the lockfile intact. See the documentation on [managing dependencies](../concepts/projects/dependencies.md) for more details. +## Managing version + +The `uv version` command can be used to read your package's version. +[See the publishing docs for updating your package's version](./package.md#updating-your-version). + +To get the version of your package, run `uv version` with no other arguments: + +```console +$ uv version +hello-world 0.7.0 +``` + +To get the version of a particular package, pass `--package`: + +```console +$ uv version --package myapp +myapp 1.2.3 +``` + +To just get the version with no other output, pass `--short`: + +```console +$ uv version --short +0.7.0 +``` + +To get the version as json, pass `--output-format json`: + +```console +$ uv version --output-format json +{ + "package_name": "hello-world", + "version": "0.7.0", + "commit_info": null +} +``` + ## Running commands `uv run` can be used to run arbitrary scripts or commands in your project environment. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 82fe0fa3d..0bc8931fa 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -785,11 +785,18 @@ uv version [OPTIONS] [VALUE]

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

+

This flag can be passed multiple times to allow going to a new release and entering a prerelease: --bump patch --bump beta

Possible values:

  • major: Increase the major version (1.2.3 => 2.0.0)
  • minor: Increase the minor version (1.2.3 => 1.3.0)
  • patch: Increase the patch version (1.2.3 => 1.2.4)
  • +
  • stable: Make the version stable (1.2.3b4.post5.dev6 => 1.2.3)
  • +
  • alpha: Increase the alpha version (1.2.3a4 => 1.2.3a5)
  • +
  • beta: Increase the beta version (1.2.3b4 => 1.2.3b5)
  • +
  • rc: Increase the rc version (1.2.3rc4 => 1.2.3rc5)
  • +
  • post: Increase the post version (1.2.3.post5 => 1.2.3.post6)
  • +
  • dev: Increase the dev version (1.2.3a4.dev6 => 1.2.3.dev7)
--cache-dir cache-dir

Path to the cache directory.

Defaults to $XDG_CACHE_HOME/uv or $HOME/.cache/uv on macOS and Linux, and %LOCALAPPDATA%\uv\cache on Windows.

To view the location of the cache directory, run uv cache dir.