diff --git a/crates/uv-resolver/src/lock/export/pylock_toml.rs b/crates/uv-resolver/src/lock/export/pylock_toml.rs index ef3ad8615..642b9488a 100644 --- a/crates/uv-resolver/src/lock/export/pylock_toml.rs +++ b/crates/uv-resolver/src/lock/export/pylock_toml.rs @@ -186,7 +186,7 @@ pub struct PylockToml { lock_version: Version, created_by: String, #[serde(skip_serializing_if = "Option::is_none")] - requires_python: Option, + pub requires_python: Option, #[serde(skip_serializing_if = "Vec::is_empty", default)] pub extras: Vec, #[serde(skip_serializing_if = "Vec::is_empty", default)] diff --git a/crates/uv/src/commands/pip/install.rs b/crates/uv/src/commands/pip/install.rs index 72c532d7b..cb1229d72 100644 --- a/crates/uv/src/commands/pip/install.rs +++ b/crates/uv/src/commands/pip/install.rs @@ -444,6 +444,17 @@ pub(crate) async fn pip_install( format!("Not a valid `pylock.toml` file: {}", pylock.user_display()) })?; + // Verify that the Python version is compatible with the lock file. + if let Some(requires_python) = lock.requires_python.as_ref() { + if !requires_python.contains(interpreter.python_version()) { + return Err(anyhow::anyhow!( + "The requested interpreter resolved to Python {}, which is incompatible with the `pylock.toml`'s Python requirement: `{}`", + interpreter.python_version(), + requires_python, + )); + } + } + // Convert the extras and groups specifications into a concrete form. let extras = extras.with_defaults(DefaultExtras::default()); let extras = extras diff --git a/crates/uv/src/commands/pip/sync.rs b/crates/uv/src/commands/pip/sync.rs index 2f46ef502..2fe5fbe87 100644 --- a/crates/uv/src/commands/pip/sync.rs +++ b/crates/uv/src/commands/pip/sync.rs @@ -382,6 +382,17 @@ pub(crate) async fn pip_sync( format!("Not a valid `pylock.toml` file: {}", pylock.user_display()) })?; + // Verify that the Python version is compatible with the lock file. + if let Some(requires_python) = lock.requires_python.as_ref() { + if !requires_python.contains(interpreter.python_version()) { + return Err(anyhow::anyhow!( + "The requested interpreter resolved to Python {}, which is incompatible with the `pylock.toml`'s Python requirement: `{}`", + interpreter.python_version(), + requires_python, + )); + } + } + // Convert the extras and groups specifications into a concrete form. let extras = extras.with_defaults(DefaultExtras::default()); let extras = extras diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs index de62c4222..936f77aff 100644 --- a/crates/uv/tests/it/pip_install.rs +++ b/crates/uv/tests/it/pip_install.rs @@ -11590,6 +11590,51 @@ requires_python = "==3.13.*" Ok(()) } +#[test] +fn pep_751_requires_python() -> Result<()> { + let context = TestContext::new_with_versions(&["3.12", "3.13"]); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.13" + dependencies = ["iniconfig"] + "#, + )?; + + context + .export() + .arg("-o") + .arg("pylock.toml") + .assert() + .success(); + + context + .venv() + .arg("--python") + .arg("3.12") + .assert() + .success(); + + uv_snapshot!(context.filters(), context.pip_install() + .arg("--preview") + .arg("-r") + .arg("pylock.toml"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: The requested interpreter resolved to Python 3.12.[X], which is incompatible with the `pylock.toml`'s Python requirement: `>=3.13` + " + ); + + Ok(()) +} + /// Test that uv doesn't hang if an index returns a distribution for the wrong package. #[tokio::test] async fn bogus_redirect() -> Result<()> {