From 8d6b3692748cc8a0c5e4f71451852f44d2e0140f Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 23 Sep 2025 07:25:13 -0400 Subject: [PATCH] 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. --- crates/uv-resolver/src/lock/mod.rs | 35 +--- crates/uv/src/commands/project/lock.rs | 40 +++- crates/uv/src/lib.rs | 2 + crates/uv/tests/it/lock.rs | 277 +++++++++++++++++++++++++ 4 files changed, 318 insertions(+), 36 deletions(-) diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 803b53560..c872ed3f8 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -170,7 +170,7 @@ static ANDROID_X86_MARKERS: LazyLock = LazyLock::new(|| { marker }); -#[derive(Clone, Debug, serde::Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] #[serde(try_from = "LockWire")] pub struct Lock { /// The (major) version of the lockfile format. @@ -3233,34 +3233,6 @@ struct PackageMetadata { dependency_groups: BTreeMap>, } -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| -> BTreeSet { - 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 { fn unwire( self, @@ -3292,7 +3264,7 @@ impl PackageWire { Ok(Package { id: self.id, - metadata: self.metadata.unwire(requires_python), + metadata: self.metadata, sdist: self.sdist, wheels: self.wheels, fork_markers: self @@ -4826,11 +4798,12 @@ impl Dependency { ) -> Self { let simplified_marker = SimplifiedMarkerTree::new(requires_python, complexified_marker.combined()); + let complexified_marker = simplified_marker.into_marker(requires_python); Self { package_id, extra, simplified_marker, - complexified_marker, + complexified_marker: UniversalMarker::from_combined(complexified_marker), } } diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 83de92aef..f09dab033 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -9,7 +9,7 @@ use owo_colors::OwoColorize; use rustc_hash::{FxBuildHasher, FxHashMap}; use tracing::debug; -use uv_cache::Cache; +use uv_cache::{Cache, Refresh}; use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder}; use uv_configuration::{ Concurrency, Constraints, DependencyGroupsWithDefaults, DryRun, ExtrasSpecification, Reinstall, @@ -84,6 +84,7 @@ pub(crate) async fn lock( locked: bool, frozen: bool, dry_run: DryRun, + refresh: Refresh, python: Option, install_mirrors: PythonInstallMirrors, settings: ResolverSettings, @@ -201,20 +202,25 @@ pub(crate) async fn lock( printer, preview, ) + .with_refresh(&refresh) .execute(target) .await { Ok(lock) => { if dry_run.enabled() { // In `--dry-run` mode, show all changes. - let mut changed = false; if let LockResult::Changed(previous, lock) = &lock { + let mut changed = false; for event in LockEvent::detect_changes(previous.as_ref(), lock, dry_run) { changed = true; 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!( printer.stderr(), "{}", @@ -260,6 +266,7 @@ pub(super) enum LockMode<'env> { pub(super) struct LockOperation<'env> { mode: LockMode<'env>, constraints: Vec, + refresh: Option<&'env Refresh>, settings: &'env ResolverSettings, client_builder: &'env BaseClientBuilder<'env>, state: &'env UniversalState, @@ -288,6 +295,7 @@ impl<'env> LockOperation<'env> { Self { mode, constraints: vec![], + refresh: None, settings, client_builder, state, @@ -310,6 +318,13 @@ impl<'env> LockOperation<'env> { 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`]. pub(super) async fn execute(self, target: LockTarget<'_>) -> Result { match self.mode { @@ -334,6 +349,7 @@ impl<'env> LockOperation<'env> { interpreter, Some(existing), self.constraints, + self.refresh, self.settings, self.client_builder, self.state, @@ -376,6 +392,7 @@ impl<'env> LockOperation<'env> { interpreter, existing, self.constraints, + self.refresh, self.settings, self.client_builder, self.state, @@ -407,6 +424,7 @@ async fn do_lock( interpreter: &Interpreter, existing_lock: Option, external: Vec, + refresh: Option<&Refresh>, settings: &ResolverSettings, client_builder: &BaseClientBuilder<'_>, state: &UniversalState, @@ -743,6 +761,7 @@ async fn do_lock( &requires_python, index_locations, upgrade, + refresh, &options, &hasher, state.index(), @@ -917,7 +936,11 @@ async fn do_lock( .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, index_locations: &IndexLocations, upgrade: &Upgrade, + refresh: Option<&Refresh>, options: &Options, hasher: &HashStrategy, index: &InMemoryIndex, @@ -1142,6 +1166,12 @@ impl ValidatedLock { 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 // file), don't use the existing lockfile if it references any registries that are no longer // included in the current configuration. diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 64d7fc4fb..bfd00ab66 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1902,6 +1902,7 @@ async fn run_project( // Initialize the cache. let cache = cache.init()?.with_refresh( args.refresh + .clone() .combine(Refresh::from(args.settings.upgrade.clone())), ); @@ -1923,6 +1924,7 @@ async fn run_project( args.locked, args.frozen, args.dry_run, + args.refresh, args.python, args.install_mirrors, args.settings, diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 70477868b..dbf411f97 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -13237,6 +13237,33 @@ fn normalize_false_marker_dependency_groups() -> Result<()> { 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(()) } @@ -13278,6 +13305,31 @@ fn normalize_false_marker_requires_dist() -> Result<()> { 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(()) } @@ -31658,6 +31710,231 @@ fn lock_required_intersection() -> Result<()> { 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. #[test] fn collapsed_error_with_marker_packages() -> Result<()> {