mirror of
https://github.com/astral-sh/uv.git
synced 2025-08-04 19:08:04 +00:00
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:
parent
1372c4e6de
commit
434706389b
9 changed files with 113 additions and 19 deletions
|
@ -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());
|
||||
|
||||
|
|
|
@ -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.
|
||||
///
|
||||
|
|
|
@ -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| {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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");
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue