diff --git a/crates/distribution-types/src/file.rs b/crates/distribution-types/src/file.rs index 4e0530477..0516ddf4f 100644 --- a/crates/distribution-types/src/file.rs +++ b/crates/distribution-types/src/file.rs @@ -11,7 +11,7 @@ use pypi_types::{CoreMetadata, HashDigest, Yanked}; /// Error converting [`pypi_types::File`] to [`distribution_type::File`]. #[derive(Debug, thiserror::Error)] pub enum FileConversionError { - #[error("Failed to parse 'requires-python': `{0}`")] + #[error("Failed to parse `requires-python`: `{0}`")] RequiresPython(String, #[source] VersionSpecifiersParseError), #[error("Failed to parse URL: {0}")] Url(String, #[source] url::ParseError), diff --git a/crates/uv-resolver/src/requires_python.rs b/crates/uv-resolver/src/requires_python.rs index 226242e7f..17f99f808 100644 --- a/crates/uv-resolver/src/requires_python.rs +++ b/crates/uv-resolver/src/requires_python.rs @@ -21,14 +21,20 @@ pub enum RequiresPythonError { /// /// See: #[derive(Debug, Clone, Eq, PartialEq, Hash)] -pub struct RequiresPython(VersionSpecifiers); +pub struct RequiresPython { + specifiers: VersionSpecifiers, + bound: Bound, +} impl RequiresPython { /// Returns a [`RequiresPython`] to express `>=` equality with the given version. pub fn greater_than_equal_version(version: Version) -> Self { - Self(VersionSpecifiers::from( - VersionSpecifier::greater_than_equal_version(version), - )) + Self { + specifiers: VersionSpecifiers::from(VersionSpecifier::greater_than_equal_version( + version.clone(), + )), + bound: Bound::Included(version), + } } /// Returns a [`RequiresPython`] to express the union of the given version specifiers. @@ -53,20 +59,25 @@ impl RequiresPython { return Ok(None); }; - // Convert back to PEP 440 specifiers. - let requires_python = Self( - range - .iter() - .flat_map(VersionSpecifier::from_bounds) - .collect(), - ); + // Extract the lower bound. + let bound = range + .iter() + .next() + .map(|(lower, _)| lower.clone()) + .unwrap_or(Bound::Unbounded); - Ok(Some(requires_python)) + // Convert back to PEP 440 specifiers. + let specifiers = range + .iter() + .flat_map(VersionSpecifier::from_bounds) + .collect(); + + Ok(Some(Self { specifiers, bound })) } /// Returns `true` if the `Requires-Python` is compatible with the given version. pub fn contains(&self, version: &Version) -> bool { - self.0.contains(version) + self.specifiers.contains(version) } /// Returns `true` if the `Requires-Python` is compatible with the given version specifiers. @@ -76,24 +87,14 @@ impl RequiresPython { /// provided range. However, `>=3.9` would not be considered compatible, as the /// `Requires-Python` includes Python 3.8, but `>=3.9` does not. pub fn is_contained_by(&self, target: &VersionSpecifiers) -> bool { - let Ok(requires_python) = crate::pubgrub::PubGrubSpecifier::try_from(&self.0) else { - return false; - }; - let Ok(target) = crate::pubgrub::PubGrubSpecifier::try_from(target) else { return false; }; - - // If the dependency has no lower bound, then it supports all versions. - let Some((target_lower, _)) = target.iter().next() else { - return true; - }; - - // If we have no lower bound, then there must be versions we support that the - // dependency does not. - let Some((requires_python_lower, _)) = requires_python.iter().next() else { - return false; - }; + let target = target + .iter() + .next() + .map(|(lower, _)| lower) + .unwrap_or(&Bound::Unbounded); // We want, e.g., `requires_python_lower` to be `>=3.8` and `version_lower` to be // `>=3.7`. @@ -138,7 +139,7 @@ impl RequiresPython { // Alternatively, we could vary the semantics depending on whether or not the user included // a pre-release in their specifier, enforcing pre-release compatibility only if the user // explicitly requested it. - match (target_lower, requires_python_lower) { + match (target, &self.bound) { (Bound::Included(target_lower), Bound::Included(requires_python_lower)) => { target_lower.release() <= requires_python_lower.release() } @@ -161,25 +162,36 @@ impl RequiresPython { /// Returns the [`VersionSpecifiers`] for the `Requires-Python` specifier. pub fn specifiers(&self) -> &VersionSpecifiers { - &self.0 + &self.specifiers + } + + /// Returns the lower [`Bound`] for the `Requires-Python` specifier. + pub fn bound(&self) -> &Bound { + &self.bound } } impl std::fmt::Display for RequiresPython { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - std::fmt::Display::fmt(&self.0, f) + std::fmt::Display::fmt(&self.specifiers, f) } } impl serde::Serialize for RequiresPython { fn serialize(&self, serializer: S) -> Result { - self.0.serialize(serializer) + self.specifiers.serialize(serializer) } } impl<'de> serde::Deserialize<'de> for RequiresPython { fn deserialize>(deserializer: D) -> Result { let specifiers = VersionSpecifiers::deserialize(deserializer)?; - Ok(Self(specifiers)) + let bound = crate::pubgrub::PubGrubSpecifier::try_from(&specifiers) + .map_err(serde::de::Error::custom)? + .iter() + .next() + .map(|(lower, _)| lower.clone()) + .unwrap_or(Bound::Unbounded); + Ok(Self { specifiers, bound }) } } diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 6ac5ff0af..993ad84d0 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -1,3 +1,5 @@ +use std::collections::Bound; + use anstream::eprint; use distribution_types::{IndexLocations, UnresolvedRequirementSpecification}; @@ -128,7 +130,7 @@ pub(super) async fn do_lock( // // For a workspace, we compute the union of all workspace requires-python values, ensuring we // keep track of `None` vs. a full range. - let requires_python_workspace = + let requires_python = RequiresPython::union(workspace.packages().values().filter_map(|member| { member .pyproject_toml() @@ -137,21 +139,32 @@ pub(super) async fn do_lock( .and_then(|project| project.requires_python.as_ref()) }))?; - let requires_python = if let Some(requires_python) = requires_python_workspace { + let requires_python = if let Some(requires_python) = requires_python { + if matches!(requires_python.bound(), Bound::Unbounded) { + let default = + RequiresPython::greater_than_equal_version(interpreter.python_minor_version()); + if let Some(root_project_name) = root_project_name.as_ref() { + warn_user!( + "The `requires-python` field found in `{root_project_name}` does not contain a lower bound: `{requires_python}`. Set a lower bound to indicate the minimum compatible Python version (e.g., `{default}`).", + ); + } else { + warn_user!( + "The `requires-python` field does not contain a lower bound: `{requires_python}`. Set a lower bound to indicate the minimum compatible Python version (e.g., `{default}`).", + ); + } + } requires_python } else { - let requires_python = + let default = RequiresPython::greater_than_equal_version(interpreter.python_minor_version()); if let Some(root_project_name) = root_project_name.as_ref() { warn_user!( - "No `requires-python` field found in `{root_project_name}`. Defaulting to `{requires_python}`.", + "No `requires-python` field found in `{root_project_name}`. Defaulting to `{default}`.", ); } else { - warn_user!( - "No `requires-python` field found in workspace. Defaulting to `{requires_python}`.", - ); + warn_user!("No `requires-python` field found in workspace. Defaulting to `{default}`.",); } - requires_python + default }; // Initialize the registry client. diff --git a/crates/uv/tests/lock.rs b/crates/uv/tests/lock.rs index 450c7e457..926bec39d 100644 --- a/crates/uv/tests/lock.rs +++ b/crates/uv/tests/lock.rs @@ -2428,6 +2428,69 @@ fn lock_requires_python_pre() -> Result<()> { Ok(()) } +/// Warn if `Requires-Python` does not include a lower bound. +#[test] +fn lock_requires_python_unbounded() -> Result<()> { + let context = TestContext::new("3.11"); + + let lockfile = context.temp_dir.join("uv.lock"); + + 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 = ["iniconfig"] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv lock` is experimental and may change without warning. + warning: The `requires-python` field found in `project` does not contain a lower bound: `<=3.12`. Set a lower bound to indicate the minimum compatible Python version (e.g., `>=3.11`). + Resolved 2 packages in [TIME] + "###); + + let lock = fs_err::read_to_string(lockfile)?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = "<=3.12" + + [[distribution]] + name = "iniconfig" + version = "1.1.1" + source = "registry+https://pypi.org/simple" + sdist = { url = "https://files.pythonhosted.org/packages/23/a2/97899f6bd0e873fed3a7e67ae8d3a08b21799430fb4da15cfedf10d6e2c2/iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32", size = 8104 } + wheels = [{ url = "https://files.pythonhosted.org/packages/9b/dd/b3c12c6d707058fa947864b67f0c4e0c39ef8610988d7baea9578f3c48f3/iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", size = 4990 }] + + [[distribution]] + name = "project" + version = "0.1.0" + source = "editable+." + sdist = { path = "." } + + [[distribution.dependencies]] + name = "iniconfig" + version = "1.1.1" + source = "registry+https://pypi.org/simple" + "### + ); + }); + + Ok(()) +} + /// Lock the development dependencies for a project. #[test] fn lock_dev() -> Result<()> {