fix: Adaptive level for compatible Version formatting (#3818)

Signed-off-by: Julien Jerphanion <git@jjerphan.xyz>
This commit is contained in:
Julien Jerphanion 2025-02-13 16:41:25 +02:00 committed by GitHub
parent 89abba3df0
commit 402b2d474b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 248 additions and 47 deletions

View File

@ -545,45 +545,6 @@ 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

@ -264,11 +264,14 @@ fmt::formatter<mamba::specs::VersionPredicate>::format(
}
if constexpr (std::is_same_v<Op, VersionPredicate::compatible_with>)
{
// Make sure to print the version without loosing information.
auto version_level = pred.m_version.version().size();
auto format_level = std::max(op.level, version_level);
out = fmt::format_to(
out,
"{}{}",
VersionSpec::compatible_str,
pred.m_version.str(op.level)
pred.m_version.str(format_level)
);
}
},

View File

@ -128,6 +128,20 @@ namespace
REQUIRE(ms.str() == "abc>3");
}
SECTION("numpy~=1.26.0")
{
auto ms = MatchSpec::parse("numpy~=1.26.0").value();
REQUIRE(ms.name().str() == "numpy");
REQUIRE(ms.version().str() == "~=1.26.0");
REQUIRE(ms.build_string().is_explicitly_free());
REQUIRE(ms.build_number().is_explicitly_free());
REQUIRE(ms.str() == "numpy~=1.26.0");
// TODO: test this assumption for many more cases
auto ms2 = MatchSpec::parse(ms.str()).value();
REQUIRE(ms2 == ms);
}
// Invalid case from `inform2w64-sysroot_win-64-v12.0.0.r2.ggc561118da-h707e725_0.conda`
// which is currently supported but which must not.
SECTION("mingw-w64-ucrt-x86_64-crt-git v12.0.0.r2.ggc561118da h707e725_0")
@ -475,11 +489,10 @@ namespace
{
auto ms = MatchSpec::parse(R"(numpy >1.8,<2|==1.7,!=1.9,~=1.7.1 py34_0)").value();
REQUIRE(ms.name().str() == "numpy");
REQUIRE(ms.version().str() == ">1.8,((<2|==1.7),(!=1.9,(>=1.7.1,=1.7)))");
REQUIRE(ms.version().str() == ">1.8,((<2|==1.7),(!=1.9,~=1.7.1))");
REQUIRE(ms.build_string().str() == "py34_0");
REQUIRE(
ms.str()
== R"ms(numpy[version=">1.8,((<2|==1.7),(!=1.9,(>=1.7.1,=1.7)))",build="py34_0"])ms"
ms.str() == R"ms(numpy[version=">1.8,((<2|==1.7),(!=1.9,~=1.7.1))",build="py34_0"])ms"
);
}
@ -487,16 +500,25 @@ namespace
{
auto ms = MatchSpec::parse("python-graphviz~=0.20").value();
REQUIRE(ms.name().str() == "python-graphviz");
REQUIRE(ms.version().str() == ">=0.20,=0");
REQUIRE(ms.str() == R"ms(python-graphviz[version=">=0.20,=0"])ms");
REQUIRE(ms.version().str() == "~=0.20");
REQUIRE(ms.str() == R"ms(python-graphviz~=0.20)ms");
}
SECTION("python-graphviz ~= 0.20")
{
auto ms = MatchSpec::parse("python-graphviz ~= 0.20").value();
REQUIRE(ms.name().str() == "python-graphviz");
REQUIRE(ms.version().str() == ">=0.20,=0");
REQUIRE(ms.str() == R"ms(python-graphviz[version=">=0.20,=0"])ms");
REQUIRE(ms.version().str() == "~=0.20");
REQUIRE(ms.str() == R"ms(python-graphviz~=0.20)ms");
}
SECTION("python[version='~=3.11.0',build=*_cpython]")
{
auto ms = MatchSpec::parse("python[version='~=3.11.0',build=*_cpython]").value();
REQUIRE(ms.name().str() == "python");
REQUIRE(ms.version().str() == "~=3.11.0");
REQUIRE(ms.build_string().str() == "*_cpython");
REQUIRE(ms.str() == R"ms(python[version="~=3.11.0",build="*_cpython"])ms");
}
SECTION("*[md5=fewjaflknd]")
@ -1000,6 +1022,189 @@ namespace
/* .track_features =*/{},
}));
}
SECTION("pytorch~=2.3.1=py3.10_cuda11.8*")
{
const auto ms = "pytorch~=2.3.1=py3.10_cuda11.8*"_ms;
REQUIRE(ms.contains_except_channel(Pkg{
/* .name= */ "pytorch",
/* .version= */ "2.3.1"_v,
/* .build_string= */ "py3.10_cuda11.8_cudnn8.7.0_0",
/* .build_number= */ 0,
/* .md5= */ "lemd5",
/* .sha256= */ "somesha256",
/* .license= */ "GPL",
/* .platform= */ "linux-64",
/* .track_features =*/{},
}));
REQUIRE(ms.contains_except_channel(Pkg{
/* .name= */ "pytorch",
/* .version= */ "2.3.2"_v,
/* .build_string= */ "py3.10_cuda11.8_cudnn8.7.0_0",
/* .build_number= */ 0,
/* .md5= */ "lemd5",
/* .sha256= */ "somesha256",
/* .license= */ "GPL",
/* .platform= */ "linux-64",
/* .track_features =*/{},
}));
REQUIRE_FALSE(ms.contains_except_channel(Pkg{
/* .name= */ "pytorch",
/* .version= */ "2.4.0"_v,
/* .build_string= */ "py3.10_cuda11.8_cudnn8.7.0_0",
/* .build_number= */ 0,
/* .md5= */ "lemd5",
/* .sha256= */ "somesha256",
/* .license= */ "GPL",
/* .platform= */ "linux-64",
/* .track_features =*/{},
}));
REQUIRE_FALSE(ms.contains_except_channel(Pkg{
/* .name= */ "pytorch",
/* .version= */ "3.0"_v,
/* .build_string= */ "py3.10_cuda11.8_cudnn8.7.0_0",
/* .build_number= */ 0,
/* .md5= */ "lemd5",
/* .sha256= */ "somesha256",
/* .license= */ "GPL",
/* .platform= */ "linux-64",
/* .track_features =*/{},
}));
REQUIRE_FALSE(ms.contains_except_channel(Pkg{
/* .name= */ "pytorch",
/* .version= */ "2.3.0"_v,
/* .build_string= */ "py3.10_cuda11.8_cudnn8.7.0_0",
/* .build_number= */ 0,
/* .md5= */ "lemd5",
/* .sha256= */ "somesha256",
/* .license= */ "GPL",
/* .platform= */ "linux-64",
/* .track_features =*/{},
}));
}
SECTION("numpy~=1.26.0")
{
const auto ms = "numpy~=1.26.0"_ms;
REQUIRE(ms.contains_except_channel(Pkg{
/* .name= */ "numpy",
/* .version= */ "1.26.0"_v,
/* .build_string= */ "py310h1d0b8b9_0",
/* .build_number= */ 0,
/* .md5= */ "lemd5",
/* .sha256= */ "somesha256",
/* .license= */ "GPL",
/* .platform= */ "linux-64",
/* .track_features =*/{},
}));
REQUIRE(ms.contains_except_channel(Pkg{
/* .name= */ "numpy",
/* .version= */ "1.26.1"_v,
/* .build_string= */ "py310h1d0b8b9_0",
/* .build_number= */ 0,
/* .md5= */ "lemd5",
/* .sha256= */ "somesha256",
/* .license= */ "GPL",
/* .platform= */ "linux-64",
/* .track_features =*/{},
}));
REQUIRE_FALSE(ms.contains_except_channel(Pkg{
/* .name= */ "numpy",
/* .version= */ "1.27"_v,
/* .build_string= */ "py310h1d0b8b9_0",
/* .build_number= */ 0,
/* .md5= */ "lemd5",
/* .sha256= */ "somesha256",
/* .license= */ "GPL",
/* .platform= */ "linux-64",
/* .track_features =*/{},
}));
REQUIRE_FALSE(ms.contains_except_channel(Pkg{
/* .name= */ "numpy",
/* .version= */ "2.0.0"_v,
/* .build_string= */ "py310h1d0b8b9_1",
/* .build_number= */ 1,
/* .md5= */ "lemd5",
/* .sha256= */ "somesha256",
/* .license= */ "GPL",
/* .platform= */ "linux-64",
/* .track_features =*/{},
}));
REQUIRE_FALSE(ms.contains_except_channel(Pkg{
/* .name= */ "numpy",
/* .version= */ "1.25.0"_v,
/* .build_string= */ "py310h1d0b8b9_0",
/* .build_number= */ 0,
/* .md5= */ "lemd5",
/* .sha256= */ "somesha256",
/* .license= */ "GPL",
/* .platform= */ "linux-64",
/* .track_features =*/{},
}));
}
SECTION("numpy~=1.26")
{
const auto ms = "numpy~=1.26"_ms;
REQUIRE(ms.contains_except_channel(Pkg{
/* .name= */ "numpy",
/* .version= */ "1.26.0"_v,
/* .build_string= */ "py310h1d0b8b9_0",
/* .build_number= */ 0,
/* .md5= */ "lemd5",
/* .sha256= */ "somesha256",
/* .license= */ "GPL",
/* .platform= */ "linux-64",
/* .track_features =*/{},
}));
REQUIRE(ms.contains_except_channel(Pkg{
/* .name= */ "numpy",
/* .version= */ "1.26.1"_v,
/* .build_string= */ "py310h1d0b8b9_0",
/* .build_number= */ 0,
/* .md5= */ "lemd5",
/* .sha256= */ "somesha256",
/* .license= */ "GPL",
/* .platform= */ "linux-64",
/* .track_features =*/{},
}));
REQUIRE(ms.contains_except_channel(Pkg{
/* .name= */ "numpy",
/* .version= */ "1.27"_v,
/* .build_string= */ "py310h1d0b8b9_0",
/* .build_number= */ 0,
/* .md5= */ "lemd5",
/* .sha256= */ "somesha256",
/* .license= */ "GPL",
/* .platform= */ "linux-64",
/* .track_features =*/{},
}));
REQUIRE_FALSE(ms.contains_except_channel(Pkg{
/* .name= */ "numpy",
/* .version= */ "2.0.0"_v,
/* .build_string= */ "py310h1d0b8b9_1",
/* .build_number= */ 1,
/* .md5= */ "lemd5",
/* .sha256= */ "somesha256",
/* .license= */ "GPL",
/* .platform= */ "linux-64",
/* .track_features =*/{},
}));
}
}
TEST_CASE("MatchSpec comparability and hashability")

View File

@ -422,6 +422,27 @@ namespace
REQUIRE(vs.str() == "=2.3,<3.0");
REQUIRE(vs.str_conda_build() == "2.3.*,<3.0");
}
SECTION("~=1")
{
auto vs = VersionSpec::parse("~=1").value();
REQUIRE(vs.str() == "~=1");
REQUIRE(vs.str_conda_build() == "~=1");
}
SECTION("~=1.8")
{
auto vs = VersionSpec::parse("~=1.8").value();
REQUIRE(vs.str() == "~=1.8");
REQUIRE(vs.str_conda_build() == "~=1.8");
}
SECTION("~=1.8.0")
{
auto vs = VersionSpec::parse("~=1.8.0").value();
REQUIRE(vs.str() == "~=1.8.0");
REQUIRE(vs.str_conda_build() == "~=1.8.0");
}
}
TEST_CASE("VersionSpec::is_explicitly_free")

View File

@ -3,6 +3,7 @@ import platform
import shutil
import subprocess
from pathlib import Path
from packaging.version import Version
import pytest
import yaml
@ -1693,3 +1694,13 @@ def test_non_url_encoding(tmp_path):
non_encoded_url_start = "https://conda.anaconda.org/conda-forge/linux-64/x264-1!"
out = helpers.run_env("export", "-p", env_prefix, "--explicit")
assert non_encoded_url_start in out
def test_compatible_release(tmp_path):
# Non-regression test for: https://github.com/mamba-org/mamba/issues/3472
env_prefix = tmp_path / "env-compatible-release"
out = helpers.create("--json", "jupyterlab~=4.3", "-p", env_prefix, "--dry-run")
jupyterlab_package = next(pkg for pkg in out["actions"]["LINK"] if pkg["name"] == "jupyterlab")
assert Version(jupyterlab_package["version"]) >= Version("4.3.0")