Use lockfile versions as resolution preferences (#3921)

## Summary

Ensures that we avoid upgrading packages unless `--upgrade` or similar
is passed.

For now, the resolver only respects these for registry distributions.

Closes https://github.com/astral-sh/uv/issues/3918.
This commit is contained in:
Charlie Marsh
2024-05-30 13:59:53 -04:00
committed by GitHub
parent 502e04200d
commit 144566907e
8 changed files with 333 additions and 13 deletions
+6 -1
View File
@@ -52,6 +52,11 @@ impl Lock {
Lock::try_from(wire)
}
/// Returns the [`Distribution`] entries in this lock.
pub fn distributions(&self) -> &[Distribution] {
&self.distributions
}
pub fn to_resolution(
&self,
marker_env: &MarkerEnvironment,
@@ -202,7 +207,7 @@ impl TryFrom<LockWire> for Lock {
}
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
pub(crate) struct Distribution {
pub struct Distribution {
#[serde(flatten)]
pub(crate) id: DistributionId,
#[serde(default)]
+10
View File
@@ -86,6 +86,16 @@ impl Preference {
}
}
/// Create a [`Preference`] from a locked distribution.
pub fn from_lock(dist: &crate::lock::Distribution) -> Self {
Self {
name: dist.id.name.clone(),
version: dist.id.version.clone(),
marker: None,
hashes: Vec::new(),
}
}
/// Return the [`PackageName`] of the package for this [`Preference`].
pub fn name(&self) -> &PackageName {
&self.name
+70 -2
View File
@@ -372,8 +372,6 @@ pub(crate) struct PipCompileArgs {
#[arg(long, env = "UV_CUSTOM_COMPILE_COMMAND")]
pub(crate) custom_compile_command: Option<String>,
/// Run offline, i.e., without accessing the network.
/// Refresh all cached data.
#[arg(long, conflicts_with("offline"), overrides_with("no_refresh"))]
pub(crate) refresh: bool,
@@ -1786,6 +1784,33 @@ pub(crate) struct RunArgs {
#[arg(long)]
pub(crate) with: Vec<String>,
/// Refresh all cached data.
#[arg(long, conflicts_with("offline"), overrides_with("no_refresh"))]
pub(crate) refresh: bool,
#[arg(
long,
conflicts_with("offline"),
overrides_with("refresh"),
hide = true
)]
pub(crate) no_refresh: bool,
/// Refresh cached data for a specific package.
#[arg(long)]
pub(crate) refresh_package: Vec<PackageName>,
/// Allow package upgrades, ignoring pinned versions in the existing lockfile.
#[arg(long, short = 'U', overrides_with("no_upgrade"))]
pub(crate) upgrade: bool,
#[arg(long, overrides_with("upgrade"), hide = true)]
pub(crate) no_upgrade: bool,
/// Allow upgrades for a specific package, ignoring pinned versions in the existing lockfile.
#[arg(long, short = 'P')]
pub(crate) upgrade_package: Vec<PackageName>,
/// The Python interpreter to use to build the run environment.
///
/// By default, `uv` uses the virtual environment in the current working directory or any parent
@@ -1822,6 +1847,22 @@ pub(crate) struct SyncArgs {
#[arg(long, overrides_with("all_extras"), hide = true)]
pub(crate) no_all_extras: bool,
/// Refresh all cached data.
#[arg(long, conflicts_with("offline"), overrides_with("no_refresh"))]
pub(crate) refresh: bool,
#[arg(
long,
conflicts_with("offline"),
overrides_with("refresh"),
hide = true
)]
pub(crate) no_refresh: bool,
/// Refresh cached data for a specific package.
#[arg(long)]
pub(crate) refresh_package: Vec<PackageName>,
/// The Python interpreter to use to build the run environment.
///
/// By default, `uv` uses the virtual environment in the current working directory or any parent
@@ -1840,6 +1881,33 @@ pub(crate) struct SyncArgs {
#[derive(Args)]
#[allow(clippy::struct_excessive_bools)]
pub(crate) struct LockArgs {
/// Refresh all cached data.
#[arg(long, conflicts_with("offline"), overrides_with("no_refresh"))]
pub(crate) refresh: bool,
#[arg(
long,
conflicts_with("offline"),
overrides_with("refresh"),
hide = true
)]
pub(crate) no_refresh: bool,
/// Refresh cached data for a specific package.
#[arg(long)]
pub(crate) refresh_package: Vec<PackageName>,
/// Allow package upgrades, ignoring pinned versions in the existing lockfile.
#[arg(long, short = 'U', overrides_with("no_upgrade"))]
pub(crate) upgrade: bool,
#[arg(long, overrides_with("upgrade"), hide = true)]
pub(crate) no_upgrade: bool,
/// Allow upgrades for a specific package, ignoring pinned versions in the existing lockfile.
#[arg(long, short = 'P')]
pub(crate) upgrade_package: Vec<PackageName>,
/// The Python interpreter to use to build the run environment.
///
/// By default, `uv` uses the virtual environment in the current working directory or any parent
+26 -4
View File
@@ -12,7 +12,7 @@ use uv_configuration::{
use uv_dispatch::BuildDispatch;
use uv_interpreter::PythonEnvironment;
use uv_requirements::ProjectWorkspace;
use uv_resolver::{ExcludeNewer, FlatIndex, InMemoryIndex, Lock, OptionsBuilder};
use uv_resolver::{ExcludeNewer, FlatIndex, InMemoryIndex, Lock, OptionsBuilder, Preference};
use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy, InFlight};
use uv_warnings::warn_user;
@@ -23,6 +23,7 @@ use crate::printer::Printer;
/// Resolve the project requirements into a lockfile.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn lock(
upgrade: Upgrade,
exclude_newer: Option<ExcludeNewer>,
preview: PreviewMode,
cache: &Cache,
@@ -39,7 +40,7 @@ pub(crate) async fn lock(
let venv = project::init_environment(&project, preview, cache, printer)?;
// Perform the lock operation.
match do_lock(&project, &venv, exclude_newer, cache, printer).await {
match do_lock(&project, &venv, upgrade, exclude_newer, cache, printer).await {
Ok(_) => Ok(ExitStatus::Success),
Err(ProjectError::Operation(pip::operations::Error::Resolve(
uv_resolver::ResolveError::NoSolution(err),
@@ -57,6 +58,7 @@ pub(crate) async fn lock(
pub(super) async fn do_lock(
project: &ProjectWorkspace,
venv: &PythonEnvironment,
upgrade: Upgrade,
exclude_newer: Option<ExcludeNewer>,
cache: &Cache,
printer: Printer,
@@ -96,14 +98,34 @@ pub(super) async fn do_lock(
let link_mode = LinkMode::default();
let no_binary = NoBinary::default();
let no_build = NoBuild::default();
let preferences = Vec::default();
let reinstall = Reinstall::default();
let setup_py = SetupPyStrategy::default();
let upgrade = Upgrade::default();
let hasher = HashStrategy::Generate;
let options = OptionsBuilder::new().exclude_newer(exclude_newer).build();
// If an existing lockfile exists, build up a set of preferences.
let lockfile = project.workspace().root().join("uv.lock");
let lock = match fs_err::tokio::read_to_string(&lockfile).await {
Ok(encoded) => match toml::from_str::<Lock>(&encoded) {
Ok(lock) => Some(lock),
Err(err) => {
eprint!("Failed to parse lockfile; ignoring locked requirements: {err}");
None
}
},
Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
Err(err) => return Err(err.into()),
};
let preferences: Vec<Preference> = lock
.map(|lock| {
lock.distributions()
.iter()
.map(Preference::from_lock)
.collect()
})
.unwrap_or_default();
// Create a build dispatch.
let build_dispatch = BuildDispatch::new(
&client,
+4 -2
View File
@@ -9,7 +9,7 @@ use tracing::debug;
use uv_cache::Cache;
use uv_client::Connectivity;
use uv_configuration::{ExtrasSpecification, PreviewMode};
use uv_configuration::{ExtrasSpecification, PreviewMode, Upgrade};
use uv_interpreter::{PythonEnvironment, SystemPython};
use uv_requirements::{ProjectWorkspace, RequirementsSource};
use uv_resolver::ExcludeNewer;
@@ -26,6 +26,7 @@ pub(crate) async fn run(
mut args: Vec<OsString>,
requirements: Vec<RequirementsSource>,
python: Option<String>,
upgrade: Upgrade,
exclude_newer: Option<ExcludeNewer>,
isolated: bool,
preview: PreviewMode,
@@ -47,7 +48,8 @@ pub(crate) async fn run(
let venv = project::init_environment(&project, preview, cache, printer)?;
// Lock and sync the environment.
let lock = project::lock::do_lock(&project, &venv, exclude_newer, cache, printer).await?;
let lock =
project::lock::do_lock(&project, &venv, upgrade, exclude_newer, cache, printer).await?;
project::sync::do_sync(&project, &venv, &lock, extras, cache, printer).await?;
Some(venv)
+12 -4
View File
@@ -550,7 +550,7 @@ async fn run() -> Result<ExitStatus> {
let args = settings::RunSettings::resolve(args, workspace);
// Initialize the cache.
let cache = cache.init()?;
let cache = cache.init()?.with_refresh(args.refresh);
let requirements = args
.with
@@ -578,6 +578,7 @@ async fn run() -> Result<ExitStatus> {
args.args,
requirements,
args.python,
args.upgrade,
args.exclude_newer,
globals.isolated,
globals.preview,
@@ -592,7 +593,7 @@ async fn run() -> Result<ExitStatus> {
let args = settings::SyncSettings::resolve(args, workspace);
// Initialize the cache.
let cache = cache.init()?;
let cache = cache.init()?.with_refresh(args.refresh);
commands::sync(args.extras, globals.preview, &cache, printer).await
}
@@ -601,9 +602,16 @@ async fn run() -> Result<ExitStatus> {
let args = settings::LockSettings::resolve(args, workspace);
// Initialize the cache.
let cache = cache.init()?;
let cache = cache.init()?.with_refresh(args.refresh);
commands::lock(args.exclude_newer, globals.preview, &cache, printer).await
commands::lock(
args.upgrade,
args.exclude_newer,
globals.preview,
&cache,
printer,
)
.await
}
#[cfg(feature = "self-update")]
Commands::Self_(SelfNamespace {
+25
View File
@@ -102,6 +102,8 @@ pub(crate) struct RunSettings {
pub(crate) args: Vec<OsString>,
pub(crate) with: Vec<String>,
pub(crate) python: Option<String>,
pub(crate) refresh: Refresh,
pub(crate) upgrade: Upgrade,
pub(crate) exclude_newer: Option<ExcludeNewer>,
}
@@ -116,11 +118,19 @@ impl RunSettings {
target,
args,
with,
refresh,
no_refresh,
refresh_package,
upgrade,
no_upgrade,
upgrade_package,
python,
exclude_newer,
} = args;
Self {
refresh: Refresh::from_args(flag(refresh, no_refresh), refresh_package),
upgrade: Upgrade::from_args(flag(upgrade, no_upgrade), upgrade_package),
extras: ExtrasSpecification::from_args(
flag(all_extras, no_all_extras).unwrap_or_default(),
extra.unwrap_or_default(),
@@ -138,6 +148,7 @@ impl RunSettings {
#[allow(clippy::struct_excessive_bools, dead_code)]
#[derive(Debug, Clone)]
pub(crate) struct SyncSettings {
pub(crate) refresh: Refresh,
pub(crate) extras: ExtrasSpecification,
pub(crate) python: Option<String>,
}
@@ -150,10 +161,14 @@ impl SyncSettings {
extra,
all_extras,
no_all_extras,
refresh,
no_refresh,
refresh_package,
python,
} = args;
Self {
refresh: Refresh::from_args(flag(refresh, no_refresh), refresh_package),
extras: ExtrasSpecification::from_args(
flag(all_extras, no_all_extras).unwrap_or_default(),
extra.unwrap_or_default(),
@@ -167,6 +182,8 @@ impl SyncSettings {
#[allow(clippy::struct_excessive_bools, dead_code)]
#[derive(Debug, Clone)]
pub(crate) struct LockSettings {
pub(crate) refresh: Refresh,
pub(crate) upgrade: Upgrade,
pub(crate) exclude_newer: Option<ExcludeNewer>,
pub(crate) python: Option<String>,
}
@@ -176,11 +193,19 @@ impl LockSettings {
#[allow(clippy::needless_pass_by_value)]
pub(crate) fn resolve(args: LockArgs, _workspace: Option<Workspace>) -> Self {
let LockArgs {
refresh,
no_refresh,
refresh_package,
upgrade,
no_upgrade,
upgrade_package,
exclude_newer,
python,
} = args;
Self {
refresh: Refresh::from_args(flag(refresh, no_refresh), refresh_package),
upgrade: Upgrade::from_args(flag(upgrade, no_upgrade), upgrade_package),
exclude_newer,
python,
}
+180
View File
@@ -735,3 +735,183 @@ fn lock_extra() -> Result<()> {
Ok(())
}
/// Respect the locked version in an existing lockfile.
#[test]
fn lock_preference() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
dependencies = ["iniconfig<2"]
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: `uv lock` is experimental and may change without warning.
Resolved 2 packages in [TIME]
"###);
let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock"))?;
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r###"
version = 1
[[distribution]]
name = "iniconfig"
version = "1.1.1"
source = "registry+https://pypi.org/simple"
[distribution.sdist]
url = "https://files.pythonhosted.org/packages/23/a2/97899f6bd0e873fed3a7e67ae8d3a08b21799430fb4da15cfedf10d6e2c2/iniconfig-1.1.1.tar.gz"
hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"
size = 8104
[[distribution.wheel]]
url = "https://files.pythonhosted.org/packages/9b/dd/b3c12c6d707058fa947864b67f0c4e0c39ef8610988d7baea9578f3c48f3/iniconfig-1.1.1-py2.py3-none-any.whl"
hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"
size = 4990
[[distribution]]
name = "project"
version = "0.1.0"
source = "editable+file://[TEMP_DIR]/"
[distribution.sdist]
url = "file://[TEMP_DIR]/"
[[distribution.dependencies]]
name = "iniconfig"
version = "1.1.1"
source = "registry+https://pypi.org/simple"
"###
);
});
// Modify the `pyproject.toml` to loosen the requirement.
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
dependencies = ["iniconfig"]
"#,
)?;
// Ensure that the locked version is still respected.
uv_snapshot!(context.filters(), context.lock(), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: `uv lock` is experimental and may change without warning.
Resolved 2 packages in [TIME]
"###);
let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock"))?;
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r###"
version = 1
[[distribution]]
name = "iniconfig"
version = "1.1.1"
source = "registry+https://pypi.org/simple"
[distribution.sdist]
url = "https://files.pythonhosted.org/packages/23/a2/97899f6bd0e873fed3a7e67ae8d3a08b21799430fb4da15cfedf10d6e2c2/iniconfig-1.1.1.tar.gz"
hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"
size = 8104
[[distribution.wheel]]
url = "https://files.pythonhosted.org/packages/9b/dd/b3c12c6d707058fa947864b67f0c4e0c39ef8610988d7baea9578f3c48f3/iniconfig-1.1.1-py2.py3-none-any.whl"
hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"
size = 4990
[[distribution]]
name = "project"
version = "0.1.0"
source = "editable+file://[TEMP_DIR]/"
[distribution.sdist]
url = "file://[TEMP_DIR]/"
[[distribution.dependencies]]
name = "iniconfig"
version = "1.1.1"
source = "registry+https://pypi.org/simple"
"###
);
});
// Run with `--upgrade`; ensure that `iniconfig` is upgraded.
uv_snapshot!(context.filters(), context.lock().arg("--upgrade"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: `uv lock` is experimental and may change without warning.
Resolved 2 packages in [TIME]
"###);
let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock"))?;
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r###"
version = 1
[[distribution]]
name = "iniconfig"
version = "2.0.0"
source = "registry+https://pypi.org/simple"
[distribution.sdist]
url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz"
hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"
size = 4646
[[distribution.wheel]]
url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl"
hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"
size = 5892
[[distribution]]
name = "project"
version = "0.1.0"
source = "editable+file://[TEMP_DIR]/"
[distribution.sdist]
url = "file://[TEMP_DIR]/"
[[distribution.dependencies]]
name = "iniconfig"
version = "2.0.0"
source = "registry+https://pypi.org/simple"
"###
);
});
Ok(())
}