diff --git a/crates/uv-resolver/src/requires_python.rs b/crates/uv-resolver/src/requires_python.rs index 3543d656176f..2656b331e18d 100644 --- a/crates/uv-resolver/src/requires_python.rs +++ b/crates/uv-resolver/src/requires_python.rs @@ -7,6 +7,7 @@ use pubgrub::Range; use distribution_filename::WheelFilename; use pep440_rs::{Version, VersionSpecifier, VersionSpecifiers}; +use pep508_rs::{MarkerExpression, MarkerTree, MarkerValueVersion}; #[derive(thiserror::Error, Debug)] pub enum RequiresPythonError { @@ -124,6 +125,85 @@ impl RequiresPython { } } + /// Returns the [`RequiresPython`] as a [`MarkerTree`]. + pub fn markers(&self) -> MarkerTree { + match (self.range.0.as_ref(), self.range.0.as_ref()) { + (Bound::Included(lower), Bound::Included(upper)) => { + let mut lower = MarkerTree::expression(MarkerExpression::Version { + key: MarkerValueVersion::PythonFullVersion, + specifier: VersionSpecifier::greater_than_equal_version(lower.clone()), + }); + let upper = MarkerTree::expression(MarkerExpression::Version { + key: MarkerValueVersion::PythonFullVersion, + specifier: VersionSpecifier::less_than_equal_version(upper.clone()), + }); + lower.and(upper); + lower + } + (Bound::Included(lower), Bound::Excluded(upper)) => { + let mut lower = MarkerTree::expression(MarkerExpression::Version { + key: MarkerValueVersion::PythonFullVersion, + specifier: VersionSpecifier::greater_than_equal_version(lower.clone()), + }); + let upper = MarkerTree::expression(MarkerExpression::Version { + key: MarkerValueVersion::PythonFullVersion, + specifier: VersionSpecifier::less_than_version(upper.clone()), + }); + lower.and(upper); + lower + } + (Bound::Excluded(lower), Bound::Included(upper)) => { + let mut lower = MarkerTree::expression(MarkerExpression::Version { + key: MarkerValueVersion::PythonFullVersion, + specifier: VersionSpecifier::greater_than_version(lower.clone()), + }); + let upper = MarkerTree::expression(MarkerExpression::Version { + key: MarkerValueVersion::PythonFullVersion, + specifier: VersionSpecifier::less_than_equal_version(upper.clone()), + }); + lower.and(upper); + lower + } + (Bound::Excluded(lower), Bound::Excluded(upper)) => { + let mut lower = MarkerTree::expression(MarkerExpression::Version { + key: MarkerValueVersion::PythonFullVersion, + specifier: VersionSpecifier::greater_than_version(lower.clone()), + }); + let upper = MarkerTree::expression(MarkerExpression::Version { + key: MarkerValueVersion::PythonFullVersion, + specifier: VersionSpecifier::less_than_version(upper.clone()), + }); + lower.and(upper); + lower + } + (Bound::Unbounded, Bound::Unbounded) => MarkerTree::TRUE, + (Bound::Unbounded, Bound::Included(upper)) => { + MarkerTree::expression(MarkerExpression::Version { + key: MarkerValueVersion::PythonFullVersion, + specifier: VersionSpecifier::less_than_equal_version(upper.clone()), + }) + } + (Bound::Unbounded, Bound::Excluded(upper)) => { + MarkerTree::expression(MarkerExpression::Version { + key: MarkerValueVersion::PythonFullVersion, + specifier: VersionSpecifier::less_than_version(upper.clone()), + }) + } + (Bound::Included(lower), Bound::Unbounded) => { + MarkerTree::expression(MarkerExpression::Version { + key: MarkerValueVersion::PythonFullVersion, + specifier: VersionSpecifier::greater_than_equal_version(lower.clone()), + }) + } + (Bound::Excluded(lower), Bound::Unbounded) => { + MarkerTree::expression(MarkerExpression::Version { + key: MarkerValueVersion::PythonFullVersion, + specifier: VersionSpecifier::greater_than_version(lower.clone()), + }) + } + } + } + /// Returns `true` if the `Requires-Python` is compatible with the given version. pub fn contains(&self, version: &Version) -> bool { let version = version.only_release(); diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 5728c4b4c036..a7dee655a37e 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -325,6 +325,25 @@ async fn do_lock( default }; + // If any of the forks are incompatible with the Python requirement, error. + for environment in environments + .map(SupportedEnvironments::as_markers) + .into_iter() + .flatten() + { + if requires_python.markers().is_disjoint(environment) { + return if let Some(contents) = environment.contents() { + Err(ProjectError::DisjointEnvironment( + contents, + requires_python.specifiers().clone(), + )) + } else { + Err(ProjectError::EmptyEnvironment) + }; + } + } + + // Determine the Python requirement. let python_requirement = PythonRequirement::from_requires_python(interpreter, &requires_python); // Add all authenticated sources to the cache. diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 47953bc1cf02..15285063fa20 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -7,6 +7,7 @@ use tracing::debug; use distribution_types::{Resolution, UnresolvedRequirementSpecification}; use pep440_rs::{Version, VersionSpecifiers}; +use pep508_rs::MarkerTreeContents; use pypi_types::Requirement; use uv_auth::store_credentials_from_url; use uv_cache::Cache; @@ -77,6 +78,12 @@ pub(crate) enum ProjectError { #[error("Supported environments must be disjoint, but the following markers overlap: `{0}` and `{1}`.\n\n{hint}{colon} replace `{1}` with `{2}`.", hint = "hint".bold().cyan(), colon = ":".bold())] OverlappingMarkers(String, String, String), + #[error("Environment markers `{0}` don't overlap with Python requirement `{1}`")] + DisjointEnvironment(MarkerTreeContents, VersionSpecifiers), + + #[error("Environment marker is empty")] + EmptyEnvironment, + #[error(transparent)] Python(#[from] uv_python::Error), diff --git a/crates/uv/tests/lock.rs b/crates/uv/tests/lock.rs index 771fd4661132..edde5dd37058 100644 --- a/crates/uv/tests/lock.rs +++ b/crates/uv/tests/lock.rs @@ -11962,3 +11962,37 @@ fn lock_implicit_virtual_path() -> Result<()> { Ok(()) } + +#[test] +fn lock_conflicting_environment() -> Result<()> { + let context = TestContext::new("3.12"); + + 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 = ["anyio"] + + [build-system] + requires = ["setuptools>=42", "wheel"] + build-backend = "setuptools.build_meta" + + [tool.uv] + environments = ["python_version < '3.11'"] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Environment markers `python_full_version < '3.11'` don't overlap with Python requirement `>=3.12` + "###); + + Ok(()) +}