fix: Support for PEP 440 "Compatible Releases" (operator `~=` for `MatchSpec`) (#3483)

Signed-off-by: Julien Jerphanion <git@jjerphan.xyz>
Co-authored-by: Johan Mabille <johan.mabille@gmail.com>
This commit is contained in:
Julien Jerphanion 2024-09-30 16:41:49 +02:00 committed by GitHub
parent 2e8b3ee7bc
commit 0b824fe252
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 74 additions and 3 deletions

View File

@ -505,6 +505,10 @@ namespace mamba::specs
std::string raw_match_spec_str = std::string(str);
raw_match_spec_str = util::strip(raw_match_spec_str);
// Those are temporary adaptations to handle some instances of `MatchSpec` which is not
// yet formally specified.
// For a tentative formulation of the MatchSpec see: https://github.com/conda/ceps/pull/82
// Remove any with space after binary operators, such as:
// - `openmpi-4.1.4-ha1ae619_102`'s improperly encoded `constrains`: "cudatoolkit >= 10.2"
// - `pytorch-1.13.0-cpu_py310h02c325b_0.conda`'s improperly encoded
@ -516,7 +520,7 @@ namespace mamba::specs
// TODO: this solution reallocates memory several times potentially, but the
// number of operators is small and the strings are short, so it must be fine.
// If needed it can be optimized so that the string is only copied once.
for (const std::string& op : { ">=", "<=", "==", ">", "<", "!=", "=", "==", "," })
for (const std::string& op : { ">=", "<=", "==", ">", "<", "!=", "=", "==", "~=", "," })
{
const std::string& bad_op = op + " ";
while (raw_match_spec_str.find(bad_op) != std::string::npos)
@ -528,6 +532,45 @@ namespace mamba::specs
}
}
// Handle PEP 440 "Compatible release" specification
// See: https://peps.python.org/pep-0440/#compatible-release
//
// Find a general replacement of the encoding of `~=` with `>=,.*` to be able to parse it
// properly.
//
// For instance:
//
// "~=x.y" must be replaced to ">=x.y,x.*" where `x` and `y` are positive integers.
//
// This solution must handle the case where the version is encoded with `~=` within the
// specification for instance:
//
// ">1.8,<2|==1.7,!=1.9,~=1.7.1 py34_0"
//
// must be replaced with:
//
// ">1.8,<2|==1.7,!=1.9,>=1.7.1,1.7.* py34_0"
//
while (raw_match_spec_str.find("~=") != std::string::npos)
{
// Extract the string before the `~=` operator (">1.8,<2|==1.7,!=1.9," for the above
// example)
const auto before = raw_match_spec_str.substr(0, str.find("~="));
// Extract the string after the `~=` operator (include `~=` in it) and the next operator
// space or end of the string ("~=1.7.1 py34_0" for the above example)
const auto after = raw_match_spec_str.substr(str.find("~="));
// Extract the version part after the `~=` operator ("1.7.1" for the above example)
const auto version = after.substr(2, after.find_first_of(" ,") - 2);
// Extract the version part without the last segment ("1.7" for the above example)
const auto version_without_last_segment = version.substr(0, version.find_last_of('.'));
// Extract the build part after the version part (" py34_0" for the above example) if
// present
const auto build = after.find(" ") != std::string::npos ? after.substr(after.find(" "))
: "";
raw_match_spec_str = before + ">=" + version + "," + version_without_last_segment + ".*"
+ build;
}
auto parse_error = [&raw_match_spec_str](std::string_view err) -> tl::unexpected<ParseError>
{
return tl::make_unexpected(ParseError(

View File

@ -446,9 +446,28 @@ TEST_SUITE("specs::match_spec")
{
auto ms = MatchSpec::parse(R"(numpy >1.8,<2|==1.7,!=1.9,~=1.7.1 py34_0)").value();
CHECK_EQ(ms.name().str(), "numpy");
CHECK_EQ(ms.version().str(), ">1.8,((<2|==1.7),(!=1.9,~=1.7))");
CHECK_EQ(ms.version().str(), ">1.8,((<2|==1.7),(!=1.9,(>=1.7.1,=1.7)))");
CHECK_EQ(ms.build_string().str(), "py34_0");
CHECK_EQ(ms.str(), R"ms(numpy[version=">1.8,((<2|==1.7),(!=1.9,~=1.7))",build="py34_0"])ms");
CHECK_EQ(
ms.str(),
R"ms(numpy[version=">1.8,((<2|==1.7),(!=1.9,(>=1.7.1,=1.7)))",build="py34_0"])ms"
);
}
SUBCASE("python-graphviz~=0.20")
{
auto ms = MatchSpec::parse("python-graphviz~=0.20").value();
CHECK_EQ(ms.name().str(), "python-graphviz");
CHECK_EQ(ms.version().str(), ">=0.20,=0");
CHECK_EQ(ms.str(), R"ms(python-graphviz[version=">=0.20,=0"])ms");
}
SUBCASE("python-graphviz ~= 0.20")
{
auto ms = MatchSpec::parse("python-graphviz ~= 0.20").value();
CHECK_EQ(ms.name().str(), "python-graphviz");
CHECK_EQ(ms.version().str(), ">=0.20,=0");
CHECK_EQ(ms.str(), R"ms(python-graphviz[version=">=0.20,=0"])ms");
}
SUBCASE("*[md5=fewjaflknd]")

View File

@ -4,6 +4,7 @@ import shutil
import subprocess
import sys
from pathlib import Path
from packaging.version import Version
import pytest
@ -633,6 +634,14 @@ class TestInstall:
reinstall_res = helpers.install("xtensor", "--force-reinstall", "--json")
assert "xtensor" in {pkg["name"] for pkg in reinstall_res["actions"]["LINK"]}
def test_install_compatible_release(self, existing_cache):
"""Install compatible release."""
res = helpers.install("numpy~=1.26.0", "--force-reinstall", "--json")
assert "numpy" in {pkg["name"] for pkg in res["actions"]["LINK"]}
numpy = [pkg for pkg in res["actions"]["LINK"] if pkg["name"] == "numpy"][0]
assert Version(numpy["version"]) >= Version("1.26.0")
def test_install_check_dirs(tmp_home, tmp_root_prefix):
env_name = "myenv"