From a97c207674abb163352e87e65df6c42a95c41ff3 Mon Sep 17 00:00:00 2001 From: Andrew Gallant Date: Fri, 16 Feb 2024 15:52:44 -0500 Subject: [PATCH] pypi-types: fix lenient requirement parsing (#1529) This fixes a bug where `uv pip install` failed to install `polars`: ``` $ uv pip install polars==0.14.0 error: Failed to download: polars==0.14.0 Caused by: Couldn't parse metadata of polars-0.14.0-cp37-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.whl from https://files.pythonhosted.org/packages/5c/c6/749022b096895790c971338de93e610210331ea6cb7c1c2cc32b7f433c4f/polars-0.14.0-cp37-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.whl Caused by: Operator >= cannot be used with a wildcard version specifier pyarrow>=4.0.*; extra == 'pyarrow' ^^^^^^^ ``` Since `pyarrow>=4.0.*; extra == 'pyarrow'` is invalid *and* it comes from the metadata of a dependency (that isn't under the control of the end user), we actually attempt to "fix" it. Namely, wildcard dependency specifications are only allowed with `==` and `!=`, as per the [Version Specifiers spec]. (They aren't explicitly forbidden in these cases, but instead only have specified behavior for the `==` and `!=` operators.) This is all fine, but it turns out that when we fix the `>=4.0.*` component, we also strip the quotes around `pyarrow`. (Because some dependency specifications include stray quotes.) We fix this by making our quote stripping a bit more selective. (We require that it appear adjacent to a digit or a `*`.) Note that #1477 also reports this error: ``` $ uv pip install 'requests>=2.30.*' error: Failed to parse `requests>=2.30.*` Caused by: Operator >= cannot be used with a wildcard version specifier requests>=2.30.* ``` However, we specifically keep that error message since it's something under the end user's control. And similarly for a dependency specification in a `requirements.txt` file. Fixes #1477 [Version Specifiers spec]: https://packaging.python.org/en/latest/specifications/version-specifiers/ --- crates/pypi-types/src/lenient_requirement.rs | 6 ++-- crates/uv/tests/pip_install.rs | 32 ++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/pypi-types/src/lenient_requirement.rs b/crates/pypi-types/src/lenient_requirement.rs index 1b2b9fd1b9c5..2564a3ee9275 100644 --- a/crates/pypi-types/src/lenient_requirement.rs +++ b/crates/pypi-types/src/lenient_requirement.rs @@ -15,13 +15,13 @@ static MISSING_COMMA: Lazy = Lazy::new(|| Regex::new(r"(\d)([<>=~^!])").u static NOT_EQUAL_TILDE: Lazy = Lazy::new(|| Regex::new(r"!=~((?:\d\.)*\d)").unwrap()); /// Ex) `>=1.9.*`, `<3.4.*` static INVALID_TRAILING_DOT_STAR: Lazy = - Lazy::new(|| Regex::new(r"(<=|>=|<|>)(\d+(\.\d+)?)\.\*").unwrap()); + Lazy::new(|| Regex::new(r"(<=|>=|<|>)(\d+(\.\d+)*)\.\*").unwrap()); /// Ex) `!=3.0*` static MISSING_DOT: Lazy = Lazy::new(|| Regex::new(r"(\d\.\d)+\*").unwrap()); /// Ex) `>=3.6,` static TRAILING_COMMA: Lazy = Lazy::new(|| Regex::new(r",\s*$").unwrap()); /// Ex) `>= '2.7'`, `>=3.6'` -static STRAY_QUOTES: Lazy = Lazy::new(|| Regex::new(r#"['"]"#).unwrap()); +static STRAY_QUOTES: Lazy = Lazy::new(|| Regex::new(r#"['"]([*\d])|([*\d])['"]"#).unwrap()); /// Regex to match the invalid specifier, replacement to fix it and message about was wrong and /// fixed @@ -45,7 +45,7 @@ static FIXUPS: &[(&Lazy, &str, &str)] = &[ // Given `>=3.6,`, rewrite to `>=3.6` (&TRAILING_COMMA, r"${1}", "removing trailing comma"), // Given `>= '2.7'`, rewrite to `>= 2.7` - (&STRAY_QUOTES, r"", "removing stray quotes"), + (&STRAY_QUOTES, r"$1$2", "removing stray quotes"), ]; fn parse_with_fixups>(input: &str, type_name: &str) -> Result { diff --git a/crates/uv/tests/pip_install.rs b/crates/uv/tests/pip_install.rs index 21d0a0eac9f3..9a7d4f78ccf1 100644 --- a/crates/uv/tests/pip_install.rs +++ b/crates/uv/tests/pip_install.rs @@ -1097,3 +1097,35 @@ fn install_constraints_inline() -> Result<()> { Ok(()) } + +/// Tests that we can install `polars==0.14.0`, which has this odd dependency +/// requirement in its wheel metadata: `pyarrow>=4.0.*; extra == 'pyarrow'`. +/// +/// The `>=4.0.*` is invalid, but is something we "fix" because it is out +/// of the control of the end user. However, our fix for this case ends up +/// stripping the quotes around `pyarrow` and thus produces an irrevocably +/// invalid dependency requirement. +/// +/// See: +#[test] +fn install_pinned_polars_invalid_metadata() { + let context = TestContext::new("3.12"); + + // Install Flask. + uv_snapshot!(command(&context) + .arg("polars==0.14.0"), + @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Downloaded 1 package in [TIME] + Installed 1 package in [TIME] + + polars==0.14.0 + "### + ); + + context.assert_command("import polars").success(); +}