fix: Support globs in `MatchSpec` build strings (#3735)

Signed-off-by: Julien Jerphanion <git@jjerphan.xyz>
Co-authored-by: Johan Mabille <johan.mabille@gmail.com>
Co-authored-by: jaimergp <jaimergp@users.noreply.github.com>
This commit is contained in:
Julien Jerphanion 2025-01-27 15:59:00 +02:00 committed by GitHub
parent c1c9f5fc90
commit 57a2c55826
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 131 additions and 19 deletions

View File

@ -31,7 +31,7 @@ namespace mamba::specs
[[nodiscard]] static auto parse(std::string pattern) -> expected_parse_t<RegexSpec>;
RegexSpec();
RegexSpec(std::regex pattern, std::string raw_pattern);
explicit RegexSpec(std::string raw_pattern);
[[nodiscard]] auto contains(std::string_view str) const -> bool;
@ -61,8 +61,8 @@ namespace mamba::specs
private:
std::regex m_pattern;
std::string m_raw_pattern;
std::regex m_pattern;
};
}

View File

@ -6,6 +6,7 @@
#include <algorithm>
#include <cassert>
#include <sstream>
#include <fmt/format.h>
@ -25,12 +26,10 @@ namespace mamba::specs
auto RegexSpec::parse(std::string pattern) -> expected_parse_t<RegexSpec>
{
// No other mean of getting parse result with ``std::regex``, but parse error need
// to be handled by ``tl::expected`` to be managed down the road.
// Parse error need to be handled by ``tl::expected`` to be managed down the road.
try
{
auto regex = std::regex(pattern);
return { { std::move(regex), std::move(pattern) } };
return RegexSpec{ std::move(pattern) };
}
catch (const std::regex_error& e)
{
@ -38,26 +37,67 @@ namespace mamba::specs
}
}
RegexSpec::RegexSpec()
: RegexSpec(std::regex(free_pattern.data(), free_pattern.size()), std::string(free_pattern))
auto regexify(std::string raw_pattern) -> std::string
{
}
// raw_pattern can be a regex or a glob pattern. We need to convert it to a regex.
RegexSpec::RegexSpec(std::regex pattern, std::string raw_pattern)
: m_pattern(std::move(pattern))
, m_raw_pattern(std::move(raw_pattern))
{
// If the string is wrapped in `^` and `$`, `conda.model.MatchSpec` considers it a regex.
// See:
// https://github.com/conda/conda/blob/52b6393d6331e8aa36b2e23ab65766a980f381d2/conda/models/match_spec.py#L134-L139.
// See:
// https://github.com/conda/conda/blob/52b6393d6331e8aa36b2e23ab65766a980f381d2/conda/models/match_spec.py#L889-L894
if (util::starts_with(raw_pattern, RegexSpec::pattern_start)
&& util::ends_with(raw_pattern, RegexSpec::pattern_end))
{
return raw_pattern;
}
// Construct the regex progressively from raw_pattern, in particular make sure to replace
// all `*` by `.*` in the pattern if they are not preceded by a `.`.
//
// We force regex to start with `^` and end with `$` to simplify the multiple
// possible representations, and because this is the safest way we can make sure it is
// not a glob when serializing it.
if (!util::starts_with(m_raw_pattern, pattern_start))
std::ostringstream ss;
ss << RegexSpec::pattern_start;
auto first_character_it = raw_pattern.cbegin();
auto last_character_it = raw_pattern.cend() - 1;
for (auto it = first_character_it; it != raw_pattern.cend(); ++it)
{
m_raw_pattern.insert(m_raw_pattern.begin(), pattern_start);
}
if (!util::ends_with(m_raw_pattern, pattern_end))
{
m_raw_pattern.push_back(pattern_end);
if (it == first_character_it && *it == RegexSpec::pattern_start)
{
continue;
}
if (it == last_character_it && *it == RegexSpec::pattern_end)
{
continue;
}
if (*it == '*' && (it == first_character_it || *(it - 1) != '.'))
{
ss << ".*";
}
else
{
ss << *it;
}
}
ss << RegexSpec::pattern_end;
return ss.str();
}
RegexSpec::RegexSpec()
: RegexSpec(std::string(free_pattern))
{
}
RegexSpec::RegexSpec(std::string raw_pattern)
: m_raw_pattern(regexify(std::move(raw_pattern)))
, m_pattern(std::regex(m_raw_pattern))
{
}
auto RegexSpec::contains(std::string_view str) const -> bool

View File

@ -981,6 +981,25 @@ namespace
/* .track_features =*/{ "openssl", "mkl" },
}));
}
SECTION("pytorch=2.3.1=py3.10_cuda11.8*")
{
// Check that it contains `pytorch=2.3.1=py3.10_cuda11.8_cudnn8.7.0_0`
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 =*/{},
}));
}
}
TEST_CASE("MatchSpec comparability and hashability")

View File

@ -80,4 +80,20 @@ namespace
REQUIRE(hash_fn(spec1) == hash_fn(spec2));
REQUIRE(hash_fn(spec1) != hash_fn(spec3));
}
TEST_CASE("RegexSpec py3.10_cuda11.8*")
{
auto spec = RegexSpec::parse("py3.10_cuda11.8*").value();
REQUIRE(spec.contains("py3.10_cuda11.8_cudnn8.7.0_0"));
}
TEST_CASE("RegexSpec * semantic")
{
auto spec = RegexSpec::parse("py3.*").value();
REQUIRE(spec.contains("py3."));
REQUIRE(spec.contains("py3.10"));
REQUIRE(spec.contains("py3.10_cuda11.8_cudnn8.7.0_0"));
}
}

View File

@ -1639,6 +1639,42 @@ def test_ca_certificates(tmp_path):
assert root_prefix_ca_certificates_used or fall_back_certificates_used
def test_glob_in_build_string(monkeypatch, tmp_path):
# Non-regression test for https://github.com/mamba-org/mamba/issues/3699
env_prefix = tmp_path / "test_glob_in_build_string"
pytorch_match_spec = "pytorch=2.3.1=py3.10_cuda11.8*"
# Export CONDA_OVERRIDE_GLIBC=2.17 to force the solver to use the glibc 2.17 package
monkeypatch.setenv("CONDA_OVERRIDE_GLIBC", "2.17")
# Should run without error
out = helpers.create(
"-p",
env_prefix,
pytorch_match_spec,
"-c",
"pytorch",
"-c",
"nvidia/label/cuda-11.8.0",
"-c",
"nvidia",
"-c",
"conda-forge",
"--platform",
"linux-64",
"--dry-run",
"--json",
)
# Check that a build of pytorch 2.3.1 with `py3.10_cuda11.8_cudnn8.7.0_0` as a build string is found
assert any(
package["name"] == "pytorch"
and package["version"] == "2.3.1"
and package["build_string"] == "py3.10_cuda11.8_cudnn8.7.0_0"
for package in out["actions"]["FETCH"]
)
def test_non_url_encoding(tmp_path):
# Non-regression test for https://github.com/mamba-org/mamba/issues/3737
env_prefix = tmp_path / "env-non_url_encoding"
@ -1656,3 +1692,4 @@ 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