diff --git a/libmamba/src/specs/match_spec.cpp b/libmamba/src/specs/match_spec.cpp index aec2ea7aaf..2659865b50 100644 --- a/libmamba/src/specs/match_spec.cpp +++ b/libmamba/src/specs/match_spec.cpp @@ -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 { return tl::make_unexpected(ParseError( diff --git a/libmamba/tests/src/specs/test_match_spec.cpp b/libmamba/tests/src/specs/test_match_spec.cpp index a90266909f..2c8f94bc3c 100644 --- a/libmamba/tests/src/specs/test_match_spec.cpp +++ b/libmamba/tests/src/specs/test_match_spec.cpp @@ -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]") diff --git a/micromamba/tests/test_install.py b/micromamba/tests/test_install.py index 48c9e518bc..67800cb894 100644 --- a/micromamba/tests/test_install.py +++ b/micromamba/tests/test_install.py @@ -4,6 +4,7 @@ import subprocess import sys from pathlib import Path +from packaging.version import Version import pytest @@ -633,6 +634,14 @@ def test_force_reinstall_not_installed(self, existing_cache): 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"