Fix handling of changes to requires-python (#14076)

When using `uv lock --upgrade-package=python` after changing
`requires-python`, it was possible to get into a state where the fork
markers produced corresponded to the empty set. This in turn resulted in
an empty lock file.

There was already some infrastructure in place that I think was perhaps
intended to handle this. In particular, `Lock::check_marker_coverage`
checks whether the fork markers have some overlap with the supported
environments (including the `requires-python`). But there were two
problems with this.

First is that in lock validation, this marker coverage check came
_after_ a path that returned `Preferable` (meaning that the fork markers
should be kept) when `--upgrade-package` was used. Second is that the
marker coverage check used the `requires-python` in the lock file and
_not_ the `requires-python` in the now updated `pyproject.toml`.

We attempt to solve this conundrum by slightly re-arranging lock file
validation and by explicitly checking whether the *new*
`requires-python` is disjoint from the fork markers in the lock file. If
it is, then we return `Versions` from lock file validation (indicating
that the fork markers should be dropped).

Fixes #13951
This commit is contained in:
Andrew Gallant 2025-06-17 11:50:05 -04:00 committed by GitHub
parent d653fbb133
commit 3d4f0c934e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 250 additions and 23 deletions

View file

@ -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<String, toml_edit::ser::Error> {
// Catch a lockfile where the union of fork markers doesn't cover the supported

View file

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

View file

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

View file

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