diff --git a/crates/uv-distribution/src/metadata/dependency_groups.rs b/crates/uv-distribution/src/metadata/dependency_groups.rs index d12e0651d..61bbb78bf 100644 --- a/crates/uv-distribution/src/metadata/dependency_groups.rs +++ b/crates/uv-distribution/src/metadata/dependency_groups.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use uv_configuration::SourceStrategy; -use uv_distribution_types::{IndexLocations, Requirement}; +use uv_distribution_types::{IndexLocations, Requirement, RequiresPython}; use uv_normalize::{GroupName, PackageName}; use uv_workspace::dependency_groups::FlatDependencyGroups; use uv_workspace::pyproject::{Sources, ToolUvSources}; @@ -45,7 +45,13 @@ use crate::metadata::{GitWorkspaceMember, LoweredRequirement, MetadataError}; #[derive(Debug, Clone)] pub struct SourcedDependencyGroups { pub name: Option, - pub dependency_groups: BTreeMap>, + pub dependency_groups: BTreeMap, +} + +#[derive(Debug, Clone)] +pub struct SourcedDependencyGroup { + pub requirements: Box<[Requirement]>, + pub requires_python: Option, } impl SourcedDependencyGroups { @@ -98,12 +104,23 @@ impl SourcedDependencyGroups { dependency_groups: dependency_groups .into_iter() .map(|(name, group)| { + let requires_python = group + .requires_python + .as_ref() + .map(RequiresPython::from_specifiers); + let requirements = group .requirements .into_iter() .map(Requirement::from) .collect(); - (name, requirements) + ( + name, + SourcedDependencyGroup { + requirements, + requires_python, + }, + ) }) .collect(), }); @@ -138,6 +155,11 @@ impl SourcedDependencyGroups { let dependency_groups = dependency_groups .into_iter() .map(|(name, group)| { + let requires_python = group + .requires_python + .as_ref() + .map(RequiresPython::from_specifiers); + let requirements = group .requirements .into_iter() @@ -167,7 +189,13 @@ impl SourcedDependencyGroups { }) }) .collect::, _>>()?; - Ok::<(GroupName, Box<_>), MetadataError>((name, requirements)) + Ok::<(GroupName, SourcedDependencyGroup), MetadataError>(( + name, + SourcedDependencyGroup { + requirements, + requires_python, + }, + )) }) .collect::, _>>()?; diff --git a/crates/uv-resolver/src/lib.rs b/crates/uv-resolver/src/lib.rs index 00cb9732e..b37b291ce 100644 --- a/crates/uv-resolver/src/lib.rs +++ b/crates/uv-resolver/src/lib.rs @@ -15,7 +15,7 @@ pub use manifest::Manifest; pub use options::{Flexibility, Options, OptionsBuilder}; pub use preferences::{Preference, PreferenceError, Preferences}; pub use prerelease::PrereleaseMode; -pub use python_requirement::PythonRequirement; +pub use python_requirement::{PythonRequirement, PythonRequirementSource}; pub use resolution::{ AnnotationStyle, ConflictingDistributionError, DisplayResolutionGraph, ResolverOutput, }; diff --git a/crates/uv/src/commands/pip/operations.rs b/crates/uv/src/commands/pip/operations.rs index 118606ab8..bd699c8f2 100644 --- a/crates/uv/src/commands/pip/operations.rs +++ b/crates/uv/src/commands/pip/operations.rs @@ -27,6 +27,7 @@ use uv_fs::Simplified; use uv_install_wheel::LinkMode; use uv_installer::{InstallationStrategy, Plan, Planner, Preparer, SitePackages}; use uv_normalize::PackageName; +use uv_pep440::Operator; use uv_pep508::{MarkerEnvironment, RequirementOrigin}; use uv_platform_tags::Tags; use uv_preview::Preview; @@ -235,13 +236,62 @@ pub(crate) async fn resolve( // Apply dependency-groups for (group_name, group) in &metadata.dependency_groups { if groups.contains(group_name) { - requirements.extend(group.iter().cloned().map(|group| Requirement { - origin: Some(RequirementOrigin::Group( - pyproject_path.clone(), - metadata.name.clone(), - group_name.clone(), - )), - ..group + if let Some(requires_python) = group.requires_python.as_ref() { + if !python_requirement + .target() + .is_contained_by(requires_python.specifiers()) + { + let required_spec = requires_python.specifiers().to_string(); + let active_spec = python_requirement.target().specifiers().to_string(); + let interpreter_version = python_requirement.exact().to_string(); + + let suggested_version = requires_python + .range() + .lower() + .specifier() + .and_then(|specifier| { + let (operator, version) = specifier.into_parts(); + + match operator { + Operator::GreaterThanEqual + | Operator::Equal + | Operator::ExactEqual + | Operator::EqualStar + | Operator::TildeEqual => Some(version), + _ => None, + } + }); + + let mut message = format!( + "Dependency group `{group_name}` in `{}` requires Python `{required_spec}`, but uv is resolving for Python `{active_spec}` (current interpreter: `{interpreter_version}`).", + pyproject_path.user_display() + ); + + if let Some(ref version) = suggested_version { + let suggested_python = version.to_string(); + + write!( + message, + "{}", + &format!( + " Re-run with `--python {suggested_python}` to target a compatible Python version." + ) + )?; + } + + return Err(anyhow!(message).into()); + } + } + + requirements.extend(group.requirements.iter().cloned().map(|group| { + Requirement { + origin: Some(RequirementOrigin::Group( + pyproject_path.clone(), + metadata.name.clone(), + group_name.clone(), + )), + ..group + } })); } } diff --git a/crates/uv/tests/it/pip_compile.rs b/crates/uv/tests/it/pip_compile.rs index 8a7b9f8fb..296ca76f9 100644 --- a/crates/uv/tests/it/pip_compile.rs +++ b/crates/uv/tests/it/pip_compile.rs @@ -16070,6 +16070,71 @@ fn project_and_group_workspace() -> Result<()> { Ok(()) } +#[test] +fn group_requires_python_incompatible_with_interpreter() -> Result<()> { + let context = TestContext::new("3.13"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "requires-python" + version = "0.1.0" + + [tool.uv.dependency-groups.ml1] + requires-python = ">=3.14" + + [dependency-groups] + ml1 = ["tqdm"] + "#, + )?; + + uv_snapshot!(context.filters(), context.pip_compile() + .arg("--group").arg("pyproject.toml:ml1"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Dependency group `ml1` in `pyproject.toml` requires Python `>=3.14`, but uv is resolving for Python `>=3.13.[X]` (current interpreter: `3.13.[X]`). Re-run with `--python 3.14` to target a compatible Python version. + "); + + Ok(()) +} + +#[test] +fn group_requires_python_incompatible_with_python_flag() -> 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 = "requires-python" + version = "0.1.0" + + [tool.uv.dependency-groups.ml1] + requires-python = ">=3.13" + + [dependency-groups] + ml1 = ["tqdm"] + "#, + )?; + + uv_snapshot!(context.filters(), context.pip_compile() + .arg("--group").arg("pyproject.toml:ml1") + .arg("--python").arg("3.12"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Dependency group `ml1` in `pyproject.toml` requires Python `>=3.13`, but uv is resolving for Python `>=3.12` (current interpreter: `3.12.[X]`). Re-run with `--python 3.13` to target a compatible Python version. + "); + + Ok(()) +} + #[test] fn directory_and_group() -> Result<()> { // Checking that --directory is handled properly with --group