diff --git a/Cargo.lock b/Cargo.lock index 1852cef58..29d7c002a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4886,6 +4886,7 @@ dependencies = [ "uv-torch", "uv-version", "uv-warnings", + "uv-workspace", ] [[package]] @@ -5777,6 +5778,7 @@ dependencies = [ "uv-static", "uv-torch", "uv-warnings", + "uv-workspace", ] [[package]] @@ -5939,6 +5941,7 @@ version = "0.0.1" dependencies = [ "anyhow", "assert_fs", + "clap", "fs-err 3.1.0", "glob", "insta", diff --git a/crates/uv-cli/Cargo.toml b/crates/uv-cli/Cargo.toml index 5713af93f..fa8453662 100644 --- a/crates/uv-cli/Cargo.toml +++ b/crates/uv-cli/Cargo.toml @@ -32,6 +32,7 @@ uv-static = { workspace = true } uv-torch = { workspace = true, features = ["clap"] } uv-version = { workspace = true } uv-warnings = { workspace = true } +uv-workspace = { workspace = true } anstream = { workspace = true } anyhow = { workspace = true } diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 0dac96000..b9b638e66 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -22,6 +22,7 @@ use uv_redacted::DisplaySafeUrl; use uv_resolver::{AnnotationStyle, ExcludeNewer, ForkStrategy, PrereleaseMode, ResolutionMode}; use uv_static::EnvVars; use uv_torch::TorchMode; +use uv_workspace::pyproject_mut::AddBoundsKind; pub mod comma; pub mod compat; @@ -836,10 +837,6 @@ pub enum ProjectCommand { /// it includes markers that differ from the existing specifier in which case another entry for /// the dependency will be added. /// - /// If no constraint or URL is provided for a dependency, a lower bound is added equal to the - /// latest compatible version of the package, e.g., `>=1.2.3`, unless `--frozen` is provided, in - /// which case no resolution is performed. - /// /// The lockfile and project environment will be updated to reflect the added dependencies. To /// skip updating the lockfile, use `--frozen`. To skip updating the environment, use /// `--no-sync`. @@ -3562,6 +3559,19 @@ pub struct AddArgs { )] pub raw: bool, + /// The kind of version specifier to use when adding dependencies. + /// + /// When adding a dependency to the project, if no constraint or URL is provided, a constraint + /// is added based on the latest compatible version of the package. By default, a lower bound + /// constraint is used, e.g., `>=1.2.3`. + /// + /// When `--frozen` is provided, no resolution is performed, and dependencies are always added + /// without constraints. + /// + /// This option is in preview and may change in any future release. + #[arg(long, value_enum)] + pub bounds: Option, + /// Commit to use when adding a dependency from Git. #[arg(long, group = "git-ref", action = clap::ArgAction::Set)] pub rev: Option, diff --git a/crates/uv-settings/Cargo.toml b/crates/uv-settings/Cargo.toml index 7e9f4b526..1cc376eea 100644 --- a/crates/uv-settings/Cargo.toml +++ b/crates/uv-settings/Cargo.toml @@ -33,6 +33,7 @@ uv-resolver = { workspace = true, features = ["schemars", "clap"] } uv-static = { workspace = true } uv-torch = { workspace = true, features = ["schemars", "clap"] } uv-warnings = { workspace = true } +uv-workspace = { workspace = true, features = ["schemars", "clap"] } clap = { workspace = true } fs-err = { workspace = true } diff --git a/crates/uv-settings/src/combine.rs b/crates/uv-settings/src/combine.rs index 900b56d42..8edbd2a05 100644 --- a/crates/uv-settings/src/combine.rs +++ b/crates/uv-settings/src/combine.rs @@ -14,6 +14,7 @@ use uv_python::{PythonDownloads, PythonPreference, PythonVersion}; use uv_redacted::DisplaySafeUrl; use uv_resolver::{AnnotationStyle, ExcludeNewer, ForkStrategy, PrereleaseMode, ResolutionMode}; use uv_torch::TorchMode; +use uv_workspace::pyproject_mut::AddBoundsKind; use crate::{FilesystemOptions, Options, PipOptions}; @@ -74,6 +75,7 @@ macro_rules! impl_combine_or { }; } +impl_combine_or!(AddBoundsKind); impl_combine_or!(AnnotationStyle); impl_combine_or!(ExcludeNewer); impl_combine_or!(ExportFormat); diff --git a/crates/uv-settings/src/settings.rs b/crates/uv-settings/src/settings.rs index effe93199..ed86147f7 100644 --- a/crates/uv-settings/src/settings.rs +++ b/crates/uv-settings/src/settings.rs @@ -20,6 +20,7 @@ use uv_redacted::DisplaySafeUrl; use uv_resolver::{AnnotationStyle, ExcludeNewer, ForkStrategy, PrereleaseMode, ResolutionMode}; use uv_static::EnvVars; use uv_torch::TorchMode; +use uv_workspace::pyproject_mut::AddBoundsKind; /// A `pyproject.toml` with an (optional) `[tool.uv]` section. #[allow(dead_code)] @@ -53,6 +54,9 @@ pub struct Options { #[serde(flatten)] pub publish: PublishOptions, + #[serde(flatten)] + pub add: AddOptions, + #[option_group] pub pip: Option, @@ -1841,6 +1845,10 @@ pub struct OptionsWire { trusted_publishing: Option, check_url: Option, + // #[serde(flatten)] + // add: AddOptions + add_bounds: Option, + pip: Option, cache_keys: Option>, @@ -1929,6 +1937,7 @@ impl From for Options { dev_dependencies, managed, package, + add_bounds: bounds, // Used by the build backend build_backend, } = value; @@ -1996,6 +2005,7 @@ impl From for Options { trusted_publishing, check_url, }, + add: AddOptions { add_bounds: bounds }, workspace, sources, dev_dependencies, @@ -2057,3 +2067,28 @@ pub struct PublishOptions { )] pub check_url: Option, } + +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, CombineOptions, OptionsMetadata)] +#[serde(rename_all = "kebab-case")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct AddOptions { + /// The default version specifier when adding a dependency. + /// + /// When adding a dependency to the project, if no constraint or URL is provided, a constraint + /// is added based on the latest compatible version of the package. By default, a lower bound + /// constraint is used, e.g., `>=1.2.3`. + /// + /// When `--frozen` is provided, no resolution is performed, and dependencies are always added + /// without constraints. + /// + /// This option is in preview and may change in any future release. + #[option( + default = "\"lower\"", + value_type = "str", + example = r#" + add-bounds = "major" + "#, + possible_values = true + )] + pub add_bounds: Option, +} diff --git a/crates/uv-workspace/Cargo.toml b/crates/uv-workspace/Cargo.toml index e69a114a8..a8d672aab 100644 --- a/crates/uv-workspace/Cargo.toml +++ b/crates/uv-workspace/Cargo.toml @@ -31,6 +31,7 @@ uv-redacted = { workspace = true } uv-static = { workspace = true } uv-warnings = { workspace = true } +clap = { workspace = true, optional = true } fs-err = { workspace = true } glob = { workspace = true } itertools = { workspace = true } diff --git a/crates/uv-workspace/src/pyproject_mut.rs b/crates/uv-workspace/src/pyproject_mut.rs index 150fbbdd8..73e3833ae 100644 --- a/crates/uv-workspace/src/pyproject_mut.rs +++ b/crates/uv-workspace/src/pyproject_mut.rs @@ -1,8 +1,9 @@ +use itertools::Itertools; +use serde::{Deserialize, Serialize}; +use std::fmt::{Display, Formatter}; use std::path::Path; use std::str::FromStr; -use std::{fmt, mem}; - -use itertools::Itertools; +use std::{fmt, iter, mem}; use thiserror::Error; use toml_edit::{ Array, ArrayOfTables, DocumentMut, Formatted, Item, RawString, Table, TomlError, Value, @@ -50,6 +51,8 @@ pub enum Error { package_name: PackageName, requirements: Vec, }, + #[error("Unknown bound king {0}")] + UnknownBoundKind(String), } /// The result of editing an array in a TOML document. @@ -83,6 +86,169 @@ impl ArrayEdit { } } +/// The default version specifier when adding a dependency. +// While PEP 440 allows an arbitrary number of version digits, the `major` and `minor` build on +// most projects sticking to two or three components and a SemVer-ish versioning system, so can +// bump the major or minor version of a major.minor or major.minor.patch input version. +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum AddBoundsKind { + /// Only a lower bound, e.g., `>=1.2.3`. + #[default] + Lower, + /// Allow the same major version, similar to the semver caret, e.g., `>=1.2.3, <2.0.0`. + /// + /// Leading zeroes are skipped, e.g. `>=0.1.2, <0.2.0`. + Major, + /// Allow the same minor version, similar to the semver tilde, e.g., `>=1.2.3, <1.3.0`. + /// + /// Leading zeroes are skipped, e.g. `>=0.1.2, <0.1.3`. + Minor, + /// Pin the exact version, e.g., `==1.2.3`. + /// + /// This option is not recommended, as versions are already pinned in the uv lockfile. + Exact, +} + +impl Display for AddBoundsKind { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Lower => write!(f, "lower"), + Self::Major => write!(f, "major"), + Self::Minor => write!(f, "minor"), + Self::Exact => write!(f, "exact"), + } + } +} + +impl AddBoundsKind { + fn specifiers(self, version: Version) -> VersionSpecifiers { + // Nomenclature: "major" is the most significant component of the version, "minor" is the + // second most significant component, so most versions are either major.minor.patch or + // 0.major.minor. + match self { + AddBoundsKind::Lower => { + VersionSpecifiers::from(VersionSpecifier::greater_than_equal_version(version)) + } + AddBoundsKind::Major => { + let leading_zeroes = version + .release() + .iter() + .take_while(|digit| **digit == 0) + .count(); + + // Special case: The version is 0. + if leading_zeroes == version.release().len() { + let upper_bound = Version::new( + [0, 1] + .into_iter() + .chain(iter::repeat_n(0, version.release().iter().skip(2).len())), + ); + return VersionSpecifiers::from_iter([ + VersionSpecifier::greater_than_equal_version(version), + VersionSpecifier::less_than_version(upper_bound), + ]); + } + + // Compute the new major version and pad it to the same length: + // 1.2.3 -> 2.0.0 + // 1.2 -> 2.0 + // 1 -> 2 + // We ignore leading zeroes, adding Semver-style semantics to 0.x versions, too: + // 0.1.2 -> 0.2.0 + // 0.0.1 -> 0.0.2 + let major = version.release().get(leading_zeroes).copied().unwrap_or(0); + // The length of the lower bound minus the leading zero and bumped component. + let trailing_zeros = version.release().iter().skip(leading_zeroes + 1).len(); + let upper_bound = Version::new( + iter::repeat_n(0, leading_zeroes) + .chain(iter::once(major + 1)) + .chain(iter::repeat_n(0, trailing_zeros)), + ); + + VersionSpecifiers::from_iter([ + VersionSpecifier::greater_than_equal_version(version), + VersionSpecifier::less_than_version(upper_bound), + ]) + } + AddBoundsKind::Minor => { + let leading_zeroes = version + .release() + .iter() + .take_while(|digit| **digit == 0) + .count(); + + // Special case: The version is 0. + if leading_zeroes == version.release().len() { + let upper_bound = [0, 0, 1] + .into_iter() + .chain(iter::repeat_n(0, version.release().iter().skip(3).len())); + return VersionSpecifiers::from_iter([ + VersionSpecifier::greater_than_equal_version(version), + VersionSpecifier::less_than_version(Version::new(upper_bound)), + ]); + } + + // If both major and minor version are 0, the concept of bumping the minor version + // instead of the major version is not useful. Instead, we bump the next + // non-zero part of the version. This avoids extending the three components of 0.0.1 + // to the four components of 0.0.1.1. + if leading_zeroes >= 2 { + let most_significant = + version.release().get(leading_zeroes).copied().unwrap_or(0); + // The length of the lower bound minus the leading zero and bumped component. + let trailing_zeros = version.release().iter().skip(leading_zeroes + 1).len(); + let upper_bound = Version::new( + iter::repeat_n(0, leading_zeroes) + .chain(iter::once(most_significant + 1)) + .chain(iter::repeat_n(0, trailing_zeros)), + ); + return VersionSpecifiers::from_iter([ + VersionSpecifier::greater_than_equal_version(version), + VersionSpecifier::less_than_version(upper_bound), + ]); + } + + // Compute the new minor version and pad it to the same length where possible: + // 1.2.3 -> 1.3.0 + // 1.2 -> 1.3 + // 1 -> 1.1 + // We ignore leading zero, adding Semver-style semantics to 0.x versions, too: + // 0.1.2 -> 0.1.3 + // 0.0.1 -> 0.0.2 + + // If the version has only one digit, say `1`, or if there are only leading zeroes, + // pad with zeroes. + let major = version.release().get(leading_zeroes).copied().unwrap_or(0); + let minor = version + .release() + .get(leading_zeroes + 1) + .copied() + .unwrap_or(0); + let upper_bound = Version::new( + iter::repeat_n(0, leading_zeroes) + .chain(iter::once(major)) + .chain(iter::once(minor + 1)) + .chain(iter::repeat_n( + 0, + version.release().iter().skip(leading_zeroes + 2).len(), + )), + ); + + VersionSpecifiers::from_iter([ + VersionSpecifier::greater_than_equal_version(version), + VersionSpecifier::less_than_version(upper_bound), + ]) + } + AddBoundsKind::Exact => { + VersionSpecifiers::from_iter([VersionSpecifier::equals_version(version)]) + } + } + } +} + /// Specifies whether dependencies are added to a script file or a `pyproject.toml` file. #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum DependencyTarget { @@ -523,22 +689,19 @@ impl PyProjectTomlMut { Ok(added) } - /// Set the minimum version for an existing dependency. - pub fn set_dependency_minimum_version( + /// Set the constraint for a requirement for an existing dependency. + pub fn set_dependency_bound( &mut self, dependency_type: &DependencyType, index: usize, version: Version, + bound_kind: AddBoundsKind, ) -> Result<(), Error> { let group = match dependency_type { - DependencyType::Production => self.set_project_dependency_minimum_version()?, - DependencyType::Dev => self.set_dev_dependency_minimum_version()?, - DependencyType::Optional(extra) => { - self.set_optional_dependency_minimum_version(extra)? - } - DependencyType::Group(group) => { - self.set_dependency_group_requirement_minimum_version(group)? - } + DependencyType::Production => self.dependencies_array()?, + DependencyType::Dev => self.dev_dependencies_array()?, + DependencyType::Optional(extra) => self.optional_dependencies_array(extra)?, + DependencyType::Group(group) => self.dependency_groups_array(group)?, }; let Some(req) = group.get(index) else { @@ -549,16 +712,16 @@ impl PyProjectTomlMut { .as_str() .and_then(try_parse_requirement) .ok_or(Error::MalformedDependencies)?; - req.version_or_url = Some(VersionOrUrl::VersionSpecifier(VersionSpecifiers::from( - VersionSpecifier::greater_than_equal_version(version), - ))); + req.version_or_url = Some(VersionOrUrl::VersionSpecifier( + bound_kind.specifiers(version), + )); group.replace(index, req.to_string()); Ok(()) } - /// Set the minimum version for an existing dependency in `project.dependencies`. - fn set_project_dependency_minimum_version(&mut self) -> Result<&mut Array, Error> { + /// Get the TOML array for `project.dependencies`. + fn dependencies_array(&mut self) -> Result<&mut Array, Error> { // Get or create `project.dependencies`. let dependencies = self .project()? @@ -570,8 +733,8 @@ impl PyProjectTomlMut { Ok(dependencies) } - /// Set the minimum version for an existing dependency in `tool.uv.dev-dependencies`. - fn set_dev_dependency_minimum_version(&mut self) -> Result<&mut Array, Error> { + /// Get the TOML array for `tool.uv.dev-dependencies`. + fn dev_dependencies_array(&mut self) -> Result<&mut Array, Error> { // Get or create `tool.uv.dev-dependencies`. let dev_dependencies = self .doc @@ -591,11 +754,8 @@ impl PyProjectTomlMut { Ok(dev_dependencies) } - /// Set the minimum version for an existing dependency in `project.optional-dependencies`. - fn set_optional_dependency_minimum_version( - &mut self, - group: &ExtraName, - ) -> Result<&mut Array, Error> { + /// Get the TOML array for a `project.optional-dependencies` entry. + fn optional_dependencies_array(&mut self, group: &ExtraName) -> Result<&mut Array, Error> { // Get or create `project.optional-dependencies`. let optional_dependencies = self .project()? @@ -623,11 +783,8 @@ impl PyProjectTomlMut { Ok(group) } - /// Set the minimum version for an existing dependency in `dependency-groups`. - fn set_dependency_group_requirement_minimum_version( - &mut self, - group: &GroupName, - ) -> Result<&mut Array, Error> { + /// Get the TOML array for a `dependency-groups` entry. + fn dependency_groups_array(&mut self, group: &GroupName) -> Result<&mut Array, Error> { // Get or create `dependency-groups`. let dependency_groups = self .doc @@ -1485,7 +1642,9 @@ fn split_specifiers(req: &str) -> (&str, &str) { #[cfg(test)] mod test { - use super::split_specifiers; + use super::{AddBoundsKind, split_specifiers}; + use std::str::FromStr; + use uv_pep440::Version; #[test] fn split() { @@ -1506,4 +1665,107 @@ mod test { ) ); } + + #[test] + fn bound_kind_to_specifiers_exact() { + let tests = [ + ("0", "==0"), + ("0.0", "==0.0"), + ("0.0.0", "==0.0.0"), + ("0.1", "==0.1"), + ("0.0.1", "==0.0.1"), + ("0.0.0.1", "==0.0.0.1"), + ("1.0.0", "==1.0.0"), + ("1.2", "==1.2"), + ("1.2.3", "==1.2.3"), + ("1.2.3.4", "==1.2.3.4"), + ("1.2.3.4a1.post1", "==1.2.3.4a1.post1"), + ]; + + for (version, expected) in tests { + let actual = AddBoundsKind::Exact + .specifiers(Version::from_str(version).unwrap()) + .to_string(); + assert_eq!(actual, expected, "{version}"); + } + } + + #[test] + fn bound_kind_to_specifiers_lower() { + let tests = [ + ("0", ">=0"), + ("0.0", ">=0.0"), + ("0.0.0", ">=0.0.0"), + ("0.1", ">=0.1"), + ("0.0.1", ">=0.0.1"), + ("0.0.0.1", ">=0.0.0.1"), + ("1", ">=1"), + ("1.0.0", ">=1.0.0"), + ("1.2", ">=1.2"), + ("1.2.3", ">=1.2.3"), + ("1.2.3.4", ">=1.2.3.4"), + ("1.2.3.4a1.post1", ">=1.2.3.4a1.post1"), + ]; + + for (version, expected) in tests { + let actual = AddBoundsKind::Lower + .specifiers(Version::from_str(version).unwrap()) + .to_string(); + assert_eq!(actual, expected, "{version}"); + } + } + + #[test] + fn bound_kind_to_specifiers_major() { + let tests = [ + ("0", ">=0, <0.1"), + ("0.0", ">=0.0, <0.1"), + ("0.0.0", ">=0.0.0, <0.1.0"), + ("0.0.0.0", ">=0.0.0.0, <0.1.0.0"), + ("0.1", ">=0.1, <0.2"), + ("0.0.1", ">=0.0.1, <0.0.2"), + ("0.0.1.1", ">=0.0.1.1, <0.0.2.0"), + ("0.0.0.1", ">=0.0.0.1, <0.0.0.2"), + ("1", ">=1, <2"), + ("1.0.0", ">=1.0.0, <2.0.0"), + ("1.2", ">=1.2, <2.0"), + ("1.2.3", ">=1.2.3, <2.0.0"), + ("1.2.3.4", ">=1.2.3.4, <2.0.0.0"), + ("1.2.3.4a1.post1", ">=1.2.3.4a1.post1, <2.0.0.0"), + ]; + + for (version, expected) in tests { + let actual = AddBoundsKind::Major + .specifiers(Version::from_str(version).unwrap()) + .to_string(); + assert_eq!(actual, expected, "{version}"); + } + } + + #[test] + fn bound_kind_to_specifiers_minor() { + let tests = [ + ("0", ">=0, <0.0.1"), + ("0.0", ">=0.0, <0.0.1"), + ("0.0.0", ">=0.0.0, <0.0.1"), + ("0.0.0.0", ">=0.0.0.0, <0.0.1.0"), + ("0.1", ">=0.1, <0.1.1"), + ("0.0.1", ">=0.0.1, <0.0.2"), + ("0.0.1.1", ">=0.0.1.1, <0.0.2.0"), + ("0.0.0.1", ">=0.0.0.1, <0.0.0.2"), + ("1", ">=1, <1.1"), + ("1.0.0", ">=1.0.0, <1.1.0"), + ("1.2", ">=1.2, <1.3"), + ("1.2.3", ">=1.2.3, <1.3.0"), + ("1.2.3.4", ">=1.2.3.4, <1.3.0.0"), + ("1.2.3.4a1.post1", ">=1.2.3.4a1.post1, <1.3.0.0"), + ]; + + for (version, expected) in tests { + let actual = AddBoundsKind::Minor + .specifiers(Version::from_str(version).unwrap()) + .to_string(); + assert_eq!(actual, expected, "{version}"); + } + } } diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index fbc4912ba..e98e4f31d 100644 --- a/crates/uv/Cargo.toml +++ b/crates/uv/Cargo.toml @@ -58,7 +58,7 @@ uv-types = { workspace = true } uv-version = { workspace = true } uv-virtualenv = { workspace = true } uv-warnings = { workspace = true } -uv-workspace = { workspace = true } +uv-workspace = { workspace = true, features = ["clap"] } anstream = { workspace = true } anyhow = { workspace = true } diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 2a2cbb5e6..7e50ac76f 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -41,7 +41,7 @@ use uv_settings::PythonInstallMirrors; use uv_types::{BuildIsolation, HashStrategy}; use uv_warnings::warn_user_once; use uv_workspace::pyproject::{DependencyType, Source, SourceError, Sources, ToolUvSources}; -use uv_workspace::pyproject_mut::{ArrayEdit, DependencyTarget, PyProjectTomlMut}; +use uv_workspace::pyproject_mut::{AddBoundsKind, ArrayEdit, DependencyTarget, PyProjectTomlMut}; use uv_workspace::{DiscoveryOptions, VirtualProject, Workspace, WorkspaceCache}; use crate::commands::pip::loggers::{ @@ -74,6 +74,7 @@ pub(crate) async fn add( editable: Option, dependency_type: DependencyType, raw: bool, + bounds: Option, indexes: Vec, rev: Option, tag: Option, @@ -94,6 +95,10 @@ pub(crate) async fn add( printer: Printer, preview: PreviewMode, ) -> Result { + if bounds.is_some() && preview.is_disabled() { + warn_user_once!("The bounds option is in preview and may change in any future release."); + } + for source in &requirements { match source { RequirementsSource::PyprojectToml(_) => { @@ -533,6 +538,7 @@ pub(crate) async fn add( locked, &dependency_type, raw, + bounds, constraints, &settings, &network_settings, @@ -759,6 +765,7 @@ async fn lock_and_sync( locked: bool, dependency_type: &DependencyType, raw: bool, + bound_kind: Option, constraints: Vec, settings: &ResolverInstallerSettings, network_settings: &NetworkSettings, @@ -834,6 +841,15 @@ async fn lock_and_sync( None => true, }; if !is_empty { + if let Some(bound_kind) = bound_kind { + writeln!( + printer.stderr(), + "{} Using explicit requirement `{}` over bounds preference `{}`", + "note:".bold(), + edit.requirement, + bound_kind + )?; + } continue; } @@ -846,7 +862,12 @@ async fn lock_and_sync( // For example, convert `1.2.3+local` to `1.2.3`. let minimum = (*minimum).clone().without_local(); - toml.set_dependency_minimum_version(&edit.dependency_type, *index, minimum)?; + toml.set_dependency_bound( + &edit.dependency_type, + *index, + minimum, + bound_kind.unwrap_or_default(), + )?; modified = true; } diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 7cc125c81..b7a43a1ad 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1907,6 +1907,7 @@ async fn run_project( args.editable, args.dependency_type, args.raw, + args.bounds, args.indexes, args.rev, args.tag, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 007fdc872..1e58536fa 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -45,6 +45,7 @@ use uv_static::EnvVars; use uv_torch::TorchMode; use uv_warnings::warn_user_once; use uv_workspace::pyproject::DependencyType; +use uv_workspace::pyproject_mut::AddBoundsKind; use crate::commands::ToolRunCommand; use crate::commands::{InitKind, InitProjectKind, pip::operations::Modifications}; @@ -1261,6 +1262,7 @@ pub(crate) struct AddSettings { pub(crate) editable: Option, pub(crate) extras: Vec, pub(crate) raw: bool, + pub(crate) bounds: Option, pub(crate) rev: Option, pub(crate) tag: Option, pub(crate) branch: Option, @@ -1289,6 +1291,7 @@ impl AddSettings { no_editable, extra, raw, + bounds, rev, tag, branch, @@ -1370,10 +1373,12 @@ impl AddSettings { } let install_mirrors = filesystem - .clone() + .as_ref() .map(|fs| fs.install_mirrors.clone()) .unwrap_or_default(); + let bounds = bounds.or(filesystem.as_ref().and_then(|fs| fs.add.add_bounds)); + Self { locked, frozen, @@ -1388,6 +1393,7 @@ impl AddSettings { marker, dependency_type, raw, + bounds, rev, tag, branch, diff --git a/crates/uv/tests/it/edit.rs b/crates/uv/tests/it/edit.rs index 28ae744dc..5f0f2d260 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -11841,3 +11841,232 @@ fn add_optional_normalize() -> Result<()> { Ok(()) } + +/// Test `uv add` with different kinds of bounds and constraints. +#[test] +fn add_bounds() -> Result<()> { + let context = TestContext::new("3.12"); + + // Set bounds in `uv.toml` + let uv_toml = context.temp_dir.child("uv.toml"); + uv_toml.write_str(indoc! {r#" + add-bounds = "exact" + "#})?; + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + "#})?; + + uv_snapshot!(context.filters(), context.add().arg("idna"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: The bounds option is in preview and may change in any future release. + Resolved 2 packages in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + idna==3.6 + "); + + let pyproject_toml = context.read("pyproject.toml"); + assert_snapshot!( + pyproject_toml, @r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "idna==3.6", + ] + "# + ); + + fs_err::remove_file(uv_toml)?; + + // Set bounds in `pyproject.toml` + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + + [tool.uv] + add-bounds = "major" + "#})?; + + uv_snapshot!(context.filters(), context.add().arg("anyio"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: The bounds option is in preview and may change in any future release. + Resolved 4 packages in [TIME] + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + anyio==4.3.0 + + sniffio==1.3.1 + "); + + let pyproject_toml = context.read("pyproject.toml"); + assert_snapshot!( + pyproject_toml, @r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "anyio>=4.3.0,<5.0.0", + ] + + [tool.uv] + add-bounds = "major" + "# + ); + + // Existing constraints take precedence over the bounds option + uv_snapshot!(context.filters(), context.add().arg("anyio").arg("--bounds").arg("minor").arg("--preview"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + Audited 3 packages in [TIME] + "); + + let pyproject_toml = context.read("pyproject.toml"); + assert_snapshot!( + pyproject_toml, @r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "anyio>=4.3.0,<5.0.0", + ] + + [tool.uv] + add-bounds = "major" + "# + ); + + // Explicit constraints take precedence over the bounds option + uv_snapshot!(context.filters(), context.add().arg("anyio==4.2").arg("idna").arg("--bounds").arg("minor").arg("--preview"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + Prepared 1 package in [TIME] + Uninstalled 1 package in [TIME] + Installed 1 package in [TIME] + - anyio==4.3.0 + + anyio==4.2.0 + "); + + let pyproject_toml = context.read("pyproject.toml"); + assert_snapshot!( + pyproject_toml, @r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "anyio==4.2", + "idna>=3.6,<3.7", + ] + + [tool.uv] + add-bounds = "major" + "# + ); + + // Set bounds on the CLI and use `--preview` to silence the warning. + uv_snapshot!(context.filters(), context.add().arg("sniffio").arg("--bounds").arg("minor").arg("--preview"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + Audited 3 packages in [TIME] + "); + + let pyproject_toml = context.read("pyproject.toml"); + assert_snapshot!( + pyproject_toml, @r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "anyio==4.2", + "idna>=3.6,<3.7", + "sniffio>=1.3.1,<1.4.0", + ] + + [tool.uv] + add-bounds = "major" + "# + ); + + Ok(()) +} + +/// Hint that we're using an explicit bound over the preferred bounds. +#[test] +fn add_bounds_requirement_over_bounds_kind() -> Result<()> { + let context = TestContext::new("3.12"); + + // Set bounds in `uv.toml` + let uv_toml = context.temp_dir.child("uv.toml"); + uv_toml.write_str(indoc! {r#" + add-bounds = "exact" + "#})?; + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + "#})?; + + uv_snapshot!(context.filters(), context.add().arg("anyio==4.2").arg("idna").arg("--bounds").arg("minor").arg("--preview"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + note: Using explicit requirement `anyio==4.2` over bounds preference `minor` + Prepared 3 packages in [TIME] + Installed 3 packages in [TIME] + + anyio==4.2.0 + + idna==3.6 + + sniffio==1.3.1 + "); + + let pyproject_toml = context.read("pyproject.toml"); + assert_snapshot!( + pyproject_toml, @r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "anyio==4.2", + "idna>=3.6,<3.7", + ] + "# + ); + + Ok(()) +} diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index a2e6f3434..87453090c 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -3976,7 +3976,7 @@ fn resolve_config_file() -> anyhow::Result<()> { .arg("--show-settings") .arg("--config-file") .arg(config.path()) - .arg("requirements.in"), @r###" + .arg("requirements.in"), @r" success: false exit_code: 2 ----- stdout ----- @@ -3987,8 +3987,8 @@ fn resolve_config_file() -> anyhow::Result<()> { | 1 | [project] | ^^^^^^^ - unknown field `project`, expected one of `required-version`, `native-tls`, `offline`, `no-cache`, `cache-dir`, `preview`, `python-preference`, `python-downloads`, `concurrent-downloads`, `concurrent-builds`, `concurrent-installs`, `index`, `index-url`, `extra-index-url`, `no-index`, `find-links`, `index-strategy`, `keyring-provider`, `allow-insecure-host`, `resolution`, `prerelease`, `fork-strategy`, `dependency-metadata`, `config-settings`, `no-build-isolation`, `no-build-isolation-package`, `exclude-newer`, `link-mode`, `compile-bytecode`, `no-sources`, `upgrade`, `upgrade-package`, `reinstall`, `reinstall-package`, `no-build`, `no-build-package`, `no-binary`, `no-binary-package`, `python-install-mirror`, `pypy-install-mirror`, `python-downloads-json-url`, `publish-url`, `trusted-publishing`, `check-url`, `pip`, `cache-keys`, `override-dependencies`, `constraint-dependencies`, `build-constraint-dependencies`, `environments`, `required-environments`, `conflicts`, `workspace`, `sources`, `managed`, `package`, `default-groups`, `dev-dependencies`, `build-backend` - "### + unknown field `project`, expected one of `required-version`, `native-tls`, `offline`, `no-cache`, `cache-dir`, `preview`, `python-preference`, `python-downloads`, `concurrent-downloads`, `concurrent-builds`, `concurrent-installs`, `index`, `index-url`, `extra-index-url`, `no-index`, `find-links`, `index-strategy`, `keyring-provider`, `allow-insecure-host`, `resolution`, `prerelease`, `fork-strategy`, `dependency-metadata`, `config-settings`, `no-build-isolation`, `no-build-isolation-package`, `exclude-newer`, `link-mode`, `compile-bytecode`, `no-sources`, `upgrade`, `upgrade-package`, `reinstall`, `reinstall-package`, `no-build`, `no-build-package`, `no-binary`, `no-binary-package`, `python-install-mirror`, `pypy-install-mirror`, `python-downloads-json-url`, `publish-url`, `trusted-publishing`, `check-url`, `add-bounds`, `pip`, `cache-keys`, `override-dependencies`, `constraint-dependencies`, `build-constraint-dependencies`, `environments`, `required-environments`, `conflicts`, `workspace`, `sources`, `managed`, `package`, `default-groups`, `dev-dependencies`, `build-backend` + " ); // Write an _actual_ `pyproject.toml`. diff --git a/docs/concepts/projects/dependencies.md b/docs/concepts/projects/dependencies.md index cb69da5cb..2c68f92a4 100644 --- a/docs/concepts/projects/dependencies.md +++ b/docs/concepts/projects/dependencies.md @@ -41,7 +41,8 @@ The [`--dev`](#development-dependencies), [`--group`](#dependency-groups), or field. The dependency will include a constraint, e.g., `>=0.27.2`, for the most recent, compatible version -of the package. An alternative constraint can be provided: +of the package. The kind of bound can be adjusted with +[`--bounds`](../../reference/settings.md#bounds), or the constraint can be provided directly: ```console $ uv add "httpx>=0.20" diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 65fcf9fd0..de9b42238 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -389,8 +389,6 @@ Dependencies are added to the project's `pyproject.toml` file. If a given dependency exists already, it will be updated to the new version specifier unless it includes markers that differ from the existing specifier in which case another entry for the dependency will be added. -If no constraint or URL is provided for a dependency, a lower bound is added equal to the latest compatible version of the package, e.g., `>=1.2.3`, unless `--frozen` is provided, in which case no resolution is performed. - The lockfile and project environment will be updated to reflect the added dependencies. To skip updating the lockfile, use `--frozen`. To skip updating the environment, use `--no-sync`. If any of the requested dependencies cannot be found, uv will exit with an error, unless the `--frozen` flag is provided, in which case uv will add the dependencies verbatim without checking that they exist or are compatible with the project. @@ -416,7 +414,17 @@ uv add [OPTIONS] >

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.

--branch branch

Branch to use when adding a dependency from Git

+

May also be set with the UV_INSECURE_HOST environment variable.

--bounds bounds

The kind of version specifier to use when adding dependencies.

+

When adding a dependency to the project, if no constraint or URL is provided, a constraint is added based on the latest compatible version of the package. By default, a lower bound constraint is used, e.g., >=1.2.3.

+

When --frozen is provided, no resolution is performed, and dependencies are always added without constraints.

+

This option is in preview and may change in any future release.

+

Possible values:

+
    +
  • lower: Only a lower bound, e.g., >=1.2.3
  • +
  • major: Allow the same major version, similar to the semver caret, e.g., >=1.2.3, <2.0.0
  • +
  • minor: Allow the same minor version, similar to the semver tilde, e.g., >=1.2.3, <1.3.0
  • +
  • exact: Pin the exact version, e.g., ==1.2.3
  • +
--branch branch

Branch to use when adding a dependency from Git

--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.

diff --git a/docs/reference/settings.md b/docs/reference/settings.md index 989d7cd69..5de24e84a 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -592,6 +592,44 @@ members = ["member1", "path/to/member2", "libs/*"] --- ## Configuration +### [`add-bounds`](#add-bounds) {: #add-bounds } + +The default version specifier when adding a dependency. + +When adding a dependency to the project, if no constraint or URL is provided, a constraint +is added based on the latest compatible version of the package. By default, a lower bound +constraint is used, e.g., `>=1.2.3`. + +When `--frozen` is provided, no resolution is performed, and dependencies are always added +without constraints. + +This option is in preview and may change in any future release. + +**Default value**: `"lower"` + +**Possible values**: + +- `"lower"`: Only a lower bound, e.g., `>=1.2.3` +- `"major"`: Allow the same major version, similar to the semver caret, e.g., `>=1.2.3, <2.0.0` +- `"minor"`: Allow the same minor version, similar to the semver tilde, e.g., `>=1.2.3, <1.3.0` +- `"exact"`: Pin the exact version, e.g., `==1.2.3` + +**Example usage**: + +=== "pyproject.toml" + + ```toml + [tool.uv] + add-bounds = "major" + ``` +=== "uv.toml" + + ```toml + add-bounds = "major" + ``` + +--- + ### [`allow-insecure-host`](#allow-insecure-host) {: #allow-insecure-host } Allow insecure connections to host. diff --git a/uv.schema.json b/uv.schema.json index 8ee9a6fc5..fff7d8a47 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -4,6 +4,17 @@ "description": "Metadata and configuration for uv.", "type": "object", "properties": { + "add-bounds": { + "description": "The default version specifier when adding a dependency.\n\nWhen adding a dependency to the project, if no constraint or URL is provided, a constraint is added based on the latest compatible version of the package. By default, a lower bound constraint is used, e.g., `>=1.2.3`.\n\nWhen `--frozen` is provided, no resolution is performed, and dependencies are always added without constraints.\n\nThis option is in preview and may change in any future release.", + "anyOf": [ + { + "$ref": "#/definitions/AddBoundsKind" + }, + { + "type": "null" + } + ] + }, "allow-insecure-host": { "description": "Allow insecure connections to host.\n\nExpects to receive either a hostname (e.g., `localhost`), a host-port pair (e.g., `localhost:8080`), or a URL (e.g., `https://localhost`).\n\nWARNING: 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.", "type": [ @@ -557,6 +568,39 @@ } }, "definitions": { + "AddBoundsKind": { + "description": "The default version specifier when adding a dependency.", + "oneOf": [ + { + "description": "Only a lower bound, e.g., `>=1.2.3`.", + "type": "string", + "enum": [ + "lower" + ] + }, + { + "description": "Allow the same major version, similar to the semver caret, e.g., `>=1.2.3, <2.0.0`.\n\nLeading zeroes are skipped, e.g. `>=0.1.2, <0.2.0`.", + "type": "string", + "enum": [ + "major" + ] + }, + { + "description": "Allow the same minor version, similar to the semver tilde, e.g., `>=1.2.3, <1.3.0`.\n\nLeading zeroes are skipped, e.g. `>=0.1.2, <0.1.3`.", + "type": "string", + "enum": [ + "minor" + ] + }, + { + "description": "Pin the exact version, e.g., `==1.2.3`.\n\nThis option is not recommended, as versions are already pinned in the uv lockfile.", + "type": "string", + "enum": [ + "exact" + ] + } + ] + }, "AnnotationStyle": { "description": "Indicate the style of annotation comments, used to indicate the dependencies that requested each package.", "oneOf": [