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

View file

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

View file

@ -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<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> {
type Err = Pep508Error<T>;

View file

@ -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 }

View file

@ -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<PipSyncArgs>),
/// 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<PackageName>,
pub upgrade_package: Vec<Requirement<VerbatimParsedUrl>>,
/// 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<PackageName>,
pub upgrade_package: Vec<Requirement<VerbatimParsedUrl>>,
/// Reinstall all packages, regardless of whether they're already installed.
#[arg(long, alias = "force-reinstall", overrides_with("no_reinstall"))]

View file

@ -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 }

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 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<PackageName, Vec<Requirement>>);
impl Constraints {
/// Create a new set of constraints from a set of requirements.
pub fn from_requirements(requirements: Vec<Requirement>) -> Self {
let mut constraints: FxHashMap<PackageName, Vec<Requirement>> =
FxHashMap::with_capacity_and_hasher(requirements.len(), FxBuildHasher);
pub fn from_requirements(requirements: impl Iterator<Item = Requirement>) -> Self {
let mut constraints: FxHashMap<PackageName, Vec<Requirement>> = 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()

View file

@ -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<PackageName>),
Packages(FxHashMap<PackageName, Vec<Requirement>>),
}
impl Upgrade {
/// 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 {
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<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.
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;
}

View file

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

View file

@ -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<ExcludeNewer>,
pub link_mode: Option<LinkMode>,
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_package: Option<Vec<PackageName>>,
pub no_binary: Option<bool>,
@ -132,7 +133,7 @@ pub struct ResolverInstallerOptions {
pub link_mode: Option<LinkMode>,
pub compile_bytecode: 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_package: Option<Vec<PackageName>>,
pub no_build: Option<bool>,
@ -193,7 +194,7 @@ pub struct PipOptions {
pub compile_bytecode: Option<bool>,
pub require_hashes: 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_package: Option<Vec<PackageName>>,
pub concurrent_downloads: Option<NonZeroUsize>,

View file

@ -180,7 +180,11 @@ pub(crate) async fn resolve<InstalledPackages: InstalledPackagesProvider>(
.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);

View file

@ -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<FilesystemOptions>) -> Self {
pub(crate) fn resolve(args: Box<PipSyncArgs>, filesystem: Option<FilesystemOptions>) -> 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),

View file

@ -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<()> {