mirror of
				https://github.com/astral-sh/uv.git
				synced 2025-10-31 20:09:09 +00:00 
			
		
		
		
	Match wheel tags against Requires-Python major-minor (#5289)
				
					
				
			## Summary Given `Requires-Python: ">=3.12.3"`, we were rejecting wheels like `dearpygui-1.11.1-cp312-cp312-win_amd64.whl`, since `3.12.0` is not included in `>=3.12.3`. We instead need to test against the major-minor version of `Requires-Python`. The easiest way to do this, I think, is the use the `RequiresPython` struct, which has a single bound that we can truncate the major-minor. This also means that we now allow `dearpygui-1.11.1-cp312-cp312-win_amd64.whl` for specifiers like `Requires-Python: "==3.10.*"`. This is incorrect on the surface, but it does match our semantics for `Requires-Python` elsewhere: we treat it as a lower bound. Closes https://github.com/astral-sh/uv/issues/5287.
This commit is contained in:
		
							parent
							
								
									8f26f379d1
								
							
						
					
					
						commit
						b6f470416e
					
				
					 5 changed files with 183 additions and 113 deletions
				
			
		|  | @ -5,6 +5,7 @@ use std::ops::Deref; | |||
| use itertools::Itertools; | ||||
| use pubgrub::range::Range; | ||||
| 
 | ||||
| use distribution_filename::WheelFilename; | ||||
| use pep440_rs::{Operator, Version, VersionSpecifier, VersionSpecifiers}; | ||||
| use pep508_rs::{MarkerExpression, MarkerTree, MarkerValueVersion}; | ||||
| 
 | ||||
|  | @ -160,6 +161,23 @@ impl RequiresPython { | |||
|         &self.bound | ||||
|     } | ||||
| 
 | ||||
|     /// Returns the [`RequiresPythonBound`] truncated to the major and minor version.
 | ||||
|     pub fn bound_major_minor(&self) -> RequiresPythonBound { | ||||
|         match self.bound.as_ref() { | ||||
|             // Ex) `>=3.10.1` -> `>=3.10`
 | ||||
|             Bound::Included(version) => RequiresPythonBound(Bound::Included(Version::new( | ||||
|                 version.release().iter().take(2), | ||||
|             ))), | ||||
|             // Ex) `>3.10.1` -> `>=3.10`
 | ||||
|             // This is unintuitive, but `>3.10.1` does indicate that _some_ version of Python 3.10
 | ||||
|             // is supported.
 | ||||
|             Bound::Excluded(version) => RequiresPythonBound(Bound::Included(Version::new( | ||||
|                 version.release().iter().take(2), | ||||
|             ))), | ||||
|             Bound::Unbounded => RequiresPythonBound(Bound::Unbounded), | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /// Returns this `Requires-Python` specifier as an equivalent marker
 | ||||
|     /// expression utilizing the `python_version` marker field.
 | ||||
|     ///
 | ||||
|  | @ -217,6 +235,74 @@ impl RequiresPython { | |||
|             MarkerTree::Expression(expr_python_full_version), | ||||
|         ]) | ||||
|     } | ||||
| 
 | ||||
|     /// Returns `false` if the wheel's tags state it can't be used in the given Python version
 | ||||
|     /// range.
 | ||||
|     ///
 | ||||
|     /// It is meant to filter out clearly unusable wheels with perfect specificity and acceptable
 | ||||
|     /// sensitivity, we return `true` if the tags are unknown.
 | ||||
|     pub fn matches_wheel_tag(&self, wheel: &WheelFilename) -> bool { | ||||
|         wheel.abi_tag.iter().any(|abi_tag| { | ||||
|             if abi_tag == "abi3" { | ||||
|                 // Universal tags are allowed.
 | ||||
|                 true | ||||
|             } else if abi_tag == "none" { | ||||
|                 wheel.python_tag.iter().any(|python_tag| { | ||||
|                     // Remove `py2-none-any` and `py27-none-any`.
 | ||||
|                     if python_tag.starts_with("py2") { | ||||
|                         return false; | ||||
|                     } | ||||
| 
 | ||||
|                     // Remove (e.g.) `cp36-none-any` if the specifier is `==3.10.*`.
 | ||||
|                     let Some(minor) = python_tag | ||||
|                         .strip_prefix("cp3") | ||||
|                         .or_else(|| python_tag.strip_prefix("pp3")) | ||||
|                         .or_else(|| python_tag.strip_prefix("py3")) | ||||
|                     else { | ||||
|                         return true; | ||||
|                     }; | ||||
|                     let Ok(minor) = minor.parse::<u64>() else { | ||||
|                         return true; | ||||
|                     }; | ||||
| 
 | ||||
|                     // Ex) If the wheel bound is `3.6`, then it doesn't match `>=3.10`.
 | ||||
|                     let wheel_bound = | ||||
|                         RequiresPythonBound(Bound::Included(Version::new([3, minor]))); | ||||
|                     wheel_bound >= self.bound_major_minor() | ||||
|                 }) | ||||
|             } else if abi_tag.starts_with("cp2") || abi_tag.starts_with("pypy2") { | ||||
|                 // Python 2 is never allowed.
 | ||||
|                 false | ||||
|             } else if let Some(minor_no_dot_abi) = abi_tag.strip_prefix("cp3") { | ||||
|                 // Remove ABI tags, both old (dmu) and future (t, and all other letters).
 | ||||
|                 let minor_not_dot = minor_no_dot_abi.trim_matches(char::is_alphabetic); | ||||
|                 let Ok(minor) = minor_not_dot.parse::<u64>() else { | ||||
|                     // Unknown version pattern are allowed.
 | ||||
|                     return true; | ||||
|                 }; | ||||
| 
 | ||||
|                 let wheel_bound = RequiresPythonBound(Bound::Included(Version::new([3, minor]))); | ||||
|                 wheel_bound >= self.bound_major_minor() | ||||
|             } else if let Some(minor_no_dot_abi) = abi_tag.strip_prefix("pypy3") { | ||||
|                 // Given  `pypy39_pp73`, we just removed `pypy3`, now we remove `_pp73` ...
 | ||||
|                 let Some((minor_not_dot, _)) = minor_no_dot_abi.split_once('_') else { | ||||
|                     // Unknown version pattern are allowed.
 | ||||
|                     return true; | ||||
|                 }; | ||||
|                 // ... and get `9`.
 | ||||
|                 let Ok(minor) = minor_not_dot.parse::<u64>() else { | ||||
|                     // Unknown version pattern are allowed.
 | ||||
|                     return true; | ||||
|                 }; | ||||
| 
 | ||||
|                 let wheel_bound = RequiresPythonBound(Bound::Included(Version::new([3, minor]))); | ||||
|                 wheel_bound >= self.bound_major_minor() | ||||
|             } else { | ||||
|                 // Unknown python tag -> allowed.
 | ||||
|                 true | ||||
|             } | ||||
|         }) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl std::fmt::Display for RequiresPython { | ||||
|  | @ -295,3 +381,81 @@ impl Ord for RequiresPythonBound { | |||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[cfg(test)] | ||||
| mod tests { | ||||
|     use std::str::FromStr; | ||||
| 
 | ||||
|     use distribution_filename::WheelFilename; | ||||
|     use pep440_rs::VersionSpecifiers; | ||||
| 
 | ||||
|     use crate::RequiresPython; | ||||
| 
 | ||||
|     #[test] | ||||
|     fn requires_python_included() { | ||||
|         let version_specifiers = VersionSpecifiers::from_str("==3.10.*").unwrap(); | ||||
|         let requires_python = RequiresPython::union(std::iter::once(&version_specifiers)) | ||||
|             .unwrap() | ||||
|             .unwrap(); | ||||
|         let wheel_names = &[ | ||||
|             "bcrypt-4.1.3-cp37-abi3-macosx_10_12_universal2.whl", | ||||
|             "black-24.4.2-cp310-cp310-win_amd64.whl", | ||||
|             "black-24.4.2-cp310-none-win_amd64.whl", | ||||
|             "cbor2-5.6.4-py3-none-any.whl", | ||||
|             "watchfiles-0.22.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", | ||||
|             "dearpygui-1.11.1-cp312-cp312-win_amd64.whl", | ||||
|         ]; | ||||
|         for wheel_name in wheel_names { | ||||
|             assert!( | ||||
|                 requires_python.matches_wheel_tag(&WheelFilename::from_str(wheel_name).unwrap()), | ||||
|                 "{wheel_name}" | ||||
|             ); | ||||
|         } | ||||
| 
 | ||||
|         let version_specifiers = VersionSpecifiers::from_str(">=3.12.3").unwrap(); | ||||
|         let requires_python = RequiresPython::union(std::iter::once(&version_specifiers)) | ||||
|             .unwrap() | ||||
|             .unwrap(); | ||||
|         let wheel_names = &["dearpygui-1.11.1-cp312-cp312-win_amd64.whl"]; | ||||
|         for wheel_name in wheel_names { | ||||
|             assert!( | ||||
|                 requires_python.matches_wheel_tag(&WheelFilename::from_str(wheel_name).unwrap()), | ||||
|                 "{wheel_name}" | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     #[test] | ||||
|     fn requires_python_dropped() { | ||||
|         let version_specifiers = VersionSpecifiers::from_str("==3.10.*").unwrap(); | ||||
|         let requires_python = RequiresPython::union(std::iter::once(&version_specifiers)) | ||||
|             .unwrap() | ||||
|             .unwrap(); | ||||
|         let wheel_names = &[ | ||||
|             "PySocks-1.7.1-py27-none-any.whl", | ||||
|             "black-24.4.2-cp39-cp39-win_amd64.whl", | ||||
|             "psutil-6.0.0-cp36-cp36m-win32.whl", | ||||
|             "pydantic_core-2.20.1-pp39-pypy39_pp73-win_amd64.whl", | ||||
|             "torch-1.10.0-cp36-none-macosx_10_9_x86_64.whl", | ||||
|             "torch-1.10.0-py36-none-macosx_10_9_x86_64.whl", | ||||
|         ]; | ||||
|         for wheel_name in wheel_names { | ||||
|             assert!( | ||||
|                 !requires_python.matches_wheel_tag(&WheelFilename::from_str(wheel_name).unwrap()), | ||||
|                 "{wheel_name}" | ||||
|             ); | ||||
|         } | ||||
| 
 | ||||
|         let version_specifiers = VersionSpecifiers::from_str(">=3.12.3").unwrap(); | ||||
|         let requires_python = RequiresPython::union(std::iter::once(&version_specifiers)) | ||||
|             .unwrap() | ||||
|             .unwrap(); | ||||
|         let wheel_names = &["dearpygui-1.11.1-cp310-cp310-win_amd64.whl"]; | ||||
|         for wheel_name in wheel_names { | ||||
|             assert!( | ||||
|                 !requires_python.matches_wheel_tag(&WheelFilename::from_str(wheel_name).unwrap()), | ||||
|                 "{wheel_name}" | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Charlie Marsh
						Charlie Marsh