diff --git a/Cargo.lock b/Cargo.lock index dd33b7672..18d0531a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2457,6 +2457,7 @@ dependencies = [ "pyo3", "pyo3-log", "regex", + "schemars", "serde", "serde_json", "testing_logger", @@ -4598,6 +4599,8 @@ dependencies = [ "fs-err", "insta", "install-wheel-rs", + "pep508_rs", + "pypi-types", "serde", "url", "uv-cache", diff --git a/crates/pep508-rs/Cargo.toml b/crates/pep508-rs/Cargo.toml index 9b9b18ea7..8718af923 100644 --- a/crates/pep508-rs/Cargo.toml +++ b/crates/pep508-rs/Cargo.toml @@ -26,6 +26,7 @@ pep440_rs = { workspace = true } pyo3 = { workspace = true, optional = true, features = ["abi3", "extension-module"] } pyo3-log = { workspace = true, optional = true } regex = { workspace = true } +schemars = { workspace = true, optional = true } serde = { workspace = true, features = ["derive", "rc"] } serde_json = { workspace = true, optional = true } thiserror = { workspace = true } @@ -44,6 +45,7 @@ testing_logger = { version = "0.1.1" } [features] pyo3 = ["dep:pyo3", "pep440_rs/pyo3", "pyo3-log", "tracing", "tracing/log"] tracing = ["dep:tracing", "pep440_rs/tracing"] +schemars = ["dep:schemars"] # PEP 508 allows only URLs such as `foo @ https://example.org/foo` or `foo @ file:///home/ferris/foo`, and # arguably does not allow relative paths in file URLs (`foo @ file://./foo`, # `foo @ file:foo-3.0.0-py3-none-any.whl`, `foo @ file://foo-3.0.0-py3-none-any.whl`), as they are not part of the diff --git a/crates/pep508-rs/src/lib.rs b/crates/pep508-rs/src/lib.rs index ffdc95f72..3e27464c0 100644 --- a/crates/pep508-rs/src/lib.rs +++ b/crates/pep508-rs/src/lib.rs @@ -33,6 +33,8 @@ use pyo3::{ create_exception, exceptions::PyNotImplementedError, pyclass, pyclass::CompareOp, pymethods, pymodule, types::PyModule, IntoPy, PyObject, PyResult, Python, }; +use schemars::gen::SchemaGenerator; +use schemars::schema::Schema; use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; use thiserror::Error; use url::Url; @@ -489,6 +491,25 @@ impl Reporter for TracingReporter { } } +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for Requirement { + fn schema_name() -> String { + "Requirement".to_string() + } + + fn json_schema(_gen: &mut SchemaGenerator) -> Schema { + schemars::schema::SchemaObject { + instance_type: Some(schemars::schema::InstanceType::String.into()), + metadata: Some(Box::new(schemars::schema::Metadata { + description: Some("A PEP 508 dependency specifier".to_string()), + ..schemars::schema::Metadata::default() + })), + ..schemars::schema::SchemaObject::default() + } + .into() + } +} + impl FromStr for Requirement { type Err = Pep508Error; diff --git a/crates/uv-cli/Cargo.toml b/crates/uv-cli/Cargo.toml index 0083cacc8..063490dd1 100644 --- a/crates/uv-cli/Cargo.toml +++ b/crates/uv-cli/Cargo.toml @@ -16,12 +16,14 @@ workspace = true [dependencies] distribution-types = { workspace = true } install-wheel-rs = { workspace = true, features = ["clap"], default-features = false } +pep508_rs = { workspace = true } +pypi-types = { workspace = true } uv-cache = { workspace = true, features = ["clap"] } uv-configuration = { workspace = true, features = ["clap"] } uv-normalize = { workspace = true } +uv-python = { workspace = true, features = ["clap", "schemars"]} uv-resolver = { workspace = true, features = ["clap"] } uv-settings = { workspace = true, features = ["schemars"] } -uv-python = { workspace = true, features = ["clap", "schemars"]} uv-version = { workspace = true } uv-warnings = { workspace = true } diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index f94d3af33..6ae5c0dfe 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -7,6 +7,8 @@ use anyhow::{anyhow, Result}; use clap::{Args, Parser, Subcommand}; use distribution_types::{FlatIndexLocation, IndexUrl}; +use pep508_rs::Requirement; +use pypi_types::VerbatimParsedUrl; use uv_cache::CacheArgs; use uv_configuration::{ ConfigSettingEntry, IndexStrategy, KeyringProviderType, PackageNameSpecifier, TargetTriple, @@ -300,7 +302,7 @@ pub enum PipCommand { after_help = "Use `uv help pip sync` for more details.", after_long_help = "" )] - Sync(PipSyncArgs), + Sync(Box), /// Install packages into an environment. #[command( after_help = "Use `uv help pip install` for more details.", @@ -2408,7 +2410,7 @@ pub struct ResolverArgs { /// Allow upgrades for a specific package, ignoring pinned versions in any existing output /// file. #[arg(long, short = 'P')] - pub upgrade_package: Vec, + pub upgrade_package: Vec>, /// The strategy to use when resolving against multiple index URLs. /// @@ -2484,7 +2486,7 @@ pub struct ResolverInstallerArgs { /// Allow upgrades for a specific package, ignoring pinned versions in any existing output /// file. #[arg(long, short = 'P')] - pub upgrade_package: Vec, + pub upgrade_package: Vec>, /// Reinstall all packages, regardless of whether they're already installed. #[arg(long, alias = "force-reinstall", overrides_with("no_reinstall"))] diff --git a/crates/uv-configuration/Cargo.toml b/crates/uv-configuration/Cargo.toml index e1e3bdc6c..894237547 100644 --- a/crates/uv-configuration/Cargo.toml +++ b/crates/uv-configuration/Cargo.toml @@ -13,7 +13,7 @@ license = { workspace = true } workspace = true [dependencies] -pep508_rs = { workspace = true } +pep508_rs = { workspace = true, features = ["schemars"] } platform-tags = { workspace = true } pypi-types = { workspace = true } uv-auth = { workspace = true } diff --git a/crates/uv-configuration/src/constraints.rs b/crates/uv-configuration/src/constraints.rs index 10150f1ec..4bea7359b 100644 --- a/crates/uv-configuration/src/constraints.rs +++ b/crates/uv-configuration/src/constraints.rs @@ -1,8 +1,10 @@ -use either::Either; -use pep508_rs::MarkerTree; -use pypi_types::Requirement; -use rustc_hash::{FxBuildHasher, FxHashMap}; use std::borrow::Cow; + +use either::Either; +use rustc_hash::FxHashMap; + +use pep508_rs::MarkerTree; +use pypi_types::{Requirement, RequirementSource}; use uv_normalize::PackageName; /// A set of constraints for a set of requirements. @@ -11,10 +13,16 @@ pub struct Constraints(FxHashMap>); impl Constraints { /// Create a new set of constraints from a set of requirements. - pub fn from_requirements(requirements: Vec) -> Self { - let mut constraints: FxHashMap> = - FxHashMap::with_capacity_and_hasher(requirements.len(), FxBuildHasher); + pub fn from_requirements(requirements: impl Iterator) -> Self { + let mut constraints: FxHashMap> = FxHashMap::default(); for requirement in requirements { + // Skip empty constraints. + if let RequirementSource::Registry { specifier, .. } = &requirement.source { + if specifier.is_empty() { + continue; + } + } + constraints .entry(requirement.name.clone()) .or_default() diff --git a/crates/uv-configuration/src/package_options.rs b/crates/uv-configuration/src/package_options.rs index e9b5bd56d..928f03aef 100644 --- a/crates/uv-configuration/src/package_options.rs +++ b/crates/uv-configuration/src/package_options.rs @@ -1,6 +1,8 @@ +use either::Either; use pep508_rs::PackageName; -use rustc_hash::FxHashSet; +use pypi_types::Requirement; +use rustc_hash::FxHashMap; /// Whether to reinstall packages. #[derive(Debug, Default, Clone)] @@ -54,12 +56,12 @@ pub enum Upgrade { All, /// Allow package upgrades, but only for the specified packages. - Packages(FxHashSet), + Packages(FxHashMap>), } impl Upgrade { /// Determine the upgrade strategy from the command-line arguments. - pub fn from_args(upgrade: Option, upgrade_package: Vec) -> Self { + pub fn from_args(upgrade: Option, upgrade_package: Vec) -> Self { match upgrade { Some(true) => Self::All, Some(false) => Self::None, @@ -67,7 +69,15 @@ impl Upgrade { if upgrade_package.is_empty() { Self::None } else { - Self::Packages(upgrade_package.into_iter().collect()) + Self::Packages(upgrade_package.into_iter().fold( + FxHashMap::default(), + |mut map, requirement| { + map.entry(requirement.name.clone()) + .or_default() + .push(requirement); + map + }, + )) } } } @@ -82,4 +92,28 @@ impl Upgrade { pub fn is_all(&self) -> bool { matches!(self, Self::All) } + + /// Returns `true` if the specified package should be upgraded. + pub fn contains(&self, package_name: &PackageName) -> bool { + match &self { + Self::None => false, + Self::All => true, + Self::Packages(packages) => packages.contains_key(package_name), + } + } + + /// Returns an iterator over the constraints. + /// + /// When upgrading, users can provide bounds on the upgrade (e.g., `--upgrade-package flask<3`). + pub fn constraints(&self) -> impl Iterator { + if let Self::Packages(packages) = self { + Either::Right( + packages + .values() + .flat_map(|requirements| requirements.iter()), + ) + } else { + Either::Left(std::iter::empty()) + } + } } diff --git a/crates/uv-requirements/src/upgrade.rs b/crates/uv-requirements/src/upgrade.rs index 5e22b854b..2b1cfc6d5 100644 --- a/crates/uv-requirements/src/upgrade.rs +++ b/crates/uv-requirements/src/upgrade.rs @@ -56,7 +56,7 @@ pub async fn read_requirements_txt( // Ignore pinned versions for the specified packages. Upgrade::Packages(packages) => preferences .into_iter() - .filter(|preference| !packages.contains(preference.name())) + .filter(|preference| !packages.contains_key(preference.name())) .collect(), }) } @@ -68,11 +68,7 @@ pub fn read_lock_requirements(lock: &Lock, upgrade: &Upgrade) -> LockedRequireme for dist in lock.distributions() { // Skip the distribution if it's not included in the upgrade strategy. - if match upgrade { - Upgrade::None => false, - Upgrade::All => true, - Upgrade::Packages(packages) => packages.contains(dist.name()), - } { + if upgrade.contains(dist.name()) { continue; } diff --git a/crates/uv-resolver/src/exclusions.rs b/crates/uv-resolver/src/exclusions.rs index 34593af9d..59a4864b2 100644 --- a/crates/uv-resolver/src/exclusions.rs +++ b/crates/uv-resolver/src/exclusions.rs @@ -26,7 +26,7 @@ impl Exclusions { }; if let Upgrade::Packages(packages) = upgrade { - exclusions.extend(packages); + exclusions.extend(packages.into_keys()); }; if exclusions.is_empty() { diff --git a/crates/uv-settings/src/settings.rs b/crates/uv-settings/src/settings.rs index 83212c92f..e7a9e8e7b 100644 --- a/crates/uv-settings/src/settings.rs +++ b/crates/uv-settings/src/settings.rs @@ -4,6 +4,7 @@ use serde::Deserialize; use distribution_types::{FlatIndexLocation, IndexUrl}; use install_wheel_rs::linker::LinkMode; +use pep508_rs::Requirement; use pypi_types::VerbatimParsedUrl; use uv_configuration::{ ConfigSettings, IndexStrategy, KeyringProviderType, PackageNameSpecifier, TargetTriple, @@ -105,7 +106,7 @@ pub struct ResolverOptions { pub exclude_newer: Option, pub link_mode: Option, pub upgrade: Option, - pub upgrade_package: Option>, + pub upgrade_package: Option>>, pub no_build: Option, pub no_build_package: Option>, pub no_binary: Option, @@ -132,7 +133,7 @@ pub struct ResolverInstallerOptions { pub link_mode: Option, pub compile_bytecode: Option, pub upgrade: Option, - pub upgrade_package: Option>, + pub upgrade_package: Option>>, pub reinstall: Option, pub reinstall_package: Option>, pub no_build: Option, @@ -193,7 +194,7 @@ pub struct PipOptions { pub compile_bytecode: Option, pub require_hashes: Option, pub upgrade: Option, - pub upgrade_package: Option>, + pub upgrade_package: Option>>, pub reinstall: Option, pub reinstall_package: Option>, pub concurrent_downloads: Option, diff --git a/crates/uv/src/commands/pip/operations.rs b/crates/uv/src/commands/pip/operations.rs index 0fd4cff54..29bb58548 100644 --- a/crates/uv/src/commands/pip/operations.rs +++ b/crates/uv/src/commands/pip/operations.rs @@ -180,7 +180,11 @@ pub(crate) async fn resolve( .await?; // Collect constraints and overrides. - let constraints = Constraints::from_requirements(constraints); + let constraints = Constraints::from_requirements( + constraints + .into_iter() + .chain(upgrade.constraints().cloned()), + ); let overrides = Overrides::from_requirements(overrides); let preferences = Preferences::from_iter(preferences, markers); diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index fc896f108..215809156 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -798,7 +798,7 @@ pub(crate) struct PipSyncSettings { impl PipSyncSettings { /// Resolve the [`PipSyncSettings`] from the CLI and filesystem configuration. - pub(crate) fn resolve(args: PipSyncArgs, filesystem: Option) -> Self { + pub(crate) fn resolve(args: Box, filesystem: Option) -> Self { let PipSyncArgs { src_file, constraint, @@ -829,7 +829,7 @@ impl PipSyncSettings { no_strict, dry_run, compat_args: _, - } = args; + } = *args; Self { src_file, @@ -1384,7 +1384,10 @@ impl ResolverSettings { args.upgrade.combine(upgrade), args.upgrade_package .combine(upgrade_package) - .unwrap_or_default(), + .into_iter() + .flatten() + .map(Requirement::from) + .collect(), ), build_options: BuildOptions::new( NoBinary::from_args( @@ -1522,7 +1525,10 @@ impl ResolverInstallerSettings { args.upgrade.combine(upgrade), args.upgrade_package .combine(upgrade_package) - .unwrap_or_default(), + .into_iter() + .flatten() + .map(Requirement::from) + .collect(), ), reinstall: Reinstall::from_args( args.reinstall.combine(reinstall), @@ -1841,7 +1847,10 @@ impl PipSettings { args.upgrade.combine(upgrade), args.upgrade_package .combine(upgrade_package) - .unwrap_or_default(), + .into_iter() + .flatten() + .map(Requirement::from) + .collect(), ), reinstall: Reinstall::from_args( args.reinstall.combine(reinstall), diff --git a/crates/uv/tests/pip_compile.rs b/crates/uv/tests/pip_compile.rs index c55dadd8b..693d27fa4 100644 --- a/crates/uv/tests/pip_compile.rs +++ b/crates/uv/tests/pip_compile.rs @@ -4770,6 +4770,61 @@ fn upgrade_package() -> Result<()> { Ok(()) } +/// Upgrade a package with a constraint on the allowed upgrade. +#[test] +fn upgrade_constraint() -> Result<()> { + let context = TestContext::new("3.12"); + let requirements_in = context.temp_dir.child("requirements.in"); + requirements_in.write_str("iniconfig")?; + + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str(indoc! {r" + # This file was autogenerated by uv via the following command: + # uv pip compile requirements.in --python-version 3.12 --cache-dir [CACHE_DIR] + iniconfig==1.0.0 + "})?; + + uv_snapshot!(context.pip_compile() + .arg("requirements.in") + .arg("--output-file") + .arg("requirements.txt") + .arg("--upgrade-package") + .arg("iniconfig<2"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv pip compile --cache-dir [CACHE_DIR] requirements.in --output-file requirements.txt --upgrade-package iniconfig<2 + iniconfig==1.1.1 + # via -r requirements.in + + ----- stderr ----- + Resolved 1 package in [TIME] + "### + ); + + uv_snapshot!(context.pip_compile() + .arg("requirements.in") + .arg("--output-file") + .arg("requirements.txt") + .arg("--upgrade-package") + .arg("iniconfig"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv pip compile --cache-dir [CACHE_DIR] requirements.in --output-file requirements.txt --upgrade-package iniconfig + iniconfig==2.0.0 + # via -r requirements.in + + ----- stderr ----- + Resolved 1 package in [TIME] + "### + ); + + Ok(()) +} + /// Attempt to resolve a requirement at a path that doesn't exist. #[test] fn missing_path_requirement() -> Result<()> { diff --git a/uv.schema.json b/uv.schema.json index 61e7b2d7b..2f1f309a2 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -267,7 +267,7 @@ "null" ], "items": { - "$ref": "#/definitions/PackageName" + "$ref": "#/definitions/Requirement" } }, "workspace": { @@ -826,7 +826,7 @@ "null" ], "items": { - "$ref": "#/definitions/PackageName" + "$ref": "#/definitions/Requirement" } } }, @@ -933,6 +933,10 @@ "type": "string", "pattern": "^3\\.\\d+(\\.\\d+)?$" }, + "Requirement": { + "description": "A PEP 508 dependency specifier", + "type": "string" + }, "ResolutionMode": { "oneOf": [ {