diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 47597f2ec..5af4377b9 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -768,6 +768,36 @@ impl Lock { } } + /// Checks whether the new requires-python specification is disjoint with + /// the fork markers in this lock file. + /// + /// If they are disjoint, then the union of the fork markers along with the + /// given requires-python specification (converted to a marker tree) are + /// returned. + /// + /// When disjoint, the fork markers in the lock file should be dropped and + /// not used. + pub fn requires_python_coverage( + &self, + new_requires_python: &RequiresPython, + ) -> Result<(), (MarkerTree, MarkerTree)> { + let fork_markers_union = if self.fork_markers().is_empty() { + self.requires_python.to_marker_tree() + } else { + let mut fork_markers_union = MarkerTree::FALSE; + for fork_marker in self.fork_markers() { + fork_markers_union.or(fork_marker.pep508()); + } + fork_markers_union + }; + let new_requires_python = new_requires_python.to_marker_tree(); + if fork_markers_union.is_disjoint(new_requires_python) { + Err((fork_markers_union, new_requires_python)) + } else { + Ok(()) + } + } + /// Returns the TOML representation of this lockfile. pub fn to_toml(&self) -> Result { // Catch a lockfile where the union of fork markers doesn't cover the supported diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index b57df429b..1ab4441b0 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -983,13 +983,54 @@ impl ValidatedLock { return Ok(Self::Unusable(lock)); } Upgrade::Packages(_) => { - // If the user specified `--upgrade-package`, then at best we can prefer some of - // the existing versions. - debug!("Ignoring existing lockfile due to `--upgrade-package`"); - return Ok(Self::Preferable(lock)); + // This is handled below, after some checks regarding fork + // markers. In particular, we'd like to return `Preferable` + // here, but we shouldn't if the fork markers cannot be + // reused. } } + // NOTE: It's important that this appears before any possible path that + // returns `Self::Preferable`. In particular, if our fork markers are + // bunk, then we shouldn't return a result that indicates we should try + // to re-use the existing fork markers. + if let Err((fork_markers_union, environments_union)) = lock.check_marker_coverage() { + warn_user!( + "Ignoring existing lockfile due to fork markers not covering the supported environments: `{}` vs `{}`", + fork_markers_union + .try_to_string() + .unwrap_or("true".to_string()), + environments_union + .try_to_string() + .unwrap_or("true".to_string()), + ); + return Ok(Self::Versions(lock)); + } + + // NOTE: Similarly as above, this should also appear before any + // possible code path that can return `Self::Preferable`. + if let Err((fork_markers_union, requires_python_marker)) = + lock.requires_python_coverage(requires_python) + { + warn_user!( + "Ignoring existing lockfile due to fork markers being disjoint with `requires-python`: `{}` vs `{}`", + fork_markers_union + .try_to_string() + .unwrap_or("true".to_string()), + requires_python_marker + .try_to_string() + .unwrap_or("true".to_string()), + ); + return Ok(Self::Versions(lock)); + } + + if let Upgrade::Packages(_) = upgrade { + // If the user specified `--upgrade-package`, then at best we can prefer some of + // the existing versions. + debug!("Ignoring existing lockfile due to `--upgrade-package`"); + return Ok(Self::Preferable(lock)); + } + // If the Requires-Python bound has changed, we have to perform a clean resolution, since // the set of `resolution-markers` may no longer cover the entire supported Python range. if lock.requires_python().range() != requires_python.range() { @@ -1022,19 +1063,6 @@ impl ValidatedLock { return Ok(Self::Versions(lock)); } - if let Err((fork_markers_union, environments_union)) = lock.check_marker_coverage() { - warn_user!( - "Ignoring existing lockfile due to fork markers not covering the supported environments: `{}` vs `{}`", - fork_markers_union - .try_to_string() - .unwrap_or("true".to_string()), - environments_union - .try_to_string() - .unwrap_or("true".to_string()), - ); - return Ok(Self::Versions(lock)); - } - // If the set of required platforms has changed, we have to perform a clean resolution. let expected = lock.simplified_required_environments(); let actual = required_environments diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index e9615538e..ac20124a0 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -4731,15 +4731,16 @@ fn lock_requires_python_wheels() -> Result<()> { "#, )?; - uv_snapshot!(context.filters(), context.lock(), @r###" + uv_snapshot!(context.filters(), context.lock(), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Using CPython 3.11.[X] interpreter at: [PYTHON-3.11] + warning: Ignoring existing lockfile due to fork markers being disjoint with `requires-python`: `python_full_version == '3.12.*'` vs `python_full_version == '3.11.*'` Resolved 2 packages in [TIME] - "###); + "); let lock = fs_err::read_to_string(&lockfile).unwrap(); @@ -28020,6 +28021,170 @@ fn lock_conflict_for_disjoint_python_version() -> Result<()> { Ok(()) } +/// Check that we hint if the resolution failed for a different platform. +#[cfg(feature = "python-patch")] +#[test] +fn lock_requires_python_empty_lock_file() -> Result<()> { + // N.B. These versions were selected based on what was + // in `.python-versions` at the time of writing (2025-06-16). + let (v1, v2) = ("3.13.0", "3.13.2"); + let context = TestContext::new_with_versions(&[v1, v2]); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(&format!( + r#" + [project] + name = "renovate-bug-repro" + version = "0.1.0" + requires-python = "=={v1}" + dependencies = ["opencv-python-headless>=4.8"] + "#, + ))?; + + uv_snapshot!(context.filters(), context.lock(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.13.0 interpreter at: [PYTHON-3.13.0] + Resolved 3 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 2 + requires-python = "==3.13.0" + resolution-markers = [ + "sys_platform == 'darwin'", + "platform_machine == 'aarch64' and sys_platform == 'linux'", + "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')", + ] + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "numpy" + version = "1.26.4" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129, upload-time = "2024-02-06T00:26:44.495Z" } + + [[package]] + name = "opencv-python-headless" + version = "4.9.0.80" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "numpy" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/3d/b2/c308bc696bf5d75304175c62222ec8af9a6d5cfe36c14f19f15ea9d1a132/opencv-python-headless-4.9.0.80.tar.gz", hash = "sha256:71a4cd8cf7c37122901d8e81295db7fb188730e33a0e40039a4e59c1030b0958", size = 92910044, upload-time = "2023-12-31T13:34:50.518Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/42/da433fca5733a3ce7e88dd0d4018f70dcffaf48770b5142250815f4faddb/opencv_python_headless-4.9.0.80-cp37-abi3-macosx_10_16_x86_64.whl", hash = "sha256:2ea8a2edc4db87841991b2fbab55fc07b97ecb602e0f47d5d485bd75cee17c1a", size = 55689478, upload-time = "2023-12-31T14:31:30.476Z" }, + { url = "https://files.pythonhosted.org/packages/32/0c/a59f2a40d6058ee8126668dc5dff6977c913f6ecd21dbd15b41563409a18/opencv_python_headless-4.9.0.80-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e0ee54e27be493e8f7850847edae3128e18b540dac1d7b2e4001b8944e11e1c6", size = 35354670, upload-time = "2023-12-31T16:38:31.588Z" }, + { url = "https://files.pythonhosted.org/packages/36/37/225a1f8be42610ffecf677558311ab0f9dfdc63537c250a2bce76762a380/opencv_python_headless-4.9.0.80-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57ce2865e8fec431c6f97a81e9faaf23fa5be61011d0a75ccf47a3c0d65fa73d", size = 28954368, upload-time = "2023-12-31T16:40:00.838Z" }, + { url = "https://files.pythonhosted.org/packages/71/19/3c65483a80a1d062d46ae20faf5404712d25cb1dfdcaf371efbd67c38544/opencv_python_headless-4.9.0.80-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:976656362d68d9f40a5c66f83901430538002465f7db59142784f3893918f3df", size = 49591873, upload-time = "2023-12-31T13:34:44.316Z" }, + { url = "https://files.pythonhosted.org/packages/10/98/300382ff6ddff3a487e808c8a76362e430f5016002fcbefb3b3117aad32b/opencv_python_headless-4.9.0.80-cp37-abi3-win32.whl", hash = "sha256:11e3849d83e6651d4e7699aadda9ec7ed7c38957cbbcb99db074f2a2d2de9670", size = 28488841, upload-time = "2023-12-31T13:34:31.974Z" }, + { url = "https://files.pythonhosted.org/packages/20/44/458a0a135866f5e08266566b32ad9a182a7a059a894effe6c41a9c841ff1/opencv_python_headless-4.9.0.80-cp37-abi3-win_amd64.whl", hash = "sha256:a8056c2cb37cd65dfcdf4153ca16f7362afcf3a50d600d6bb69c660fc61ee29c", size = 38536073, upload-time = "2023-12-31T13:34:39.675Z" }, + ] + + [[package]] + name = "renovate-bug-repro" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "opencv-python-headless" }, + ] + + [package.metadata] + requires-dist = [{ name = "opencv-python-headless", specifier = ">=4.8" }] + "# + ); + }); + + pyproject_toml.write_str(&format!( + r#" + [project] + name = "renovate-bug-repro" + version = "0.1.0" + requires-python = "=={v2}" + dependencies = ["opencv-python-headless>=4.8"] + "#, + ))?; + + uv_snapshot!(context.filters(), context.lock().arg("--upgrade-package=python"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.13.2 interpreter at: [PYTHON-3.13.2] + warning: Ignoring existing lockfile due to fork markers being disjoint with `requires-python`: `python_full_version == '3.13.0'` vs `python_full_version == '3.13.2'` + Resolved 3 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 2 + requires-python = "==3.13.2" + resolution-markers = [ + "sys_platform == 'darwin'", + "platform_machine == 'aarch64' and sys_platform == 'linux'", + "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')", + ] + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "numpy" + version = "1.26.4" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129, upload-time = "2024-02-06T00:26:44.495Z" } + + [[package]] + name = "opencv-python-headless" + version = "4.9.0.80" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "numpy" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/3d/b2/c308bc696bf5d75304175c62222ec8af9a6d5cfe36c14f19f15ea9d1a132/opencv-python-headless-4.9.0.80.tar.gz", hash = "sha256:71a4cd8cf7c37122901d8e81295db7fb188730e33a0e40039a4e59c1030b0958", size = 92910044, upload-time = "2023-12-31T13:34:50.518Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/42/da433fca5733a3ce7e88dd0d4018f70dcffaf48770b5142250815f4faddb/opencv_python_headless-4.9.0.80-cp37-abi3-macosx_10_16_x86_64.whl", hash = "sha256:2ea8a2edc4db87841991b2fbab55fc07b97ecb602e0f47d5d485bd75cee17c1a", size = 55689478, upload-time = "2023-12-31T14:31:30.476Z" }, + { url = "https://files.pythonhosted.org/packages/32/0c/a59f2a40d6058ee8126668dc5dff6977c913f6ecd21dbd15b41563409a18/opencv_python_headless-4.9.0.80-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e0ee54e27be493e8f7850847edae3128e18b540dac1d7b2e4001b8944e11e1c6", size = 35354670, upload-time = "2023-12-31T16:38:31.588Z" }, + { url = "https://files.pythonhosted.org/packages/36/37/225a1f8be42610ffecf677558311ab0f9dfdc63537c250a2bce76762a380/opencv_python_headless-4.9.0.80-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57ce2865e8fec431c6f97a81e9faaf23fa5be61011d0a75ccf47a3c0d65fa73d", size = 28954368, upload-time = "2023-12-31T16:40:00.838Z" }, + { url = "https://files.pythonhosted.org/packages/71/19/3c65483a80a1d062d46ae20faf5404712d25cb1dfdcaf371efbd67c38544/opencv_python_headless-4.9.0.80-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:976656362d68d9f40a5c66f83901430538002465f7db59142784f3893918f3df", size = 49591873, upload-time = "2023-12-31T13:34:44.316Z" }, + { url = "https://files.pythonhosted.org/packages/10/98/300382ff6ddff3a487e808c8a76362e430f5016002fcbefb3b3117aad32b/opencv_python_headless-4.9.0.80-cp37-abi3-win32.whl", hash = "sha256:11e3849d83e6651d4e7699aadda9ec7ed7c38957cbbcb99db074f2a2d2de9670", size = 28488841, upload-time = "2023-12-31T13:34:31.974Z" }, + { url = "https://files.pythonhosted.org/packages/20/44/458a0a135866f5e08266566b32ad9a182a7a059a894effe6c41a9c841ff1/opencv_python_headless-4.9.0.80-cp37-abi3-win_amd64.whl", hash = "sha256:a8056c2cb37cd65dfcdf4153ca16f7362afcf3a50d600d6bb69c660fc61ee29c", size = 38536073, upload-time = "2023-12-31T13:34:39.675Z" }, + ] + + [[package]] + name = "renovate-bug-repro" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "opencv-python-headless" }, + ] + + [package.metadata] + requires-dist = [{ name = "opencv-python-headless", specifier = ">=4.8" }] + "# + ); + }); + + Ok(()) +} + /// Check that we hint if the resolution failed for a different platform. #[test] fn lock_conflict_for_disjoint_platform() -> Result<()> { diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 70d8a9118..966dd41d2 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -8140,7 +8140,7 @@ fn sync_dry_run() -> Result<()> { "#, )?; - uv_snapshot!(context.filters(), context.sync().arg("--dry-run"), @r###" + uv_snapshot!(context.filters(), context.sync().arg("--dry-run"), @r" success: true exit_code: 0 ----- stdout ----- @@ -8148,14 +8148,15 @@ fn sync_dry_run() -> Result<()> { ----- stderr ----- Using CPython 3.9.[X] interpreter at: [PYTHON-3.9] Would replace existing virtual environment at: .venv + warning: Ignoring existing lockfile due to fork markers being disjoint with `requires-python`: `python_full_version >= '3.12'` vs `python_full_version == '3.9.*'` Resolved 2 packages in [TIME] Would update lockfile at: uv.lock Would install 1 package + iniconfig==2.0.0 - "###); + "); // Perform a full sync. - uv_snapshot!(context.filters(), context.sync(), @r###" + uv_snapshot!(context.filters(), context.sync(), @r" success: true exit_code: 0 ----- stdout ----- @@ -8164,10 +8165,11 @@ fn sync_dry_run() -> Result<()> { Using CPython 3.9.[X] interpreter at: [PYTHON-3.9] Removed virtual environment at: .venv Creating virtual environment at: .venv + warning: Ignoring existing lockfile due to fork markers being disjoint with `requires-python`: `python_full_version >= '3.12'` vs `python_full_version == '3.9.*'` Resolved 2 packages in [TIME] Installed 1 package in [TIME] + iniconfig==2.0.0 - "###); + "); let output = context.sync().arg("--dry-run").arg("-vv").output()?; let stderr = String::from_utf8_lossy(&output.stderr); @@ -8658,6 +8660,7 @@ fn sync_locked_script() -> Result<()> { ----- stderr ----- Recreating script environment at: [CACHE_DIR]/environments-v2/script-[HASH] + warning: Ignoring existing lockfile due to fork markers being disjoint with `requires-python`: `python_full_version >= '3.11'` vs `python_full_version >= '3.8' and python_full_version < '3.11'` Resolved 6 packages in [TIME] error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. To update the lockfile, run `uv lock`. "); @@ -8669,6 +8672,7 @@ fn sync_locked_script() -> Result<()> { ----- stderr ----- Using script environment at: [CACHE_DIR]/environments-v2/script-[HASH] + warning: Ignoring existing lockfile due to fork markers being disjoint with `requires-python`: `python_full_version >= '3.11'` vs `python_full_version >= '3.8' and python_full_version < '3.11'` Resolved 6 packages in [TIME] Prepared 2 packages in [TIME] Installed 6 packages in [TIME]