Allow constraints to be provided in --upgrade-package (#4952)

## Summary

Allows, e.g., `--upgrade-package flask<3.0.0`.

Closes https://github.com/astral-sh/uv/issues/1964.
This commit is contained in:
Charlie Marsh 2024-07-09 20:09:13 -07:00 committed by GitHub
parent 5b6dffe522
commit 23eb42deed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 175 additions and 34 deletions

3
Cargo.lock generated
View file

@ -2457,6 +2457,7 @@ dependencies = [
"pyo3", "pyo3",
"pyo3-log", "pyo3-log",
"regex", "regex",
"schemars",
"serde", "serde",
"serde_json", "serde_json",
"testing_logger", "testing_logger",
@ -4598,6 +4599,8 @@ dependencies = [
"fs-err", "fs-err",
"insta", "insta",
"install-wheel-rs", "install-wheel-rs",
"pep508_rs",
"pypi-types",
"serde", "serde",
"url", "url",
"uv-cache", "uv-cache",

View file

@ -26,6 +26,7 @@ pep440_rs = { workspace = true }
pyo3 = { workspace = true, optional = true, features = ["abi3", "extension-module"] } pyo3 = { workspace = true, optional = true, features = ["abi3", "extension-module"] }
pyo3-log = { workspace = true, optional = true } pyo3-log = { workspace = true, optional = true }
regex = { workspace = true } regex = { workspace = true }
schemars = { workspace = true, optional = true }
serde = { workspace = true, features = ["derive", "rc"] } serde = { workspace = true, features = ["derive", "rc"] }
serde_json = { workspace = true, optional = true } serde_json = { workspace = true, optional = true }
thiserror = { workspace = true } thiserror = { workspace = true }
@ -44,6 +45,7 @@ testing_logger = { version = "0.1.1" }
[features] [features]
pyo3 = ["dep:pyo3", "pep440_rs/pyo3", "pyo3-log", "tracing", "tracing/log"] pyo3 = ["dep:pyo3", "pep440_rs/pyo3", "pyo3-log", "tracing", "tracing/log"]
tracing = ["dep:tracing", "pep440_rs/tracing"] 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 # 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`, # 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 # `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

View file

@ -33,6 +33,8 @@ use pyo3::{
create_exception, exceptions::PyNotImplementedError, pyclass, pyclass::CompareOp, pymethods, create_exception, exceptions::PyNotImplementedError, pyclass, pyclass::CompareOp, pymethods,
pymodule, types::PyModule, IntoPy, PyObject, PyResult, Python, pymodule, types::PyModule, IntoPy, PyObject, PyResult, Python,
}; };
use schemars::gen::SchemaGenerator;
use schemars::schema::Schema;
use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
use thiserror::Error; use thiserror::Error;
use url::Url; use url::Url;
@ -489,6 +491,25 @@ impl Reporter for TracingReporter {
} }
} }
#[cfg(feature = "schemars")]
impl<T: Pep508Url> schemars::JsonSchema for Requirement<T> {
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<T: Pep508Url> FromStr for Requirement<T> { impl<T: Pep508Url> FromStr for Requirement<T> {
type Err = Pep508Error<T>; type Err = Pep508Error<T>;

View file

@ -16,12 +16,14 @@ workspace = true
[dependencies] [dependencies]
distribution-types = { workspace = true } distribution-types = { workspace = true }
install-wheel-rs = { workspace = true, features = ["clap"], default-features = false } 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-cache = { workspace = true, features = ["clap"] }
uv-configuration = { workspace = true, features = ["clap"] } uv-configuration = { workspace = true, features = ["clap"] }
uv-normalize = { workspace = true } uv-normalize = { workspace = true }
uv-python = { workspace = true, features = ["clap", "schemars"]}
uv-resolver = { workspace = true, features = ["clap"] } uv-resolver = { workspace = true, features = ["clap"] }
uv-settings = { workspace = true, features = ["schemars"] } uv-settings = { workspace = true, features = ["schemars"] }
uv-python = { workspace = true, features = ["clap", "schemars"]}
uv-version = { workspace = true } uv-version = { workspace = true }
uv-warnings = { workspace = true } uv-warnings = { workspace = true }

View file

@ -7,6 +7,8 @@ use anyhow::{anyhow, Result};
use clap::{Args, Parser, Subcommand}; use clap::{Args, Parser, Subcommand};
use distribution_types::{FlatIndexLocation, IndexUrl}; use distribution_types::{FlatIndexLocation, IndexUrl};
use pep508_rs::Requirement;
use pypi_types::VerbatimParsedUrl;
use uv_cache::CacheArgs; use uv_cache::CacheArgs;
use uv_configuration::{ use uv_configuration::{
ConfigSettingEntry, IndexStrategy, KeyringProviderType, PackageNameSpecifier, TargetTriple, ConfigSettingEntry, IndexStrategy, KeyringProviderType, PackageNameSpecifier, TargetTriple,
@ -300,7 +302,7 @@ pub enum PipCommand {
after_help = "Use `uv help pip sync` for more details.", after_help = "Use `uv help pip sync` for more details.",
after_long_help = "" after_long_help = ""
)] )]
Sync(PipSyncArgs), Sync(Box<PipSyncArgs>),
/// Install packages into an environment. /// Install packages into an environment.
#[command( #[command(
after_help = "Use `uv help pip install` for more details.", 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 /// Allow upgrades for a specific package, ignoring pinned versions in any existing output
/// file. /// file.
#[arg(long, short = 'P')] #[arg(long, short = 'P')]
pub upgrade_package: Vec<PackageName>, pub upgrade_package: Vec<Requirement<VerbatimParsedUrl>>,
/// The strategy to use when resolving against multiple index URLs. /// 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 /// Allow upgrades for a specific package, ignoring pinned versions in any existing output
/// file. /// file.
#[arg(long, short = 'P')] #[arg(long, short = 'P')]
pub upgrade_package: Vec<PackageName>, pub upgrade_package: Vec<Requirement<VerbatimParsedUrl>>,
/// Reinstall all packages, regardless of whether they're already installed. /// Reinstall all packages, regardless of whether they're already installed.
#[arg(long, alias = "force-reinstall", overrides_with("no_reinstall"))] #[arg(long, alias = "force-reinstall", overrides_with("no_reinstall"))]

View file

@ -13,7 +13,7 @@ license = { workspace = true }
workspace = true workspace = true
[dependencies] [dependencies]
pep508_rs = { workspace = true } pep508_rs = { workspace = true, features = ["schemars"] }
platform-tags = { workspace = true } platform-tags = { workspace = true }
pypi-types = { workspace = true } pypi-types = { workspace = true }
uv-auth = { workspace = true } uv-auth = { workspace = true }

View file

@ -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 std::borrow::Cow;
use either::Either;
use rustc_hash::FxHashMap;
use pep508_rs::MarkerTree;
use pypi_types::{Requirement, RequirementSource};
use uv_normalize::PackageName; use uv_normalize::PackageName;
/// A set of constraints for a set of requirements. /// A set of constraints for a set of requirements.
@ -11,10 +13,16 @@ pub struct Constraints(FxHashMap<PackageName, Vec<Requirement>>);
impl Constraints { impl Constraints {
/// Create a new set of constraints from a set of requirements. /// Create a new set of constraints from a set of requirements.
pub fn from_requirements(requirements: Vec<Requirement>) -> Self { pub fn from_requirements(requirements: impl Iterator<Item = Requirement>) -> Self {
let mut constraints: FxHashMap<PackageName, Vec<Requirement>> = let mut constraints: FxHashMap<PackageName, Vec<Requirement>> = FxHashMap::default();
FxHashMap::with_capacity_and_hasher(requirements.len(), FxBuildHasher);
for requirement in requirements { for requirement in requirements {
// Skip empty constraints.
if let RequirementSource::Registry { specifier, .. } = &requirement.source {
if specifier.is_empty() {
continue;
}
}
constraints constraints
.entry(requirement.name.clone()) .entry(requirement.name.clone())
.or_default() .or_default()

View file

@ -1,6 +1,8 @@
use either::Either;
use pep508_rs::PackageName; use pep508_rs::PackageName;
use rustc_hash::FxHashSet; use pypi_types::Requirement;
use rustc_hash::FxHashMap;
/// Whether to reinstall packages. /// Whether to reinstall packages.
#[derive(Debug, Default, Clone)] #[derive(Debug, Default, Clone)]
@ -54,12 +56,12 @@ pub enum Upgrade {
All, All,
/// Allow package upgrades, but only for the specified packages. /// Allow package upgrades, but only for the specified packages.
Packages(FxHashSet<PackageName>), Packages(FxHashMap<PackageName, Vec<Requirement>>),
} }
impl Upgrade { impl Upgrade {
/// Determine the upgrade strategy from the command-line arguments. /// Determine the upgrade strategy from the command-line arguments.
pub fn from_args(upgrade: Option<bool>, upgrade_package: Vec<PackageName>) -> Self { pub fn from_args(upgrade: Option<bool>, upgrade_package: Vec<Requirement>) -> Self {
match upgrade { match upgrade {
Some(true) => Self::All, Some(true) => Self::All,
Some(false) => Self::None, Some(false) => Self::None,
@ -67,7 +69,15 @@ impl Upgrade {
if upgrade_package.is_empty() { if upgrade_package.is_empty() {
Self::None Self::None
} else { } 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 { pub fn is_all(&self) -> bool {
matches!(self, Self::All) 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<Item = &Requirement> {
if let Self::Packages(packages) = self {
Either::Right(
packages
.values()
.flat_map(|requirements| requirements.iter()),
)
} else {
Either::Left(std::iter::empty())
}
}
} }

View file

@ -56,7 +56,7 @@ pub async fn read_requirements_txt(
// Ignore pinned versions for the specified packages. // Ignore pinned versions for the specified packages.
Upgrade::Packages(packages) => preferences Upgrade::Packages(packages) => preferences
.into_iter() .into_iter()
.filter(|preference| !packages.contains(preference.name())) .filter(|preference| !packages.contains_key(preference.name()))
.collect(), .collect(),
}) })
} }
@ -68,11 +68,7 @@ pub fn read_lock_requirements(lock: &Lock, upgrade: &Upgrade) -> LockedRequireme
for dist in lock.distributions() { for dist in lock.distributions() {
// Skip the distribution if it's not included in the upgrade strategy. // Skip the distribution if it's not included in the upgrade strategy.
if match upgrade { if upgrade.contains(dist.name()) {
Upgrade::None => false,
Upgrade::All => true,
Upgrade::Packages(packages) => packages.contains(dist.name()),
} {
continue; continue;
} }

View file

@ -26,7 +26,7 @@ impl Exclusions {
}; };
if let Upgrade::Packages(packages) = upgrade { if let Upgrade::Packages(packages) = upgrade {
exclusions.extend(packages); exclusions.extend(packages.into_keys());
}; };
if exclusions.is_empty() { if exclusions.is_empty() {

View file

@ -4,6 +4,7 @@ use serde::Deserialize;
use distribution_types::{FlatIndexLocation, IndexUrl}; use distribution_types::{FlatIndexLocation, IndexUrl};
use install_wheel_rs::linker::LinkMode; use install_wheel_rs::linker::LinkMode;
use pep508_rs::Requirement;
use pypi_types::VerbatimParsedUrl; use pypi_types::VerbatimParsedUrl;
use uv_configuration::{ use uv_configuration::{
ConfigSettings, IndexStrategy, KeyringProviderType, PackageNameSpecifier, TargetTriple, ConfigSettings, IndexStrategy, KeyringProviderType, PackageNameSpecifier, TargetTriple,
@ -105,7 +106,7 @@ pub struct ResolverOptions {
pub exclude_newer: Option<ExcludeNewer>, pub exclude_newer: Option<ExcludeNewer>,
pub link_mode: Option<LinkMode>, pub link_mode: Option<LinkMode>,
pub upgrade: Option<bool>, pub upgrade: Option<bool>,
pub upgrade_package: Option<Vec<PackageName>>, pub upgrade_package: Option<Vec<Requirement<VerbatimParsedUrl>>>,
pub no_build: Option<bool>, pub no_build: Option<bool>,
pub no_build_package: Option<Vec<PackageName>>, pub no_build_package: Option<Vec<PackageName>>,
pub no_binary: Option<bool>, pub no_binary: Option<bool>,
@ -132,7 +133,7 @@ pub struct ResolverInstallerOptions {
pub link_mode: Option<LinkMode>, pub link_mode: Option<LinkMode>,
pub compile_bytecode: Option<bool>, pub compile_bytecode: Option<bool>,
pub upgrade: Option<bool>, pub upgrade: Option<bool>,
pub upgrade_package: Option<Vec<PackageName>>, pub upgrade_package: Option<Vec<Requirement<VerbatimParsedUrl>>>,
pub reinstall: Option<bool>, pub reinstall: Option<bool>,
pub reinstall_package: Option<Vec<PackageName>>, pub reinstall_package: Option<Vec<PackageName>>,
pub no_build: Option<bool>, pub no_build: Option<bool>,
@ -193,7 +194,7 @@ pub struct PipOptions {
pub compile_bytecode: Option<bool>, pub compile_bytecode: Option<bool>,
pub require_hashes: Option<bool>, pub require_hashes: Option<bool>,
pub upgrade: Option<bool>, pub upgrade: Option<bool>,
pub upgrade_package: Option<Vec<PackageName>>, pub upgrade_package: Option<Vec<Requirement<VerbatimParsedUrl>>>,
pub reinstall: Option<bool>, pub reinstall: Option<bool>,
pub reinstall_package: Option<Vec<PackageName>>, pub reinstall_package: Option<Vec<PackageName>>,
pub concurrent_downloads: Option<NonZeroUsize>, pub concurrent_downloads: Option<NonZeroUsize>,

View file

@ -180,7 +180,11 @@ pub(crate) async fn resolve<InstalledPackages: InstalledPackagesProvider>(
.await?; .await?;
// Collect constraints and overrides. // 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 overrides = Overrides::from_requirements(overrides);
let preferences = Preferences::from_iter(preferences, markers); let preferences = Preferences::from_iter(preferences, markers);

View file

@ -798,7 +798,7 @@ pub(crate) struct PipSyncSettings {
impl PipSyncSettings { impl PipSyncSettings {
/// Resolve the [`PipSyncSettings`] from the CLI and filesystem configuration. /// Resolve the [`PipSyncSettings`] from the CLI and filesystem configuration.
pub(crate) fn resolve(args: PipSyncArgs, filesystem: Option<FilesystemOptions>) -> Self { pub(crate) fn resolve(args: Box<PipSyncArgs>, filesystem: Option<FilesystemOptions>) -> Self {
let PipSyncArgs { let PipSyncArgs {
src_file, src_file,
constraint, constraint,
@ -829,7 +829,7 @@ impl PipSyncSettings {
no_strict, no_strict,
dry_run, dry_run,
compat_args: _, compat_args: _,
} = args; } = *args;
Self { Self {
src_file, src_file,
@ -1384,7 +1384,10 @@ impl ResolverSettings {
args.upgrade.combine(upgrade), args.upgrade.combine(upgrade),
args.upgrade_package args.upgrade_package
.combine(upgrade_package) .combine(upgrade_package)
.unwrap_or_default(), .into_iter()
.flatten()
.map(Requirement::from)
.collect(),
), ),
build_options: BuildOptions::new( build_options: BuildOptions::new(
NoBinary::from_args( NoBinary::from_args(
@ -1522,7 +1525,10 @@ impl ResolverInstallerSettings {
args.upgrade.combine(upgrade), args.upgrade.combine(upgrade),
args.upgrade_package args.upgrade_package
.combine(upgrade_package) .combine(upgrade_package)
.unwrap_or_default(), .into_iter()
.flatten()
.map(Requirement::from)
.collect(),
), ),
reinstall: Reinstall::from_args( reinstall: Reinstall::from_args(
args.reinstall.combine(reinstall), args.reinstall.combine(reinstall),
@ -1841,7 +1847,10 @@ impl PipSettings {
args.upgrade.combine(upgrade), args.upgrade.combine(upgrade),
args.upgrade_package args.upgrade_package
.combine(upgrade_package) .combine(upgrade_package)
.unwrap_or_default(), .into_iter()
.flatten()
.map(Requirement::from)
.collect(),
), ),
reinstall: Reinstall::from_args( reinstall: Reinstall::from_args(
args.reinstall.combine(reinstall), args.reinstall.combine(reinstall),

View file

@ -4770,6 +4770,61 @@ fn upgrade_package() -> Result<()> {
Ok(()) 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. /// Attempt to resolve a requirement at a path that doesn't exist.
#[test] #[test]
fn missing_path_requirement() -> Result<()> { fn missing_path_requirement() -> Result<()> {

8
uv.schema.json generated
View file

@ -267,7 +267,7 @@
"null" "null"
], ],
"items": { "items": {
"$ref": "#/definitions/PackageName" "$ref": "#/definitions/Requirement"
} }
}, },
"workspace": { "workspace": {
@ -826,7 +826,7 @@
"null" "null"
], ],
"items": { "items": {
"$ref": "#/definitions/PackageName" "$ref": "#/definitions/Requirement"
} }
} }
}, },
@ -933,6 +933,10 @@
"type": "string", "type": "string",
"pattern": "^3\\.\\d+(\\.\\d+)?$" "pattern": "^3\\.\\d+(\\.\\d+)?$"
}, },
"Requirement": {
"description": "A PEP 508 dependency specifier",
"type": "string"
},
"ResolutionMode": { "ResolutionMode": {
"oneOf": [ "oneOf": [
{ {