diff --git a/crates/uv-resolver/src/requires_python.rs b/crates/uv-resolver/src/requires_python.rs index 668d512e4..ff5be043a 100644 --- a/crates/uv-resolver/src/requires_python.rs +++ b/crates/uv-resolver/src/requires_python.rs @@ -75,6 +75,11 @@ impl RequiresPython { } })?; + // If the intersection is empty, return `None`. + if range.is_empty() { + return None; + } + // Convert back to PEP 440 specifiers. let specifiers = VersionSpecifiers::from_release_only_bounds(range.iter()); diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 19b6d74bf..b974ab018 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -9,6 +9,7 @@ use tracing::{debug, trace, warn}; use uv_distribution_types::Index; use uv_fs::{Simplified, CWD}; use uv_normalize::{GroupName, PackageName, DEV_DEPENDENCIES}; +use uv_pep440::VersionSpecifiers; use uv_pep508::{MarkerTree, VerbatimUrl}; use uv_pypi_types::{ Conflicts, Requirement, RequirementSource, SupportedEnvironments, VerbatimParsedUrl, @@ -383,6 +384,18 @@ impl Workspace { conflicting } + /// Returns an iterator over the `requires-python` values for each member of the workspace. + pub fn requires_python(&self) -> impl Iterator { + self.packages().iter().filter_map(|(name, member)| { + member + .pyproject_toml() + .project + .as_ref() + .and_then(|project| project.requires_python.as_ref()) + .map(|requires_python| (name, requires_python)) + }) + } + /// Returns any requirements that are exclusive to the workspace root, i.e., not included in /// any of the workspace members. /// diff --git a/crates/uv/src/commands/build_frontend.rs b/crates/uv/src/commands/build_frontend.rs index 0569b72f1..7901fe56b 100644 --- a/crates/uv/src/commands/build_frontend.rs +++ b/crates/uv/src/commands/build_frontend.rs @@ -11,7 +11,7 @@ use thiserror::Error; use tracing::instrument; use crate::commands::pip::operations; -use crate::commands::project::find_requires_python; +use crate::commands::project::{find_requires_python, ProjectError}; use crate::commands::reporters::PythonDownloadReporter; use crate::commands::ExitStatus; use crate::printer::Printer; @@ -69,6 +69,8 @@ enum Error { BuildDispatch(AnyErrorBuild), #[error(transparent)] BuildFrontend(#[from] uv_build_frontend::Error), + #[error(transparent)] + Project(#[from] ProjectError), #[error("Failed to write message")] Fmt(#[from] fmt::Error), #[error("Can't use `--force-pep517` with `--list`")] @@ -464,7 +466,7 @@ async fn build_package( // (3) `Requires-Python` in `pyproject.toml` if interpreter_request.is_none() { if let Ok(workspace) = workspace { - interpreter_request = find_requires_python(workspace) + interpreter_request = find_requires_python(workspace)? .as_ref() .map(RequiresPython::specifiers) .map(|specifiers| { diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index 75f8008a6..e95568830 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -499,7 +499,12 @@ async fn init_project( }; (requires_python, python_request) - } else if let Some(requires_python) = workspace.as_ref().and_then(find_requires_python) { + } else if let Some(requires_python) = workspace + .as_ref() + .map(find_requires_python) + .transpose()? + .flatten() + { // (3) `requires-python` from the workspace debug!("Using Python version from project workspace"); let python_request = PythonRequest::Version(VersionRequest::Range( diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index efa4e5b3a..7e3a3c0ed 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -418,7 +418,7 @@ async fn do_lock( // Determine the supported Python range. If no range is defined, and warn and default to the // current minor version. - let requires_python = target.requires_python(); + let requires_python = target.requires_python()?; let requires_python = if let Some(requires_python) = requires_python { if requires_python.is_unbounded() { diff --git a/crates/uv/src/commands/project/lock_target.rs b/crates/uv/src/commands/project/lock_target.rs index e81ca37e7..8db608bd9 100644 --- a/crates/uv/src/commands/project/lock_target.rs +++ b/crates/uv/src/commands/project/lock_target.rs @@ -188,14 +188,15 @@ impl<'lock> LockTarget<'lock> { } /// Return the `Requires-Python` bound for the [`LockTarget`]. - pub(crate) fn requires_python(self) -> Option { + #[allow(clippy::result_large_err)] + pub(crate) fn requires_python(self) -> Result, ProjectError> { match self { Self::Workspace(workspace) => find_requires_python(workspace), - Self::Script(script) => script + Self::Script(script) => Ok(script .metadata .requires_python .as_ref() - .map(RequiresPython::from_specifiers), + .map(RequiresPython::from_specifiers)), } } diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 74cfbe5ff..d42643501 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -155,6 +155,9 @@ pub(crate) enum ProjectError { #[error("Environment markers `{0}` don't overlap with Python requirement `{1}`")] DisjointEnvironment(MarkerTreeContents, VersionSpecifiers), + #[error("The workspace contains conflicting Python requirements:\n{}", _0.iter().map(|(name, specifiers)| format!("- `{name}`: `{specifiers}`")).join("\n"))] + DisjointRequiresPython(BTreeMap), + #[error("Environment marker is empty")] EmptyEnvironment, @@ -317,14 +320,27 @@ impl std::error::Error for ConflictError {} /// /// For a [`Workspace`] with multiple packages, the `Requires-Python` bound is the union of the /// `Requires-Python` bounds of all the packages. -pub(crate) fn find_requires_python(workspace: &Workspace) -> Option { - RequiresPython::intersection(workspace.packages().values().filter_map(|member| { - member - .pyproject_toml() - .project - .as_ref() - .and_then(|project| project.requires_python.as_ref()) - })) +#[allow(clippy::result_large_err)] +pub(crate) fn find_requires_python( + workspace: &Workspace, +) -> Result, ProjectError> { + // If there are no `Requires-Python` specifiers in the workspace, return `None`. + if workspace.requires_python().next().is_none() { + return Ok(None); + } + match RequiresPython::intersection( + workspace + .requires_python() + .map(|(.., specifiers)| specifiers), + ) { + Some(requires_python) => Ok(Some(requires_python)), + None => Err(ProjectError::DisjointRequiresPython( + workspace + .requires_python() + .map(|(name, specifiers)| (name.clone(), specifiers.clone())) + .collect(), + )), + } } /// Returns an error if the [`Interpreter`] does not satisfy the [`Workspace`] `requires-python`. @@ -732,7 +748,7 @@ impl WorkspacePython { project_dir: &Path, no_config: bool, ) -> Result { - let requires_python = workspace.and_then(find_requires_python); + let requires_python = workspace.map(find_requires_python).transpose()?.flatten(); let workspace_root = workspace.map(Workspace::install_path); diff --git a/crates/uv/src/commands/python/pin.rs b/crates/uv/src/commands/python/pin.rs index a16439e6b..1c07b24d0 100644 --- a/crates/uv/src/commands/python/pin.rs +++ b/crates/uv/src/commands/python/pin.rs @@ -256,7 +256,7 @@ fn assert_pin_compatible_with_project(pin: &Pin, virtual_project: &VirtualProjec project_workspace.project_name(), project_workspace.workspace().install_path().display() ); - let requires_python = find_requires_python(project_workspace.workspace()); + let requires_python = find_requires_python(project_workspace.workspace())?; (requires_python, "project") } VirtualProject::NonProject(workspace) => { @@ -264,7 +264,7 @@ fn assert_pin_compatible_with_project(pin: &Pin, virtual_project: &VirtualProjec "Discovered virtual workspace at: {}", workspace.install_path().display() ); - let requires_python = find_requires_python(workspace); + let requires_python = find_requires_python(workspace)?; (requires_python, "workspace") } }; diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index dc09dde55..784aaf808 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -5242,6 +5242,58 @@ fn lock_requires_python_unbounded() -> Result<()> { Ok(()) } +/// Error if `Requires-Python` is disjoint across the workspace. +#[test] +fn lock_requires_python_disjoint() -> Result<()> { + let context = TestContext::new("3.11"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + + [tool.uv.workspace] + members = ["child"] + "#, + )?; + + let pyproject_toml = context.temp_dir.child("child").child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "child" + version = "0.1.0" + requires-python = "==3.10" + dependencies = [] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: The workspace contains conflicting Python requirements: + - `child`: `==3.10` + - `project`: `>=3.12` + "###); + + Ok(()) +} + #[test] fn lock_requires_python_maximum_version() -> Result<()> { let context = TestContext::new("3.11");