Refresh lockfile when --refresh is provided (#15991) (#15994)

## Summary

If you provide `--refresh` to `uv lock`, we'll now always resolve (even
though it might return the same result). This is also robust to
`--locked` such that `--refresh --locked` will only fail if the lockfile
changes.

Closes https://github.com/astral-sh/uv/issues/15997.
This commit is contained in:
Charlie Marsh 2025-09-23 07:25:13 -04:00 committed by GitHub
parent 7f7fac812c
commit 8d6b369274
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 318 additions and 36 deletions

View file

@ -170,7 +170,7 @@ static ANDROID_X86_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
marker marker
}); });
#[derive(Clone, Debug, serde::Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
#[serde(try_from = "LockWire")] #[serde(try_from = "LockWire")]
pub struct Lock { pub struct Lock {
/// The (major) version of the lockfile format. /// The (major) version of the lockfile format.
@ -3233,34 +3233,6 @@ struct PackageMetadata {
dependency_groups: BTreeMap<GroupName, BTreeSet<Requirement>>, dependency_groups: BTreeMap<GroupName, BTreeSet<Requirement>>,
} }
impl PackageMetadata {
fn unwire(self, requires_python: &RequiresPython) -> Self {
// We need to complexify these markers so things like
// `requires_python < '0'` get normalized to False
let unwire_requirements = |requirements: BTreeSet<Requirement>| -> BTreeSet<Requirement> {
requirements
.into_iter()
.map(|mut requirement| {
let complexified_marker =
requires_python.complexify_markers(requirement.marker);
requirement.marker = complexified_marker;
requirement
})
.collect()
};
Self {
requires_dist: unwire_requirements(self.requires_dist),
provides_extra: self.provides_extra,
dependency_groups: self
.dependency_groups
.into_iter()
.map(|(group, requirements)| (group, unwire_requirements(requirements)))
.collect(),
}
}
}
impl PackageWire { impl PackageWire {
fn unwire( fn unwire(
self, self,
@ -3292,7 +3264,7 @@ impl PackageWire {
Ok(Package { Ok(Package {
id: self.id, id: self.id,
metadata: self.metadata.unwire(requires_python), metadata: self.metadata,
sdist: self.sdist, sdist: self.sdist,
wheels: self.wheels, wheels: self.wheels,
fork_markers: self fork_markers: self
@ -4826,11 +4798,12 @@ impl Dependency {
) -> Self { ) -> Self {
let simplified_marker = let simplified_marker =
SimplifiedMarkerTree::new(requires_python, complexified_marker.combined()); SimplifiedMarkerTree::new(requires_python, complexified_marker.combined());
let complexified_marker = simplified_marker.into_marker(requires_python);
Self { Self {
package_id, package_id,
extra, extra,
simplified_marker, simplified_marker,
complexified_marker, complexified_marker: UniversalMarker::from_combined(complexified_marker),
} }
} }

View file

@ -9,7 +9,7 @@ use owo_colors::OwoColorize;
use rustc_hash::{FxBuildHasher, FxHashMap}; use rustc_hash::{FxBuildHasher, FxHashMap};
use tracing::debug; use tracing::debug;
use uv_cache::Cache; use uv_cache::{Cache, Refresh};
use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder}; use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder};
use uv_configuration::{ use uv_configuration::{
Concurrency, Constraints, DependencyGroupsWithDefaults, DryRun, ExtrasSpecification, Reinstall, Concurrency, Constraints, DependencyGroupsWithDefaults, DryRun, ExtrasSpecification, Reinstall,
@ -84,6 +84,7 @@ pub(crate) async fn lock(
locked: bool, locked: bool,
frozen: bool, frozen: bool,
dry_run: DryRun, dry_run: DryRun,
refresh: Refresh,
python: Option<String>, python: Option<String>,
install_mirrors: PythonInstallMirrors, install_mirrors: PythonInstallMirrors,
settings: ResolverSettings, settings: ResolverSettings,
@ -201,20 +202,25 @@ pub(crate) async fn lock(
printer, printer,
preview, preview,
) )
.with_refresh(&refresh)
.execute(target) .execute(target)
.await .await
{ {
Ok(lock) => { Ok(lock) => {
if dry_run.enabled() { if dry_run.enabled() {
// In `--dry-run` mode, show all changes. // In `--dry-run` mode, show all changes.
let mut changed = false;
if let LockResult::Changed(previous, lock) = &lock { if let LockResult::Changed(previous, lock) = &lock {
let mut changed = false;
for event in LockEvent::detect_changes(previous.as_ref(), lock, dry_run) { for event in LockEvent::detect_changes(previous.as_ref(), lock, dry_run) {
changed = true; changed = true;
writeln!(printer.stderr(), "{event}")?; writeln!(printer.stderr(), "{event}")?;
} }
}
if !changed { // If we didn't report any version changes, but the lockfile changed, report back.
if !changed {
writeln!(printer.stderr(), "{}", "Lockfile changes detected".bold())?;
}
} else {
writeln!( writeln!(
printer.stderr(), printer.stderr(),
"{}", "{}",
@ -260,6 +266,7 @@ pub(super) enum LockMode<'env> {
pub(super) struct LockOperation<'env> { pub(super) struct LockOperation<'env> {
mode: LockMode<'env>, mode: LockMode<'env>,
constraints: Vec<NameRequirementSpecification>, constraints: Vec<NameRequirementSpecification>,
refresh: Option<&'env Refresh>,
settings: &'env ResolverSettings, settings: &'env ResolverSettings,
client_builder: &'env BaseClientBuilder<'env>, client_builder: &'env BaseClientBuilder<'env>,
state: &'env UniversalState, state: &'env UniversalState,
@ -288,6 +295,7 @@ impl<'env> LockOperation<'env> {
Self { Self {
mode, mode,
constraints: vec![], constraints: vec![],
refresh: None,
settings, settings,
client_builder, client_builder,
state, state,
@ -310,6 +318,13 @@ impl<'env> LockOperation<'env> {
self self
} }
/// Set the refresh strategy for the [`LockOperation`].
#[must_use]
pub(super) fn with_refresh(mut self, refresh: &'env Refresh) -> Self {
self.refresh = Some(refresh);
self
}
/// Perform a [`LockOperation`]. /// Perform a [`LockOperation`].
pub(super) async fn execute(self, target: LockTarget<'_>) -> Result<LockResult, ProjectError> { pub(super) async fn execute(self, target: LockTarget<'_>) -> Result<LockResult, ProjectError> {
match self.mode { match self.mode {
@ -334,6 +349,7 @@ impl<'env> LockOperation<'env> {
interpreter, interpreter,
Some(existing), Some(existing),
self.constraints, self.constraints,
self.refresh,
self.settings, self.settings,
self.client_builder, self.client_builder,
self.state, self.state,
@ -376,6 +392,7 @@ impl<'env> LockOperation<'env> {
interpreter, interpreter,
existing, existing,
self.constraints, self.constraints,
self.refresh,
self.settings, self.settings,
self.client_builder, self.client_builder,
self.state, self.state,
@ -407,6 +424,7 @@ async fn do_lock(
interpreter: &Interpreter, interpreter: &Interpreter,
existing_lock: Option<Lock>, existing_lock: Option<Lock>,
external: Vec<NameRequirementSpecification>, external: Vec<NameRequirementSpecification>,
refresh: Option<&Refresh>,
settings: &ResolverSettings, settings: &ResolverSettings,
client_builder: &BaseClientBuilder<'_>, client_builder: &BaseClientBuilder<'_>,
state: &UniversalState, state: &UniversalState,
@ -743,6 +761,7 @@ async fn do_lock(
&requires_python, &requires_python,
index_locations, index_locations,
upgrade, upgrade,
refresh,
&options, &options,
&hasher, &hasher,
state.index(), state.index(),
@ -917,7 +936,11 @@ async fn do_lock(
.unwrap_or_default(), .unwrap_or_default(),
); );
Ok(LockResult::Changed(previous, lock)) if previous.as_ref().is_some_and(|previous| *previous == lock) {
Ok(LockResult::Unchanged(lock))
} else {
Ok(LockResult::Changed(previous, lock))
}
} }
} }
} }
@ -957,6 +980,7 @@ impl ValidatedLock {
requires_python: &RequiresPython, requires_python: &RequiresPython,
index_locations: &IndexLocations, index_locations: &IndexLocations,
upgrade: &Upgrade, upgrade: &Upgrade,
refresh: Option<&Refresh>,
options: &Options, options: &Options,
hasher: &HashStrategy, hasher: &HashStrategy,
index: &InMemoryIndex, index: &InMemoryIndex,
@ -1142,6 +1166,12 @@ impl ValidatedLock {
return Ok(Self::Versions(lock)); return Ok(Self::Versions(lock));
} }
// If the user specified `--refresh`, then we have to re-resolve.
if matches!(refresh, Some(Refresh::All(..) | Refresh::Packages(..))) {
debug!("Resolving despite existing lockfile due to `--refresh`");
return Ok(Self::Preferable(lock));
}
// If the user provided at least one index URL (from the command line, or from a configuration // If the user provided at least one index URL (from the command line, or from a configuration
// file), don't use the existing lockfile if it references any registries that are no longer // file), don't use the existing lockfile if it references any registries that are no longer
// included in the current configuration. // included in the current configuration.

View file

@ -1902,6 +1902,7 @@ async fn run_project(
// Initialize the cache. // Initialize the cache.
let cache = cache.init()?.with_refresh( let cache = cache.init()?.with_refresh(
args.refresh args.refresh
.clone()
.combine(Refresh::from(args.settings.upgrade.clone())), .combine(Refresh::from(args.settings.upgrade.clone())),
); );
@ -1923,6 +1924,7 @@ async fn run_project(
args.locked, args.locked,
args.frozen, args.frozen,
args.dry_run, args.dry_run,
args.refresh,
args.python, args.python,
args.install_mirrors, args.install_mirrors,
args.settings, args.settings,

View file

@ -13237,6 +13237,33 @@ fn normalize_false_marker_dependency_groups() -> Result<()> {
Resolved 1 package in [TIME] Resolved 1 package in [TIME]
"); ");
let lock = context.read("uv.lock");
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r#"
version = 1
revision = 3
requires-python = ">=3.11"
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[[package]]
name = "project"
version = "0.1.0"
source = { virtual = "." }
[package.metadata]
[package.metadata.requires-dev]
dev = [{ name = "pytest", marker = "python_version < '0'" }]
"#
);
});
Ok(()) Ok(())
} }
@ -13278,6 +13305,31 @@ fn normalize_false_marker_requires_dist() -> Result<()> {
Resolved 1 package in [TIME] Resolved 1 package in [TIME]
"); ");
let lock = context.read("uv.lock");
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r#"
version = 1
revision = 3
requires-python = ">=3.11"
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[[package]]
name = "debug"
version = "0.1.0"
source = { virtual = "." }
[package.metadata]
requires-dist = [{ name = "pytest", marker = "python_version < '0'" }]
"#
);
});
Ok(()) Ok(())
} }
@ -31658,6 +31710,231 @@ fn lock_required_intersection() -> Result<()> {
Ok(()) Ok(())
} }
#[test]
fn lock_refresh() -> 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"
requires-python = ">=3.12"
dependencies = ["anyio==3.7.0"]
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 4 packages in [TIME]
"###);
// Write a `uv.lock` that accidentally omits the `anyio` wheel, and uses an outdated revision.
context.temp_dir.child("uv.lock").write_str(
r#"
version = 1
revision = 2
requires-python = ">=3.12"
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[[package]]
name = "anyio"
version = "3.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/b3/fefbf7e78ab3b805dec67d698dc18dd505af7a18a8dd08868c9b4fa736b5/anyio-3.7.0.tar.gz", hash = "sha256:275d9973793619a5374e1c89a4f4ad3f4b0a5510a2b5b939444bee8f4c4d37ce", size = 142737, upload-time = "2023-05-27T11:12:46.688Z" }
[[package]]
name = "idna"
version = "3.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426, upload-time = "2023-11-25T15:40:54.902Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567, upload-time = "2023-11-25T15:40:52.604Z" },
]
[[package]]
name = "project"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "anyio" },
]
[package.metadata]
requires-dist = [{ name = "anyio", specifier = "==3.7.0" }]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
"#,
)?;
// Run `uv lock`.
uv_snapshot!(context.filters(), context.lock(), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 4 packages in [TIME]
"###);
let lock = context.read("uv.lock");
// The wheel should still be missing.
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r#"
version = 1
revision = 2
requires-python = ">=3.12"
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[[package]]
name = "anyio"
version = "3.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/b3/fefbf7e78ab3b805dec67d698dc18dd505af7a18a8dd08868c9b4fa736b5/anyio-3.7.0.tar.gz", hash = "sha256:275d9973793619a5374e1c89a4f4ad3f4b0a5510a2b5b939444bee8f4c4d37ce", size = 142737, upload-time = "2023-05-27T11:12:46.688Z" }
[[package]]
name = "idna"
version = "3.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426, upload-time = "2023-11-25T15:40:54.902Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567, upload-time = "2023-11-25T15:40:52.604Z" },
]
[[package]]
name = "project"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "anyio" },
]
[package.metadata]
requires-dist = [{ name = "anyio", specifier = "==3.7.0" }]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
"#
);
});
// Re-run with `--refresh`.
uv_snapshot!(context.filters(), context.lock().arg("--refresh"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 4 packages in [TIME]
");
let lock = context.read("uv.lock");
// The wheel should be present, and the revision should be incremented.
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r#"
version = 1
revision = 3
requires-python = ">=3.12"
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[[package]]
name = "anyio"
version = "3.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/b3/fefbf7e78ab3b805dec67d698dc18dd505af7a18a8dd08868c9b4fa736b5/anyio-3.7.0.tar.gz", hash = "sha256:275d9973793619a5374e1c89a4f4ad3f4b0a5510a2b5b939444bee8f4c4d37ce", size = 142737, upload-time = "2023-05-27T11:12:46.688Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/fe/7ce1926952c8a403b35029e194555558514b365ad77d75125f521a2bec62/anyio-3.7.0-py3-none-any.whl", hash = "sha256:eddca883c4175f14df8aedce21054bfca3adb70ffe76a9f607aef9d7fa2ea7f0", size = 80873, upload-time = "2023-05-27T11:12:44.474Z" },
]
[[package]]
name = "idna"
version = "3.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426, upload-time = "2023-11-25T15:40:54.902Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567, upload-time = "2023-11-25T15:40:52.604Z" },
]
[[package]]
name = "project"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "anyio" },
]
[package.metadata]
requires-dist = [{ name = "anyio", specifier = "==3.7.0" }]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
"#
);
});
// Re-run with `--refresh --locked`.
uv_snapshot!(context.filters(), context.lock().arg("--refresh").arg("--locked"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 4 packages in [TIME]
");
Ok(())
}
/// Ensure conflicts on virtual packages (such as markers) give good error messages. /// Ensure conflicts on virtual packages (such as markers) give good error messages.
#[test] #[test]
fn collapsed_error_with_marker_packages() -> Result<()> { fn collapsed_error_with_marker_packages() -> Result<()> {