diff --git a/crates/uv-resolver/src/python_requirement.rs b/crates/uv-resolver/src/python_requirement.rs index 005360975..3ba72d076 100644 --- a/crates/uv-resolver/src/python_requirement.rs +++ b/crates/uv-resolver/src/python_requirement.rs @@ -22,12 +22,12 @@ impl PythonRequirement { } /// Return the installed version of Python. - pub(crate) fn installed(&self) -> &Version { + pub fn installed(&self) -> &Version { &self.installed } /// Return the target version of Python. - pub(crate) fn target(&self) -> &Version { + pub fn target(&self) -> &Version { &self.target } diff --git a/crates/uv/src/commands/pip_compile.rs b/crates/uv/src/commands/pip_compile.rs index f8e23986a..cfe777da0 100644 --- a/crates/uv/src/commands/pip_compile.rs +++ b/crates/uv/src/commands/pip_compile.rs @@ -29,7 +29,7 @@ use uv_interpreter::{Interpreter, PythonVersion}; use uv_normalize::{ExtraName, PackageName}; use uv_resolver::{ AnnotationStyle, DependencyMode, DisplayResolutionGraph, InMemoryIndex, Manifest, - OptionsBuilder, PreReleaseMode, ResolutionMode, Resolver, + OptionsBuilder, PreReleaseMode, PythonRequirement, ResolutionMode, Resolver, }; use uv_traits::{ConfigSettings, InFlight, NoBuild, SetupPyStrategy}; use uv_warnings::warn_user; @@ -244,8 +244,9 @@ pub(crate) async fn pip_compile( let downloader = Downloader::new(&cache, &tags, &client, &build_dispatch) .with_reporter(DownloadReporter::from(printer).with_length(editables.len() as u64)); + // Build all editables. let editable_wheel_dir = tempdir_in(cache.root())?; - let editable_metadata: Vec<_> = downloader + let editables: Vec<_> = downloader .build_editables(editables, editable_wheel_dir.path()) .await .context("Failed to build editables")? @@ -253,22 +254,33 @@ pub(crate) async fn pip_compile( .map(|built_editable| (built_editable.editable, built_editable.metadata)) .collect(); - let s = if editable_metadata.len() == 1 { - "" - } else { - "s" - }; + // Validate that the editables are compatible with the target Python version. + let requirement = PythonRequirement::new(&interpreter, &markers); + for (.., metadata) in &editables { + if let Some(python_requires) = metadata.requires_python.as_ref() { + if !python_requires.contains(requirement.target()) { + return Err(anyhow!( + "Editable `{}` requires Python {}, but resolution targets Python {}", + metadata.name, + python_requires, + requirement.target() + )); + } + } + } + + let s = if editables.len() == 1 { "" } else { "s" }; writeln!( printer, "{}", format!( "Built {} in {}", - format!("{} editable{}", editable_metadata.len(), s).bold(), + format!("{} editable{}", editables.len(), s).bold(), elapsed(start.elapsed()) ) .dimmed() )?; - editable_metadata + editables }; // Create a manifest of the requirements. diff --git a/crates/uv/src/commands/pip_install.rs b/crates/uv/src/commands/pip_install.rs index c2a1303e5..7230be949 100644 --- a/crates/uv/src/commands/pip_install.rs +++ b/crates/uv/src/commands/pip_install.rs @@ -215,6 +215,7 @@ pub(crate) async fn pip_install( &editables, editable_wheel_dir.path(), &cache, + &interpreter, tags, &client, &resolve_dispatch, @@ -356,10 +357,12 @@ fn specification( } /// Build a set of editable distributions. +#[allow(clippy::too_many_arguments)] async fn build_editables( editables: &[EditableRequirement], editable_wheel_dir: &Path, cache: &Cache, + interpreter: &Interpreter, tags: &Tags, client: &RegistryClient, build_dispatch: &BuildDispatch<'_>, @@ -389,6 +392,21 @@ async fn build_editables( .into_iter() .collect(); + // Validate that the editables are compatible with the target Python version. + for editable in &editables { + if let Some(python_requires) = editable.metadata.requires_python.as_ref() { + if !python_requires.contains(interpreter.python_version()) { + return Err(anyhow!( + "Editable `{}` requires Python {}, but {} is installed", + editable.metadata.name, + python_requires, + interpreter.python_version() + ) + .into()); + } + } + } + let s = if editables.len() == 1 { "" } else { "s" }; writeln!( printer, diff --git a/crates/uv/src/commands/pip_sync.rs b/crates/uv/src/commands/pip_sync.rs index 7134da732..4c824286e 100644 --- a/crates/uv/src/commands/pip_sync.rs +++ b/crates/uv/src/commands/pip_sync.rs @@ -1,6 +1,6 @@ use std::fmt::Write; -use anyhow::{Context, Result}; +use anyhow::{anyhow, Context, Result}; use itertools::Itertools; use owo_colors::OwoColorize; use tracing::debug; @@ -18,7 +18,7 @@ use uv_fs::Simplified; use uv_installer::{ is_dynamic, Downloader, NoBinary, Plan, Planner, Reinstall, ResolvedEditable, SitePackages, }; -use uv_interpreter::PythonEnvironment; +use uv_interpreter::{Interpreter, PythonEnvironment}; use uv_resolver::InMemoryIndex; use uv_traits::{ConfigSettings, InFlight, NoBuild, SetupPyStrategy}; @@ -151,7 +151,7 @@ pub(crate) async fn pip_sync( editables, &site_packages, reinstall, - &venv, + venv.interpreter(), tags, &cache, &client, @@ -413,7 +413,7 @@ async fn resolve_editables( editables: Vec, site_packages: &SitePackages<'_>, reinstall: &Reinstall, - venv: &PythonEnvironment, + interpreter: &Interpreter, tags: &Tags, cache: &Cache, client: &RegistryClient, @@ -479,7 +479,7 @@ async fn resolve_editables( } else { let start = std::time::Instant::now(); - let temp_dir = tempfile::tempdir_in(venv.root())?; + let temp_dir = tempfile::tempdir_in(cache.root())?; let downloader = Downloader::new(cache, tags, client, build_dispatch) .with_reporter(DownloadReporter::from(printer).with_length(uninstalled.len() as u64)); @@ -503,6 +503,20 @@ async fn resolve_editables( .into_iter() .collect(); + // Validate that the editables are compatible with the target Python version. + for editable in &built_editables { + if let Some(python_requires) = editable.metadata.requires_python.as_ref() { + if !python_requires.contains(interpreter.python_version()) { + return Err(anyhow!( + "Editable `{}` requires Python {}, but {} is installed", + editable.metadata.name, + python_requires, + interpreter.python_version() + )); + } + } + } + let s = if built_editables.len() == 1 { "" } else { "s" }; writeln!( printer, diff --git a/crates/uv/tests/pip_compile.rs b/crates/uv/tests/pip_compile.rs index 8feb270d5..ae2644783 100644 --- a/crates/uv/tests/pip_compile.rs +++ b/crates/uv/tests/pip_compile.rs @@ -4670,3 +4670,89 @@ fn expand_env_var_requirements_txt() -> Result<()> { Ok(()) } + +/// Raise an error when an editable's `Requires-Python` constraint is not met. +#[test] +fn requires_python_editable() -> Result<()> { + let context = TestContext::new("3.12"); + + // Create an editable package with a `Requires-Python` constraint that is not met. + let editable_dir = TempDir::new()?; + let pyproject_toml = editable_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#"[project] +name = "example" +version = "0.0.0" +dependencies = [ + "anyio==4.0.0" +] +requires-python = "<=3.8" +"#, + )?; + + // Write to a requirements file. + let requirements_in = context.temp_dir.child("requirements.in"); + requirements_in.write_str(&format!("-e {}", editable_dir.path().display()))?; + + uv_snapshot!(context.compile() + .arg("requirements.in"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Editable `example` requires Python <=3.8, but resolution targets Python 3.12.1 + "### + ); + + Ok(()) +} + +/// Raise an error when an editable's `Requires-Python` constraint is not met. +#[test] +fn requires_python_editable_target_version() -> Result<()> { + let context = TestContext::new("3.12"); + + // Create an editable package with a `Requires-Python` constraint that is not met. + let editable_dir = TempDir::new()?; + let pyproject_toml = editable_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#"[project] +name = "example" +version = "0.0.0" +dependencies = [ + "anyio==4.0.0" +] +requires-python = "<=3.8" +"#, + )?; + + // Write to a requirements file. + let requirements_in = context.temp_dir.child("requirements.in"); + requirements_in.write_str(&format!("-e {}", editable_dir.path().display()))?; + + let filters: Vec<_> = [ + // 3.11 may not be installed + ( + "warning: The requested Python version 3.11 is not available; .* will be used to build dependencies instead.\n", + "", + ), + ] + .into_iter() + .chain(INSTA_FILTERS.to_vec()) + .collect(); + + uv_snapshot!(filters, context.compile() + .arg("requirements.in") + .arg("--python-version=3.11"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Editable `example` requires Python <=3.8, but resolution targets Python 3.11 + "### + ); + + Ok(()) +} diff --git a/crates/uv/tests/pip_install.rs b/crates/uv/tests/pip_install.rs index c37ac0d9e..9bd24d3c1 100644 --- a/crates/uv/tests/pip_install.rs +++ b/crates/uv/tests/pip_install.rs @@ -2247,3 +2247,37 @@ requires-python = ">=3.11,<3.13" Ok(()) } + +/// Raise an error when an editable's `Requires-Python` constraint is not met. +#[test] +fn requires_python_editable() -> Result<()> { + let context = TestContext::new("3.12"); + + // Create an editable package with a `Requires-Python` constraint that is not met. + let editable_dir = assert_fs::TempDir::new()?; + let pyproject_toml = editable_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#"[project] +name = "example" +version = "0.0.0" +dependencies = [ + "anyio==4.0.0" +] +requires-python = "<=3.8" +"#, + )?; + + uv_snapshot!(command(&context) + .arg("--editable") + .arg(editable_dir.path()), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Editable `example` requires Python <=3.8, but 3.12.1 is installed + "### + ); + + Ok(()) +} diff --git a/crates/uv/tests/pip_sync.rs b/crates/uv/tests/pip_sync.rs index fe7b45ce5..338a98eb5 100644 --- a/crates/uv/tests/pip_sync.rs +++ b/crates/uv/tests/pip_sync.rs @@ -2956,3 +2956,40 @@ fn compile() -> Result<()> { Ok(()) } + +/// Raise an error when an editable's `Requires-Python` constraint is not met. +#[test] +fn requires_python_editable() -> Result<()> { + let context = TestContext::new("3.12"); + + // Create an editable package with a `Requires-Python` constraint that is not met. + let editable_dir = assert_fs::TempDir::new()?; + let pyproject_toml = editable_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#"[project] +name = "example" +version = "0.0.0" +dependencies = [ + "anyio==4.0.0" +] +requires-python = "<=3.5" +"#, + )?; + + // Write to a requirements file. + let requirements_in = context.temp_dir.child("requirements.in"); + requirements_in.write_str(&format!("-e {}", editable_dir.path().display()))?; + + uv_snapshot!(command(&context) + .arg("requirements.in"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Editable `example` requires Python <=3.5, but 3.12.1 is installed + "### + ); + + Ok(()) +}