Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Compare dependencies' python VersionSpec on their minor version
Signed-off-by: Julien Jerphanion <git@jjerphan.xyz>
  • Loading branch information
jjerphan committed Apr 2, 2026
commit b1847720a285e5e23977fc6e04bddc9731ee8293
1 change: 1 addition & 0 deletions libmamba/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,7 @@ set(
${LIBMAMBA_INCLUDE_DIR}/mamba/core/repo_checker_store.hpp
${LIBMAMBA_INCLUDE_DIR}/mamba/core/run.hpp
${LIBMAMBA_INCLUDE_DIR}/mamba/core/shell_init.hpp
${LIBMAMBA_INCLUDE_DIR}/mamba/core/shard_python_minor_prefilter.hpp
${LIBMAMBA_INCLUDE_DIR}/mamba/core/shards.hpp
${LIBMAMBA_INCLUDE_DIR}/mamba/core/shard_index_loader.hpp
${LIBMAMBA_INCLUDE_DIR}/mamba/core/shard_types.hpp
Expand Down
37 changes: 37 additions & 0 deletions libmamba/include/mamba/core/shard_python_minor_prefilter.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright (c) 2026, QuantStack and Mamba Contributors
//
// Distributed under the terms of the BSD 3-Clause License.
//
// The full license is in the file LICENSE, distributed with this software.

#ifndef MAMBA_CORE_SHARD_PYTHON_MINOR_PREFILTER_HPP
#define MAMBA_CORE_SHARD_PYTHON_MINOR_PREFILTER_HPP

#include <string>

#include "mamba/specs/version.hpp"
#include "mamba/specs/version_spec.hpp"

namespace mamba
{
/**
* For a single ``== <full version>`` leaf, replace with ``== <major.minor>`` so
* ``VersionSpec::contains`` matches a user ``python=X.Y`` point. Other specs are returned
* unchanged.
*/
[[nodiscard]] auto relax_version_spec_to_minor(const specs::VersionSpec& vs)
-> specs::VersionSpec;

/**
* Whether a ``depends`` line for ``python`` is compatible with the requested minor.
* Uses ``VersionSpec::contains`` on the parsed version first; if that fails, relaxes exact
* on ``major.minor`` (see ``relax_version_spec_to_minor``) and tests again.
* Non-python dependencies always return true; parse failures return true (no prefilter).
*/
[[nodiscard]] auto dependency_matches_requested_python_minor(
const std::string& dependency_spec,
const specs::Version& requested_python_minor
) -> bool;
}

#endif
76 changes: 57 additions & 19 deletions libmamba/src/core/shards.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
#include <cstdlib>
#include <fstream>
#include <iostream>
#include <sstream>
#include <optional>
#include <thread>

#include <fmt/format.h>
Expand All @@ -19,6 +19,7 @@

#include "mamba/core/logging.hpp"
#include "mamba/core/output.hpp"
#include "mamba/core/shard_python_minor_prefilter.hpp"
#include "mamba/core/shard_types.hpp"
#include "mamba/core/shards.hpp"
#include "mamba/core/subdir_index.hpp"
Expand All @@ -27,6 +28,7 @@
#include "mamba/fs/filesystem.hpp"
#include "mamba/specs/match_spec.hpp"
#include "mamba/specs/version.hpp"
#include "mamba/specs/version_spec.hpp"
#include "mamba/util/cryptography.hpp"
#include "mamba/util/encoding.hpp"
#include "mamba/util/environment.hpp"
Expand All @@ -37,6 +39,60 @@

namespace mamba
{
auto relax_version_spec_to_minor(const specs::VersionSpec& vs) -> specs::VersionSpec
{
// Only relax a single exact-equality leaf; other shapes keep normal ``contains``.
if (vs.expression_size() != 1)
{
return vs;
}
const std::string vs_str = vs.to_string();
if (!util::starts_with(vs_str, specs::VersionSpec::equal_str))
{
return vs;
}
const auto ver_tail = std::string_view(vs_str).substr(specs::VersionSpec::equal_str.size());
auto maybe_v = specs::Version::parse(util::lstrip(ver_tail));
if (!maybe_v.has_value())
{
return vs;
}
const std::string minor_str = maybe_v->to_string(2);
if (auto maybe_minor = specs::Version::parse(minor_str); maybe_minor.has_value())
{
return specs::VersionSpec::from_predicate(
specs::VersionPredicate::make_equal_to(std::move(maybe_minor).value())
);
}
return vs;
}

auto dependency_matches_requested_python_minor(
const std::string& dependency_spec,
const specs::Version& requested_python_minor
) -> bool
{
auto maybe_name = specs::MatchSpec::extract_name(dependency_spec);
if (!maybe_name.has_value() || maybe_name.value() != "python")
{
return true;
}
auto maybe_match_spec = specs::MatchSpec::parse(dependency_spec);
if (!maybe_match_spec.has_value())
{
return true;
}
const auto& ms = maybe_match_spec.value();
const auto& vs = ms.version();
if (vs.contains(requested_python_minor))
{
return true;
}

// Relax the version spec on the minor version (ignoring the patch version and build string)
return relax_version_spec_to_minor(vs).contains(requested_python_minor);
}

namespace
{
// Helper functions to extract values from msgpack_object (C API)
Expand Down Expand Up @@ -390,24 +446,6 @@ namespace mamba
return record;
}

auto dependency_matches_requested_python_minor(
const std::string& dependency_spec,
const specs::Version& requested_python_minor
) -> bool
{
auto maybe_name = specs::MatchSpec::extract_name(dependency_spec);
if (!maybe_name.has_value() || maybe_name.value() != "python")
{
return true;
}
auto maybe_match_spec = specs::MatchSpec::parse(dependency_spec);
if (!maybe_match_spec.has_value())
{
return true;
}
return maybe_match_spec.value().version().contains(requested_python_minor);
}

/**
* Whether a raw shard package record's ``depends`` list is compatible with the
* requested environment python minor.
Expand Down
155 changes: 155 additions & 0 deletions libmamba/tests/src/core/test_shards.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#include <msgpack/zone.h>

#include "mamba/core/channel_context.hpp"
#include "mamba/core/shard_python_minor_prefilter.hpp"
#include "mamba/core/shard_types.hpp"
#include "mamba/core/shards.hpp"
#include "mamba/core/util.hpp"
Expand All @@ -23,8 +24,10 @@
#include "mamba/specs/conda_url.hpp"
#include "mamba/specs/unresolved_channel.hpp"
#include "mamba/specs/version.hpp"
#include "mamba/specs/version_spec.hpp"
#include "mamba/util/encoding.hpp"
#include "mamba/util/environment.hpp"
#include "mamba/util/string.hpp"
#include "mamba/validation/tools.hpp"

#include "mambatests.hpp"
Expand Down Expand Up @@ -2848,4 +2851,156 @@ TEST_CASE("Shards - python minor prefilter")
REQUIRE(result->packages.size() == 1);
REQUIRE(result->packages.begin()->second.name == "test-pkg");
}

SECTION("exact python pin matches requested minor (conda three-token depends)")
{
auto result = run_for_dep(
"python 3.7.12 0_73_pypy",
specs::Version::parse("3.7").value_or(specs::Version())
);
REQUIRE(result.has_value());
REQUIRE(result->packages.size() == 1);
REQUIRE(result->packages.begin()->second.name == "test-pkg");
}
}

TEST_CASE("relax_version_spec_to_minor")
{
using specs::Version;
using specs::VersionSpec;

const auto req = [](std::string_view s) -> Version { return Version::parse(s).value(); };

SECTION("bare equality pin relaxes so requested minor is contained")
{
const auto vs = VersionSpec::parse("3.7.12").value();
const auto relaxed = relax_version_spec_to_minor(vs);
REQUIRE(relaxed.contains(req("3.7")));
REQUIRE_FALSE(relaxed.contains(req("3.8")));
}

SECTION("explicit double-equals string form relaxes")
{
const auto vs = VersionSpec::parse("==3.7.12").value();
const auto relaxed = relax_version_spec_to_minor(vs);
REQUIRE(relaxed.contains(req("3.7")));
REQUIRE(util::starts_with(relaxed.to_string(), "=="));
}

SECTION("four-component pin relaxes to first two components")
{
const auto vs = VersionSpec::parse("1.2.3.4").value();
const auto relaxed = relax_version_spec_to_minor(vs);
REQUIRE(relaxed.contains(req("1.2")));
REQUIRE_FALSE(relaxed.contains(req("1.3")));
}

SECTION("greater-or-equal is unchanged")
{
const auto vs = VersionSpec::parse(">=3.7").value();
REQUIRE(relax_version_spec_to_minor(vs).to_string() == vs.to_string());
}

SECTION("less-than is unchanged")
{
const auto vs = VersionSpec::parse("<4").value();
REQUIRE(relax_version_spec_to_minor(vs).to_string() == vs.to_string());
}

SECTION("compatible-release operator is unchanged")
{
const auto vs = VersionSpec::parse("~=3.7").value();
REQUIRE(relax_version_spec_to_minor(vs).to_string() == vs.to_string());
}

SECTION("not-equal is unchanged")
{
const auto vs = VersionSpec::parse("!=3.7.12").value();
REQUIRE(relax_version_spec_to_minor(vs).to_string() == vs.to_string());
}

SECTION("disjunction is unchanged")
{
const auto vs = VersionSpec::parse("==3.7.12|==3.8.0").value();
REQUIRE(relax_version_spec_to_minor(vs).to_string() == vs.to_string());
}

SECTION("conjunction is unchanged")
{
const auto vs = VersionSpec::parse(">=3.7,<3.8").value();
REQUIRE(relax_version_spec_to_minor(vs).to_string() == vs.to_string());
}

SECTION("free spec is unchanged")
{
const VersionSpec vs{};
REQUIRE(relax_version_spec_to_minor(vs).is_explicitly_free());
}
}

TEST_CASE("dependency_matches_requested_python_minor")
{
const auto req = [](std::string_view s) -> specs::Version
{ return specs::Version::parse(s).value(); };

SECTION("non-python dependency is not filtered")
{
REQUIRE(dependency_matches_requested_python_minor("numpy >=1.0", req("3.12")));
REQUIRE(dependency_matches_requested_python_minor("libstdcxx-ng >=12", req("3.12")));
}

SECTION("name starting with python but not the python package")
{
REQUIRE(dependency_matches_requested_python_minor("python_abi 3.12 1_cp312", req("3.12")));
}

SECTION("python version range matches requested minor")
{
REQUIRE(dependency_matches_requested_python_minor("python >=3.12,<3.13", req("3.12")));
REQUIRE(dependency_matches_requested_python_minor("python >=3.12", req("3.12")));
}

SECTION("python version range does not match requested minor")
{
REQUIRE_FALSE(dependency_matches_requested_python_minor("python >=3.12,<3.13", req("3.11")));
REQUIRE_FALSE(dependency_matches_requested_python_minor("python >=3.12,<3.13", req("3.13")));
}

SECTION("exact three-token conda pin matches requested minor")
{
REQUIRE(dependency_matches_requested_python_minor("python 3.7.12 0_73_pypy", req("3.7")));
}

SECTION("two-token exact pin matches requested minor")
{
REQUIRE(dependency_matches_requested_python_minor("python 3.7.12", req("3.7")));
}

SECTION("exact pin does not match different minor")
{
REQUIRE_FALSE(dependency_matches_requested_python_minor("python 3.8.0", req("3.7")));
}

SECTION("leading whitespace on dependency line")
{
REQUIRE(dependency_matches_requested_python_minor(" python >=3.12,<3.13", req("3.12")));
}

SECTION("unparsable python dependency does not filter (passes)")
{
REQUIRE(dependency_matches_requested_python_minor("python ,,not-a-valid-spec,,", req("3.12")));
}

SECTION("namespaced python pin")
{
REQUIRE(dependency_matches_requested_python_minor(
"conda-forge::python 3.7.12 0_73_pypy",
req("3.7")
));
}

SECTION("only python in range with no upper bound")
{
REQUIRE(dependency_matches_requested_python_minor("python", req("3.12")));
}
}