Skip to content

Commit be5f725

Browse files
committed
Error when workspace contains conflicting Python requirements
1 parent e02f061 commit be5f725

File tree

9 files changed

+113
-19
lines changed

9 files changed

+113
-19
lines changed

crates/uv-resolver/src/requires_python.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ impl RequiresPython {
7575
}
7676
})?;
7777

78+
// If the intersection is empty, return `None`.
79+
if range.is_empty() {
80+
return None;
81+
}
82+
7883
// Convert back to PEP 440 specifiers.
7984
let specifiers = VersionSpecifiers::from_release_only_bounds(range.iter());
8085

crates/uv-workspace/src/workspace.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use tracing::{debug, trace, warn};
99
use uv_distribution_types::Index;
1010
use uv_fs::{Simplified, CWD};
1111
use uv_normalize::{GroupName, PackageName, DEV_DEPENDENCIES};
12+
use uv_pep440::VersionSpecifiers;
1213
use uv_pep508::{MarkerTree, VerbatimUrl};
1314
use uv_pypi_types::{
1415
Conflicts, Requirement, RequirementSource, SupportedEnvironments, VerbatimParsedUrl,
@@ -383,6 +384,18 @@ impl Workspace {
383384
conflicting
384385
}
385386

387+
/// Returns an iterator over the `requires-python` values for each member of the workspace.
388+
pub fn requires_python(&self) -> impl Iterator<Item = (&PackageName, &VersionSpecifiers)> {
389+
self.packages().iter().filter_map(|(name, member)| {
390+
member
391+
.pyproject_toml()
392+
.project
393+
.as_ref()
394+
.and_then(|project| project.requires_python.as_ref())
395+
.map(|requires_python| (name, requires_python))
396+
})
397+
}
398+
386399
/// Returns any requirements that are exclusive to the workspace root, i.e., not included in
387400
/// any of the workspace members.
388401
///

crates/uv/src/commands/build_frontend.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use thiserror::Error;
1111
use tracing::instrument;
1212

1313
use crate::commands::pip::operations;
14-
use crate::commands::project::find_requires_python;
14+
use crate::commands::project::{find_requires_python, ProjectError};
1515
use crate::commands::reporters::PythonDownloadReporter;
1616
use crate::commands::ExitStatus;
1717
use crate::printer::Printer;
@@ -69,6 +69,8 @@ enum Error {
6969
BuildDispatch(AnyErrorBuild),
7070
#[error(transparent)]
7171
BuildFrontend(#[from] uv_build_frontend::Error),
72+
#[error(transparent)]
73+
Project(#[from] ProjectError),
7274
#[error("Failed to write message")]
7375
Fmt(#[from] fmt::Error),
7476
#[error("Can't use `--force-pep517` with `--list`")]
@@ -464,7 +466,7 @@ async fn build_package(
464466
// (3) `Requires-Python` in `pyproject.toml`
465467
if interpreter_request.is_none() {
466468
if let Ok(workspace) = workspace {
467-
interpreter_request = find_requires_python(workspace)
469+
interpreter_request = find_requires_python(workspace)?
468470
.as_ref()
469471
.map(RequiresPython::specifiers)
470472
.map(|specifiers| {

crates/uv/src/commands/project/init.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -499,7 +499,12 @@ async fn init_project(
499499
};
500500

501501
(requires_python, python_request)
502-
} else if let Some(requires_python) = workspace.as_ref().and_then(find_requires_python) {
502+
} else if let Some(requires_python) = workspace
503+
.as_ref()
504+
.map(find_requires_python)
505+
.transpose()?
506+
.flatten()
507+
{
503508
// (3) `requires-python` from the workspace
504509
debug!("Using Python version from project workspace");
505510
let python_request = PythonRequest::Version(VersionRequest::Range(

crates/uv/src/commands/project/lock.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,7 @@ async fn do_lock(
418418

419419
// Determine the supported Python range. If no range is defined, and warn and default to the
420420
// current minor version.
421-
let requires_python = target.requires_python();
421+
let requires_python = target.requires_python()?;
422422

423423
let requires_python = if let Some(requires_python) = requires_python {
424424
if requires_python.is_unbounded() {

crates/uv/src/commands/project/lock_target.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,14 +188,15 @@ impl<'lock> LockTarget<'lock> {
188188
}
189189

190190
/// Return the `Requires-Python` bound for the [`LockTarget`].
191-
pub(crate) fn requires_python(self) -> Option<RequiresPython> {
191+
#[allow(clippy::result_large_err)]
192+
pub(crate) fn requires_python(self) -> Result<Option<RequiresPython>, ProjectError> {
192193
match self {
193194
Self::Workspace(workspace) => find_requires_python(workspace),
194-
Self::Script(script) => script
195+
Self::Script(script) => Ok(script
195196
.metadata
196197
.requires_python
197198
.as_ref()
198-
.map(RequiresPython::from_specifiers),
199+
.map(RequiresPython::from_specifiers)),
199200
}
200201
}
201202

crates/uv/src/commands/project/mod.rs

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::collections::BTreeSet;
1+
use std::collections::{BTreeMap, BTreeSet};
22
use std::fmt::Write;
33
use std::path::{Path, PathBuf};
44
use std::sync::Arc;
@@ -155,6 +155,9 @@ pub(crate) enum ProjectError {
155155
#[error("Environment markers `{0}` don't overlap with Python requirement `{1}`")]
156156
DisjointEnvironment(MarkerTreeContents, VersionSpecifiers),
157157

158+
#[error("The workspace contains conflicting Python requirements:\n{}", _0.iter().map(|(name, specifiers)| format!("- `{name}`: `{specifiers}`")).join("\n"))]
159+
DisjointRequiresPython(BTreeMap<PackageName, VersionSpecifiers>),
160+
158161
#[error("Environment marker is empty")]
159162
EmptyEnvironment,
160163

@@ -317,14 +320,27 @@ impl std::error::Error for ConflictError {}
317320
///
318321
/// For a [`Workspace`] with multiple packages, the `Requires-Python` bound is the union of the
319322
/// `Requires-Python` bounds of all the packages.
320-
pub(crate) fn find_requires_python(workspace: &Workspace) -> Option<RequiresPython> {
321-
RequiresPython::intersection(workspace.packages().values().filter_map(|member| {
322-
member
323-
.pyproject_toml()
324-
.project
325-
.as_ref()
326-
.and_then(|project| project.requires_python.as_ref())
327-
}))
323+
#[allow(clippy::result_large_err)]
324+
pub(crate) fn find_requires_python(
325+
workspace: &Workspace,
326+
) -> Result<Option<RequiresPython>, ProjectError> {
327+
// If there are no `Requires-Python` specifiers in the workspace, return `None`.
328+
if workspace.requires_python().next().is_none() {
329+
return Ok(None);
330+
}
331+
match RequiresPython::intersection(
332+
workspace
333+
.requires_python()
334+
.map(|(.., specifiers)| specifiers),
335+
) {
336+
Some(requires_python) => Ok(Some(requires_python)),
337+
None => Err(ProjectError::DisjointRequiresPython(
338+
workspace
339+
.requires_python()
340+
.map(|(name, specifiers)| (name.clone(), specifiers.clone()))
341+
.collect(),
342+
)),
343+
}
328344
}
329345

330346
/// Returns an error if the [`Interpreter`] does not satisfy the [`Workspace`] `requires-python`.
@@ -732,7 +748,7 @@ impl WorkspacePython {
732748
project_dir: &Path,
733749
no_config: bool,
734750
) -> Result<Self, ProjectError> {
735-
let requires_python = workspace.and_then(find_requires_python);
751+
let requires_python = workspace.map(find_requires_python).transpose()?.flatten();
736752

737753
let workspace_root = workspace.map(Workspace::install_path);
738754

crates/uv/src/commands/python/pin.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,15 +256,15 @@ fn assert_pin_compatible_with_project(pin: &Pin, virtual_project: &VirtualProjec
256256
project_workspace.project_name(),
257257
project_workspace.workspace().install_path().display()
258258
);
259-
let requires_python = find_requires_python(project_workspace.workspace());
259+
let requires_python = find_requires_python(project_workspace.workspace())?;
260260
(requires_python, "project")
261261
}
262262
VirtualProject::NonProject(workspace) => {
263263
debug!(
264264
"Discovered virtual workspace at: {}",
265265
workspace.install_path().display()
266266
);
267-
let requires_python = find_requires_python(workspace);
267+
let requires_python = find_requires_python(workspace)?;
268268
(requires_python, "workspace")
269269
}
270270
};

crates/uv/tests/it/lock.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5242,6 +5242,58 @@ fn lock_requires_python_unbounded() -> Result<()> {
52425242
Ok(())
52435243
}
52445244

5245+
/// Error if `Requires-Python` is disjoint across the workspace.
5246+
#[test]
5247+
fn lock_requires_python_disjoint() -> Result<()> {
5248+
let context = TestContext::new("3.11");
5249+
5250+
let pyproject_toml = context.temp_dir.child("pyproject.toml");
5251+
pyproject_toml.write_str(
5252+
r#"
5253+
[project]
5254+
name = "project"
5255+
version = "0.1.0"
5256+
requires-python = ">=3.12"
5257+
dependencies = []
5258+
5259+
[build-system]
5260+
requires = ["setuptools>=42"]
5261+
build-backend = "setuptools.build_meta"
5262+
5263+
[tool.uv.workspace]
5264+
members = ["child"]
5265+
"#,
5266+
)?;
5267+
5268+
let pyproject_toml = context.temp_dir.child("child").child("pyproject.toml");
5269+
pyproject_toml.write_str(
5270+
r#"
5271+
[project]
5272+
name = "child"
5273+
version = "0.1.0"
5274+
requires-python = "==3.10"
5275+
dependencies = []
5276+
5277+
[build-system]
5278+
requires = ["setuptools>=42"]
5279+
build-backend = "setuptools.build_meta"
5280+
"#,
5281+
)?;
5282+
5283+
uv_snapshot!(context.filters(), context.lock(), @r###"
5284+
success: false
5285+
exit_code: 2
5286+
----- stdout -----
5287+
5288+
----- stderr -----
5289+
error: The workspace contains conflicting Python requirements:
5290+
- `child`: `==3.10`
5291+
- `project`: `>=3.12`
5292+
"###);
5293+
5294+
Ok(())
5295+
}
5296+
52455297
#[test]
52465298
fn lock_requires_python_maximum_version() -> Result<()> {
52475299
let context = TestContext::new("3.11");

0 commit comments

Comments
 (0)