Error when workspace contains conflicting Python requirements (#10841)

## Summary

If members define disjoint Python requirements, we should error. Right
now, it seems that it maps to unbounded and leads to weird behavior.

Closes https://github.com/astral-sh/uv/issues/10835.
This commit is contained in:
Charlie Marsh 2025-01-22 12:22:52 -05:00 committed by GitHub
parent 1372c4e6de
commit 434706389b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 113 additions and 19 deletions

View file

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

View file

@ -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<Item = (&PackageName, &VersionSpecifiers)> {
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.
///

View file

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

View file

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

View file

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

View file

@ -188,14 +188,15 @@ impl<'lock> LockTarget<'lock> {
}
/// Return the `Requires-Python` bound for the [`LockTarget`].
pub(crate) fn requires_python(self) -> Option<RequiresPython> {
#[allow(clippy::result_large_err)]
pub(crate) fn requires_python(self) -> Result<Option<RequiresPython>, 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)),
}
}

View file

@ -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<PackageName, VersionSpecifiers>),
#[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> {
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<Option<RequiresPython>, 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<Self, ProjectError> {
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);

View file

@ -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")
}
};

View file

@ -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");