diff --git a/libmamba/CMakeLists.txt b/libmamba/CMakeLists.txt index 15ee98e6ce..ef53a772f6 100644 --- a/libmamba/CMakeLists.txt +++ b/libmamba/CMakeLists.txt @@ -247,6 +247,8 @@ set( ${LIBMAMBA_SOURCE_DIR}/core/query.cpp ${LIBMAMBA_SOURCE_DIR}/core/repo_checker_store.cpp ${LIBMAMBA_SOURCE_DIR}/core/run.cpp + ${LIBMAMBA_SOURCE_DIR}/core/shard_python_minor_prefilter.hpp + ${LIBMAMBA_SOURCE_DIR}/core/shard_python_minor_prefilter.cpp ${LIBMAMBA_SOURCE_DIR}/core/shell_init.cpp ${LIBMAMBA_SOURCE_DIR}/core/shards.cpp ${LIBMAMBA_SOURCE_DIR}/core/shard_index_loader.cpp diff --git a/libmamba/include/mamba/api/channel_loader.hpp b/libmamba/include/mamba/api/channel_loader.hpp index 89961d4ce5..0b0d350a3c 100644 --- a/libmamba/include/mamba/api/channel_loader.hpp +++ b/libmamba/include/mamba/api/channel_loader.hpp @@ -7,11 +7,13 @@ #ifndef MAMBA_API_CHANNEL_LOADER_HPP #define MAMBA_API_CHANNEL_LOADER_HPP +#include #include #include #include #include "mamba/core/error_handling.hpp" +#include "mamba/specs/version.hpp" namespace mamba { @@ -41,6 +43,8 @@ namespace mamba * @param subdir_idx Index of the subdir to load in \p subdirs. * @param loaded_subdirs_with_shards Set of subdir names already loaded via shards (updated). * @param priorities Repo priorities aligned with \p subdirs. + * @param python_minor_version_for_prefilter Optional python minor for shard record prefiltering + * (from \c prepare_solver_context). * @return The repo for the requested subdir, or unexpected mamba_error on failure. */ auto load_subdir_with_shards( @@ -50,7 +54,8 @@ namespace mamba std::vector& subdirs, std::size_t subdir_idx, std::set& loaded_subdirs_with_shards, - const std::vector& priorities + const std::vector& priorities, + std::optional python_minor_version_for_prefilter = std::nullopt ) -> expected_t; class ChannelContext; @@ -86,7 +91,8 @@ namespace mamba ChannelContext& channel_context, solver::libsolv::Database& database, MultiPackageCache& package_caches, - const std::vector& root_packages = {} + const std::vector& root_packages = {}, + std::optional python_minor_version_for_prefilter = std::nullopt ) -> expected_t; /* Brief Creates channels and mirrors objects, diff --git a/libmamba/include/mamba/core/shards.hpp b/libmamba/include/mamba/core/shards.hpp index d69086d497..f933bba12c 100644 --- a/libmamba/include/mamba/core/shards.hpp +++ b/libmamba/include/mamba/core/shards.hpp @@ -22,6 +22,7 @@ #include "mamba/fs/filesystem.hpp" #include "mamba/specs/authentication_info.hpp" #include "mamba/specs/channel.hpp" +#include "mamba/specs/version.hpp" namespace mamba { @@ -30,6 +31,12 @@ namespace mamba * * This class manages fetching and caching of individual shards from * a sharded repodata index. + * + * **Python minor prefilter:** When constructed with ``python_minor_version_for_prefilter`` + * (e.g. 3.12), parsing a shard msgpack drops package records whose ``depends`` list constrains + * ``python`` to a range that does not contain that minor, reducing work for the solver. + * When that optional is unset, no such filtering is applied and all records in the shard + * are parsed (python compatibility is left to the solver). */ class Shards { @@ -47,6 +54,9 @@ namespace mamba * @param mirrors Optional base mirrors for channel-based downloads. When provided, * extend_mirrors in fetch_shards will be initialized from these before adding * absolute-URL mirrors. + * @param python_minor_version_for_prefilter If set, shard parsing filters out records whose + * ``depends`` python constraints are incompatible with this minor; if unset, + * no python-minor-based record filtering is performed. */ Shards( ShardsIndexDict shards_index, @@ -56,7 +66,8 @@ namespace mamba download::RemoteFetchParams remote_fetch_params, // 0 means: auto; value is normalized with normalize_to_affinity_concurrency(). std::size_t download_threads = 0, - std::optional> mirrors = std::nullopt + std::optional> mirrors = std::nullopt, + std::optional python_minor_version_for_prefilter = std::nullopt ); /** Return the names of all packages available in this shard collection. */ @@ -119,6 +130,13 @@ namespace mamba /** Optional base mirrors for channel-based downloads. */ std::optional> m_mirrors; + /** + * Environment python minor used when parsing shards to prefilter package records + * (see ``record_depends_on_python_minor_version_for_prefilter`` in shards.cpp). + * Empty means the prefilter is disabled. + */ + std::optional m_python_minor_version_for_prefilter; + /** Visited shards, keyed by package name. */ std::map m_visited; diff --git a/libmamba/src/api/channel_loader.cpp b/libmamba/src/api/channel_loader.cpp index c4bed328b0..2a7512a10b 100644 --- a/libmamba/src/api/channel_loader.cpp +++ b/libmamba/src/api/channel_loader.cpp @@ -24,6 +24,9 @@ #include "mamba/solver/libsolv/repo_info.hpp" #include "mamba/specs/error.hpp" #include "mamba/specs/package_info.hpp" +#include "mamba/specs/version.hpp" + +#include "utils.hpp" namespace mamba { @@ -235,7 +238,8 @@ namespace mamba std::size_t subdir_idx, std::set& loaded_subdirs_with_shards, const SubdirDownloadParams& subdir_params, - const std::vector& priorities + const std::vector& priorities, + std::optional python_minor_version_for_prefilter ) { auto& subdir = subdirs[subdir_idx]; @@ -253,7 +257,8 @@ namespace mamba subdirs, subdir_idx, loaded_subdirs_with_shards, - priorities + priorities, + python_minor_version_for_prefilter ); if (!res) @@ -434,7 +439,8 @@ namespace mamba const std::vector& priorities, const SubdirDownloadParams& subdir_params, bool is_retry, - std::vector& error_list + std::vector& error_list, + std::optional python_minor_version_for_prefilter ) { std::set loaded_subdirs_with_shards; @@ -475,7 +481,8 @@ namespace mamba i, loaded_subdirs_with_shards, subdir_params, - priorities + priorities, + python_minor_version_for_prefilter ); if (result) @@ -556,7 +563,11 @@ namespace mamba continue; } SubdirIndexLoader subdir_index_loader = std::move(subdir_index_loader_result).value(); - if (subdir_index_loader.valid_cache_found() && Console::can_report_status()) + + // Only show flat repodata cache status if we're not using shards and we have a + // valid cache + if (!ctx.repodata_use_shards && subdir_index_loader.valid_cache_found() + && Console::can_report_status()) { Console::stream() << fmt::format("{:<50} {:>20}", subdir_index_loader.name(), "Using cache"); @@ -641,7 +652,8 @@ namespace mamba std::vector& subdirs, std::size_t subdir_idx, std::set& loaded_subdirs_with_shards, - const std::vector& priorities + const std::vector& priorities, + std::optional python_minor_version_for_prefilter ) -> expected_t { auto& subdir = subdirs[subdir_idx]; @@ -670,6 +682,15 @@ namespace mamba LOG_DEBUG << "Shard index fetched for " << subdir.name(); const auto& channel = subdir.channel(); std::string current_repodata_url = subdir.repodata_url().str(); + if (python_minor_version_for_prefilter.has_value()) + { + LOG_DEBUG << "Shard prefilter on python minor version enabled with " + << python_minor_version_for_prefilter.value().to_string(); + } + else + { + LOG_DEBUG << "Shard prefilter on python minor version disabled."; + } // For all subdirs sharing the same channel URL, fetch their shard indices and build // a Shards instance per subdir; collect them into a RepodataSubset. @@ -702,7 +723,8 @@ namespace mamba ctx.authentication_info(), ctx.remote_fetch_params, normalize_to_affinity_concurrency(static_cast(ctx.repodata_shards_threads)), - std::cref(ctx.mirrors) + std::cref(ctx.mirrors), + python_minor_version_for_prefilter ); url_to_subdir_idx[sdir_url] = j; } @@ -761,7 +783,8 @@ namespace mamba solver::libsolv::Database& database, MultiPackageCache& package_caches, const std::vector& root_packages, - bool is_retry + bool is_retry, + std::optional python_minor_version_for_prefilter ) { std::vector subdirs; @@ -808,7 +831,8 @@ namespace mamba priorities, subdir_params, is_retry, - error_list + error_list, + python_minor_version_for_prefilter ); if (loading_failed) @@ -824,7 +848,8 @@ namespace mamba database, package_caches, root_packages, - retry + retry, + python_minor_version_for_prefilter ); } error_list.emplace_back( @@ -843,11 +868,20 @@ namespace mamba ChannelContext& channel_context, solver::libsolv::Database& database, MultiPackageCache& package_caches, - const std::vector& root_packages + const std::vector& root_packages, + std::optional python_minor_version_for_prefilter ) -> expected_t { bool retry = false; - return load_channels_impl(ctx, channel_context, database, package_caches, root_packages, retry); + return load_channels_impl( + ctx, + channel_context, + database, + package_caches, + root_packages, + retry, + std::move(python_minor_version_for_prefilter) + ); } void init_channels(Context& context, ChannelContext& channel_context) diff --git a/libmamba/src/api/install.cpp b/libmamba/src/api/install.cpp index 668be448a0..643411fb32 100644 --- a/libmamba/src/api/install.cpp +++ b/libmamba/src/api/install.cpp @@ -376,6 +376,33 @@ namespace mamba { using Request = solver::Request; + // When the user explicitly asks for ``python`` in the requested specs, also inject a + // plain ``pip`` request unless it is already present. This complements + // ``add_pip_as_python_dependency`` at the repo level and makes sure that the Request + // is in phase with the root packages including both ``python`` and ``pip`` when requested. + bool wants_python = false; + bool wants_pip = false; + for (const auto& s : specs) + { + const auto maybe_name = specs::MatchSpec::extract_name(s); + if (!maybe_name.has_value()) + { + continue; + } + if (maybe_name.value() == "python") + { + wants_python = true; + } + else if (maybe_name.value() == "pip") + { + wants_pip = true; + } + } + if (wants_python && !wants_pip) + { + specs.emplace_back("pip"); + } + const auto& prefix_pkgs = prefix_data.records(); auto request = Request(); @@ -555,7 +582,13 @@ namespace mamba auto& no_env = config.at("no_env").value(); validate_target_prefix_and_channels(ctx, create_env); - auto [db, package_caches] = prepare_solver_context(ctx, channel_context, raw_specs); + auto [db, package_caches] = prepare_solver_context( + ctx, + channel_context, + raw_specs, + is_retry, + no_py_pin + ); auto prefix_data = load_prefix_data_and_installed(ctx, channel_context, db); diff --git a/libmamba/src/api/update.cpp b/libmamba/src/api/update.cpp index 5d391d193d..28f12610cb 100644 --- a/libmamba/src/api/update.cpp +++ b/libmamba/src/api/update.cpp @@ -4,6 +4,11 @@ // // The full license is in the file LICENSE, distributed with this software. +#include + +#include + +#include "mamba/api/channel_loader.hpp" #include "mamba/api/configuration.hpp" #include "mamba/api/install.hpp" #include "mamba/api/update.hpp" @@ -147,7 +152,13 @@ namespace mamba auto& retry_clean_cache = config.at("retry_clean_cache").value(); validate_target_prefix_and_channels(ctx, /* create_env= */ false); - auto [db, package_caches] = prepare_solver_context(ctx, channel_context, raw_update_specs); + auto [db, package_caches] = prepare_solver_context( + ctx, + channel_context, + raw_update_specs, + is_retry, + no_py_pin + ); auto prefix_data = load_prefix_data_and_installed(ctx, channel_context, db); diff --git a/libmamba/src/api/utils.cpp b/libmamba/src/api/utils.cpp index 4677111dcd..a51c446e5d 100644 --- a/libmamba/src/api/utils.cpp +++ b/libmamba/src/api/utils.cpp @@ -4,12 +4,15 @@ // // The full license is in the file LICENSE, distributed with this software. +#include +#include #include #include #include #include #include +#include #include #include @@ -31,7 +34,11 @@ #include "mamba/solver/libsolv/database.hpp" #include "mamba/solver/request.hpp" #include "mamba/specs/match_spec.hpp" +#include "mamba/specs/version_spec.hpp" #include "mamba/util/environment.hpp" +#include "mamba/util/string.hpp" + +#include "core/shard_python_minor_prefilter.hpp" #include "utils.hpp" @@ -114,6 +121,64 @@ namespace mamba ); } } + + std::optional + installed_python_minor_for_prefix(const fs::u8path& target_prefix) + { + const auto parse_minor = [](std::string_view v) -> std::optional + { + auto maybe_version = specs::Version::parse(std::string(v)); + if (maybe_version.has_value()) + { + return maybe_version.value(); + } + return std::nullopt; + }; + const auto conda_meta = target_prefix / "conda-meta"; + if (!fs::exists(conda_meta) || !fs::is_directory(conda_meta)) + { + return std::nullopt; + } + + for (const auto& entry : fs::directory_iterator(conda_meta)) + { + if (!entry.is_regular_file() || entry.path().extension() != ".json") + { + continue; + } + std::ifstream infile(entry.path().std_path()); + if (!infile.is_open()) + { + continue; + } + nlohmann::json j; + try + { + infile >> j; + } + catch (const std::exception&) + { + continue; + } + if (!j.is_object() || j.value("name", "") != "python") + { + continue; + } + const std::string version = j.value("version", ""); + auto dot = version.find('.'); + if (dot == std::string::npos) + { + continue; + } + auto second_dot = version.find('.', dot + 1); + if (second_dot == std::string::npos) + { + return parse_minor(version); + } + return parse_minor(version.substr(0, second_dot)); + } + return std::nullopt; + } } bool reproc_killed(int status) @@ -434,7 +499,9 @@ namespace mamba std::pair prepare_solver_context( Context& ctx, ChannelContext& channel_context, - const std::vector& raw_specs + const std::vector& raw_specs, + bool is_retry, + bool no_py_pin ) { populate_context_channels_from_specs(raw_specs, ctx); @@ -444,7 +511,58 @@ namespace mamba auto root_packages = ctx.repodata_use_shards ? build_sharded_root_packages(ctx, channel_context, raw_specs) : std::vector{}; - auto maybe_load = load_channels(ctx, channel_context, db, package_caches, root_packages); + + const std::optional python_minor_version_for_prefilter = + [&]() -> std::optional + { + if (no_py_pin) + { + LOG_DEBUG << "Shard python minor prefilter disabled (--no-py-pin)."; + return std::nullopt; + } + + const auto maybe_explicit_requested_python_minor = extract_requested_python_minor( + raw_specs + ); + + if (maybe_explicit_requested_python_minor.has_value()) + { + LOG_DEBUG << "Pre-filtering shards using explicitly requested python minor version: " + << maybe_explicit_requested_python_minor.value().to_string(); + return maybe_explicit_requested_python_minor.value(); + } + + if (is_retry) + { + LOG_DEBUG << "Retry without prefiltering on any python minor version."; + return std::nullopt; + } + + const auto maybe_installed_python_minor = installed_python_minor_for_prefix( + ctx.prefix_params.target_prefix + ); + + if (maybe_installed_python_minor.has_value()) + { + LOG_DEBUG << "Pre-filtering shards using installed python minor version: " + << maybe_installed_python_minor.value().to_string(); + return maybe_installed_python_minor.value(); + } + + LOG_DEBUG << "Pre-filtering shards using fallback python minor version: " + << fallback_python_minor; + return specs::Version::parse(std::string(fallback_python_minor)).value(); + }(); + + auto maybe_load = load_channels( + ctx, + channel_context, + db, + package_caches, + root_packages, + python_minor_version_for_prefilter + ); + if (!maybe_load) { throw maybe_load.error(); @@ -485,6 +603,11 @@ namespace mamba { return false; } + if (!is_retry) + { + retry_fn(); + return true; + } unsolvable->explain_problems_to( db, LOG_ERROR, @@ -599,4 +722,40 @@ namespace mamba execute_other_pkg_managers(other_specs, ctx, update); } } + + std::optional + extract_requested_python_minor(const std::vector& specs) + { + for (const auto& spec : specs) + { + auto maybe_name = specs::MatchSpec::extract_name(spec); + if (!maybe_name.has_value() || maybe_name.value() != "python") + { + continue; + } + auto maybe_ms = specs::MatchSpec::parse(spec); + if (!maybe_ms.has_value()) + { + continue; + } + const auto& raw_version_spec = maybe_ms.value().version(); + // Pins like ``python=2`` or ``python=3`` specify only the major version. Relaxing those + // to ``2.0`` / ``3.0`` for shard prefiltering would drop packages whose ``depends`` + // require a real minor (e.g. ``python >=2.7``). Skip the prefilter for such specs. + if (auto maybe_single_v = version_from_single_equality_spec(raw_version_spec)) + { + if (maybe_single_v->version().size() <= std::size_t{ 1 }) + { + return std::nullopt; + } + } + const specs::VersionSpec relaxed = relax_version_spec_to_minor(raw_version_spec); + if (auto maybe_v = version_from_single_equality_spec(relaxed)) + { + return maybe_v; + } + } + return std::nullopt; + } + } diff --git a/libmamba/src/api/utils.hpp b/libmamba/src/api/utils.hpp index 9b04dd423c..18067b483d 100644 --- a/libmamba/src/api/utils.hpp +++ b/libmamba/src/api/utils.hpp @@ -8,12 +8,14 @@ #define MAMBA_UTILS_HPP #include +#include #include #include #include #include #include "mamba/solver/libsolv/solver.hpp" +#include "mamba/specs/version.hpp" #include "tl/expected.hpp" @@ -52,6 +54,7 @@ namespace mamba } using command_args = std::vector; + inline constexpr std::string_view fallback_python_minor = "3.14"; /** * Build the command-line invocation for a secondary package manager (e.g. pip/uv). @@ -121,11 +124,17 @@ namespace mamba /** * Prepare solver state: channels, package cache, database, and root package loading. + * Computes ``python_minor_version_for_prefilter`` for sharded repodata: explicit python from + * specs, implicit fallback on the first solve attempt, or the installed prefix minor on retry + * when no explicit python is given. When ``no_py_pin`` is true (``--no-py-pin``), no python + * minor is used for shard prefiltering. */ std::pair prepare_solver_context( Context& ctx, ChannelContext& channel_context, - const std::vector& raw_specs + const std::vector& raw_specs, + bool is_retry, + bool no_py_pin ); /** @@ -211,6 +220,17 @@ namespace mamba pip::Update update ); + /** + * Extract an explicit python minor requirement (e.g. "3.12") from specs. + * + * Parses each ``python`` ``MatchSpec``, applies ``relax_version_spec_to_minor`` to the + * version, and returns the version if it is a single ``==`` equality (e.g. full pins relax to + * ``major.minor``). Skips specs that do not parse or do not yield such an equality after + * relaxation. + */ + std::optional + extract_requested_python_minor(const std::vector& specs); + } #endif // MAMBA_UTILS_HPP diff --git a/libmamba/src/core/shard_python_minor_prefilter.cpp b/libmamba/src/core/shard_python_minor_prefilter.cpp new file mode 100644 index 0000000000..0fe49daaaf --- /dev/null +++ b/libmamba/src/core/shard_python_minor_prefilter.cpp @@ -0,0 +1,110 @@ +// 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. + +#include +#include +#include + +#include "core/shard_python_minor_prefilter.hpp" +#include "mamba/specs/match_spec.hpp" +#include "mamba/specs/version.hpp" +#include "mamba/specs/version_spec.hpp" +#include "mamba/util/string.hpp" + +namespace mamba +{ + namespace + { + // Allows supporting both form of pin, e.g. ``python=3.13`` and ``python ==3.13``. + auto equality_tail(std::string_view spec_str) -> std::optional + { + if (util::starts_with(spec_str, specs::VersionSpec::equal_str)) + { + return spec_str.substr(specs::VersionSpec::equal_str.size()); + } + if (util::starts_with(spec_str, "=")) + { + return spec_str.substr(1); + } + return std::nullopt; + } + } + + auto version_from_single_equality_spec(const specs::VersionSpec& vs) + -> std::optional + { + if (vs.expression_size() != 1) + { + return std::nullopt; + } + const std::string s = vs.to_string(); + const auto maybe_tail = equality_tail(s); + if (!maybe_tail.has_value()) + { + return std::nullopt; + } + auto maybe_v = specs::Version::parse(std::string(util::lstrip(maybe_tail.value()))); + if (maybe_v.has_value()) + { + return maybe_v.value(); + } + return std::nullopt; + } + + 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(); + const auto maybe_tail = equality_tail(vs_str); + if (!maybe_tail.has_value()) + { + return vs; + } + auto maybe_v = specs::Version::parse(util::lstrip(maybe_tail.value())); + 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 matches_python_minor( + const std::string& dependency_spec, + const specs::Version& python_minor_version_for_prefilter + ) -> 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(python_minor_version_for_prefilter)) + { + 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(python_minor_version_for_prefilter); + } +} diff --git a/libmamba/src/core/shard_python_minor_prefilter.hpp b/libmamba/src/core/shard_python_minor_prefilter.hpp new file mode 100644 index 0000000000..1f5634e04b --- /dev/null +++ b/libmamba/src/core/shard_python_minor_prefilter.hpp @@ -0,0 +1,45 @@ +// 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 +#include + +#include "mamba/specs/version.hpp" +#include "mamba/specs/version_spec.hpp" + +namespace mamba +{ + /** + * If ``vs`` is a single ``==…`` leaf, return the parsed ``Version``; otherwise ``nullopt``. + */ + [[nodiscard]] auto version_from_single_equality_spec(const specs::VersionSpec& vs) + -> std::optional; + + /** + * For a single ``== `` leaf, replace with ``== `` 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 + * ``python_minor_version_for_prefilter``. 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 matches_python_minor( + const std::string& dependency_spec, + const specs::Version& python_minor_version_for_prefilter + ) -> bool; +} + +#endif diff --git a/libmamba/src/core/shards.cpp b/libmamba/src/core/shards.cpp index fed42f1c17..7e70782a3b 100644 --- a/libmamba/src/core/shards.cpp +++ b/libmamba/src/core/shards.cpp @@ -9,7 +9,7 @@ #include #include #include -#include +#include #include #include @@ -34,6 +34,8 @@ #include "mamba/util/url_manip.hpp" #include "mamba/validation/tools.hpp" +#include "core/shard_python_minor_prefilter.hpp" + namespace mamba { namespace @@ -183,7 +185,8 @@ namespace mamba * This handles the case where sha256 and md5 can be either strings or bytes * (as per Python TypedDict: NotRequired[str | bytes]). */ - auto parse_shard_package_record(const msgpack_object& obj) -> specs::RepoDataPackage + auto parse_shard_package_record(const msgpack_object& obj, std::string_view package_filename) + -> specs::RepoDataPackage { specs::RepoDataPackage record; @@ -364,8 +367,12 @@ namespace mamba catch (const std::exception& e) { LOG_WARNING << "Failed to parse field '" << key - << "' (type=" << static_cast(val_obj.type) - << ") in shard package record: " << e.what(); + << "' (msgpack type=" << static_cast(val_obj.type) + << ") in shard package record" + << (package_filename.empty() + ? "" + : (" for '" + std::string(package_filename) + "'")) + << ": " << e.what() << ". This field will be ignored."; // Continue parsing other fields } } @@ -383,6 +390,60 @@ namespace mamba return record; } + + /** + * Whether a raw shard package record's ``depends`` list is compatible with the + * requested environment python minor. + * + * When ``python_minor_version_for_prefilter`` is unset, returns true (no prefilter). + * When set, inspects ``depends`` entries for ``python`` and keeps the record only if + * each such constraint contains that minor (see + * ``matches_python_minor``). + */ + bool record_depends_on_python_minor_version_for_prefilter( + const msgpack_object& raw_record_obj, + const std::optional& python_minor_version_for_prefilter + ) + { + if (!python_minor_version_for_prefilter.has_value()) + { + // No requested python minor version is provided + // so the build is installable in the environment. + return true; + } + if (raw_record_obj.type != MSGPACK_OBJECT_MAP) + { + return true; + } + for (std::uint32_t i = 0; i < raw_record_obj.via.map.size; ++i) + { + const msgpack_object& key_obj = raw_record_obj.via.map.ptr[i].key; + const msgpack_object& val_obj = raw_record_obj.via.map.ptr[i].val; + std::string key; + try + { + key = msgpack_object_to_string(key_obj); + } + catch (const std::exception&) + { + continue; + } + if (key != "depends") + { + continue; + } + const auto depends = msgpack_object_to_string_array(val_obj); + for (const auto& dep : depends) + { + if (!matches_python_minor(dep, python_minor_version_for_prefilter.value())) + { + return false; + } + } + return true; + } + return true; + } } /****************** @@ -396,7 +457,8 @@ namespace mamba specs::AuthenticationDataBase auth_info, download::RemoteFetchParams remote_fetch_params, std::size_t download_threads, - std::optional> mirrors + std::optional> mirrors, + std::optional python_minor_version_for_prefilter ) : m_shards_index(std::move(shards_index)) , m_url(std::move(url)) @@ -405,6 +467,7 @@ namespace mamba , m_remote_fetch_params(std::move(remote_fetch_params)) , m_download_threads(normalize_to_affinity_concurrency(static_cast(download_threads))) , m_mirrors(std::move(mirrors)) + , m_python_minor_version_for_prefilter(std::move(python_minor_version_for_prefilter)) , m_pkgs_cache_root(fs::u8path(util::user_cache_dir()) / "conda" / "pkgs") , m_shard_cache_dir(m_pkgs_cache_root / "cache" / "shards") { @@ -927,19 +990,35 @@ namespace mamba const msgpack_object& obj = unpacked.data; ShardDict shard; - auto parse_package_records = [](const msgpack_object& map_obj, - std::map& target_map, - const std::string& map_name) + auto parse_package_records = [this]( + const msgpack_object& map_obj, + std::map& target_map, + const std::string& map_name + ) { for (std::uint32_t k = 0; k < map_obj.via.map.size; ++k) { + const auto& msgpack_record = map_obj.via.map.ptr[k]; + const msgpack_object& val = msgpack_record.val; + const msgpack_object& key = msgpack_record.key; try { - std::string pkg_filename = msgpack_object_to_string(map_obj.via.map.ptr[k].key); - specs::RepoDataPackage record = parse_shard_package_record( - map_obj.via.map.ptr[k].val + // Filter out builds which depend on another python minor version + // than the one requested. This significantly reduces the number of + // builds to parse and to provide to the solver for dependency resolution. + if (!record_depends_on_python_minor_version_for_prefilter( + val, + m_python_minor_version_for_prefilter + )) + { + continue; + } + std::string pkg_filename = msgpack_object_to_string(key); + specs::RepoDataPackage parsed_record = parse_shard_package_record( + val, + pkg_filename ); - target_map[pkg_filename] = record; + target_map[pkg_filename] = std::move(parsed_record); } catch (const std::exception& e) { diff --git a/libmamba/tests/CMakeLists.txt b/libmamba/tests/CMakeLists.txt index 98dca311b1..89019afcf3 100644 --- a/libmamba/tests/CMakeLists.txt +++ b/libmamba/tests/CMakeLists.txt @@ -105,6 +105,7 @@ set( src/core/test_progress_bar.cpp src/core/test_query.cpp src/core/test_shell_init.cpp + src/core/test_shard_python_minor_prefilter.cpp src/core/test_shards.cpp src/core/test_shard_index_loader.cpp src/core/test_shard_traversal.cpp diff --git a/libmamba/tests/src/core/test_shard_python_minor_prefilter.cpp b/libmamba/tests/src/core/test_shard_python_minor_prefilter.cpp new file mode 100644 index 0000000000..8698517ad7 --- /dev/null +++ b/libmamba/tests/src/core/test_shard_python_minor_prefilter.cpp @@ -0,0 +1,439 @@ +// Copyright (c) 2026, QuantStack and Mamba Contributors +// +// Distributed under the terms of the BSD 3-Clause License. + +#include + +#include +#include +#include + +#include "mamba/core/channel_context.hpp" +#include "mamba/core/shard_types.hpp" +#include "mamba/core/shards.hpp" +#include "mamba/core/util.hpp" +#include "mamba/download/mirror.hpp" +#include "mamba/download/parameters.hpp" +#include "mamba/download/request.hpp" +#include "mamba/fs/filesystem.hpp" +#include "mamba/specs/channel.hpp" +#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/string.hpp" + +#include "api/utils.hpp" +#include "core/shard_python_minor_prefilter.hpp" + +#include "mambatests.hpp" +#include "test_shard_utils.hpp" + +using namespace mamba; +using namespace mambatests::shard_test_utils; + +namespace +{ + auto make_simple_channel(std::string_view chan) -> specs::Channel + { + const auto resolve_params = ChannelContext::ChannelResolveParams{ + { "linux-64", "noarch" }, + specs::CondaURL::parse("https://conda.anaconda.org").value() + }; + + return specs::Channel::resolve(specs::UnresolvedChannel::parse(chan).value(), resolve_params) + .value() + .front(); + } + + auto create_shard_with_checksum( + const std::string& package_name, + const std::string& version, + const std::string& build, + const std::vector& depends = {}, + const std::vector& track_features = {} + ) -> std::vector + { + auto package_record = create_shard_package_record_msgpack( + package_name, + version, + build, + 0, + "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + std::nullopt, + depends, + {}, + std::nullopt, + HashFormat::String, + HashFormat::String, + track_features + ); + + msgpack_sbuffer sbuf; + msgpack_sbuffer_init(&sbuf); + msgpack_packer pk; + msgpack_packer_init(&pk, &sbuf, msgpack_sbuffer_write); + msgpack_pack_map(&pk, 1); + msgpack_pack_str(&pk, 8); + msgpack_pack_str_body(&pk, "packages", 8); + msgpack_pack_map(&pk, 1); + std::string filename = package_name + "-" + version + "-" + build + ".tar.bz2"; + msgpack_pack_str(&pk, filename.size()); + msgpack_pack_str_body(&pk, filename.c_str(), filename.size()); + msgpack_sbuffer_write( + &sbuf, + reinterpret_cast(package_record.data()), + package_record.size() + ); + std::vector shard_msgpack( + reinterpret_cast(sbuf.data), + reinterpret_cast(sbuf.data + sbuf.size) + ); + msgpack_sbuffer_destroy(&sbuf); + return compress_zstd(shard_msgpack); + } + + auto v(std::string_view s) -> specs::Version + { + return specs::Version::parse(std::string(s)).value(); + } +} + +TEST_CASE("version_from_single_equality_spec") +{ + using specs::VersionSpec; + + SECTION("explicit double-equals") + { + const auto vs = VersionSpec::parse("==3.7.12").value(); + const auto got = version_from_single_equality_spec(vs); + REQUIRE(got.has_value()); + REQUIRE(got.value() == v("3.7.12")); + } + + SECTION("bare equality parses as single ==") + { + const auto vs = VersionSpec::parse("3.12.5").value(); + const auto got = version_from_single_equality_spec(vs); + REQUIRE(got.has_value()); + REQUIRE(got.value() == v("3.12.5")); + } + + SECTION("greater-or-equal is not single equality") + { + const auto vs = VersionSpec::parse(">=3.7").value(); + REQUIRE_FALSE(version_from_single_equality_spec(vs).has_value()); + } + + SECTION("conjunction is not a single leaf") + { + const auto vs = VersionSpec::parse(">=3.12,<3.13").value(); + REQUIRE_FALSE(version_from_single_equality_spec(vs).has_value()); + } + + SECTION("disjunction is not a single leaf") + { + const auto vs = VersionSpec::parse("==3.7.12|==3.8.0").value(); + REQUIRE_FALSE(version_from_single_equality_spec(vs).has_value()); + } + + SECTION("free spec") + { + const VersionSpec vs{}; + REQUIRE_FALSE(version_from_single_equality_spec(vs).has_value()); + } +} + +TEST_CASE("extract_requested_python_minor") +{ + SECTION("empty specs") + { + REQUIRE_FALSE(extract_requested_python_minor({}).has_value()); + } + + SECTION("no python package") + { + REQUIRE_FALSE(extract_requested_python_minor({ "numpy >=1.0", "openssl 3" }).has_value()); + } + + SECTION("two-token exact pin relaxes to minor") + { + const auto got = extract_requested_python_minor({ "python 3.12.5" }); + REQUIRE(got.has_value()); + REQUIRE(got.value() == v("3.12")); + } + + SECTION("three-token conda pin") + { + const auto got = extract_requested_python_minor({ "python 3.7.12 0_73_pypy" }); + REQUIRE(got.has_value()); + REQUIRE(got.value() == v("3.7")); + } + + SECTION("explicit equality operator form") + { + const auto got = extract_requested_python_minor({ "python ==3.11" }); + REQUIRE(got.has_value()); + REQUIRE(got.value() == v("3.11")); + } + + SECTION("single equals conda pin form") + { + const auto got = extract_requested_python_minor({ "python=3.13" }); + REQUIRE(got.has_value()); + REQUIRE(got.value() == v("3.13")); + } + + SECTION("major-only pin yields no minor (avoids bogus 2.0/3.0 shard prefilter)") + { + REQUIRE_FALSE(extract_requested_python_minor({ "python=2" }).has_value()); + REQUIRE_FALSE(extract_requested_python_minor({ "python=3" }).has_value()); + } + + SECTION("range spec yields no minor (not single equality after relax)") + { + REQUIRE_FALSE(extract_requested_python_minor({ "python >=3.12,<3.13" }).has_value()); + } + + SECTION("uses first matching python spec") + { + const auto got = extract_requested_python_minor( + { "numpy 1.0", "python 3.10.0", "python 3.11" } + ); + REQUIRE(got.has_value()); + REQUIRE(got.value() == v("3.10")); + } + + SECTION("bare python name without version constraint") + { + REQUIRE_FALSE(extract_requested_python_minor({ "python" }).has_value()); + } +} + +TEST_CASE("Shards - python minor prefilter") +{ + ShardsIndexDict index; + index.info.base_url = "https://example.com/packages"; + index.info.shards_base_url = "shards"; + index.info.subdir = "linux-64"; + index.version = 1; + index.shards["test-pkg"] = std::vector(32, 0xAB); + + specs::Channel channel = make_simple_channel("https://example.com/conda-forge"); + specs::AuthenticationDataBase auth_info; + download::RemoteFetchParams remote_fetch_params; + + const auto tmp_dir = TemporaryDirectory(); + const auto shard_file = tmp_dir.path() / "test-pkg.msgpack.zst"; + + std::map package_to_cache_path; + package_to_cache_path["test-pkg"] = shard_file; + + auto run_for_dep = [&](const std::string& dep, + std::optional python_minor) -> expected_t + { + auto shard_data = create_shard_with_checksum("test-pkg", "1.0.0", "0", { dep }); + { + std::ofstream file(shard_file.string(), std::ios::binary); + file.write( + reinterpret_cast(shard_data.data()), + static_cast(shard_data.size()) + ); + } + + download::Success success; + success.content = download::Filename{ shard_file.string() }; + success.transfer.downloaded_size = shard_data.size(); + + Shards shards( + index, + "https://example.com/conda-forge/linux-64/repodata.json", + channel, + auth_info, + remote_fetch_params, + 0, + std::nullopt, + std::move(python_minor) + ); + return test_process_downloaded_shard(shards, "test-pkg", success, package_to_cache_path); + }; + + SECTION("mismatching python minor is discarded before record creation") + { + auto result = run_for_dep( + "python >=3.11,<3.12", + specs::Version::parse("3.12").value_or(specs::Version()) + ); + REQUIRE(result.has_value()); + REQUIRE(result->packages.empty()); + REQUIRE(result->conda_packages.empty()); + } + + SECTION("matching python minor is retained") + { + auto result = run_for_dep( + "python >=3.12,<3.13", + specs::Version::parse("3.12").value_or(specs::Version()) + ); + REQUIRE(result.has_value()); + REQUIRE(result->packages.size() == 1); + REQUIRE(result->packages.begin()->second.name == "test-pkg"); + } + + SECTION("no python minor context does not apply prefilter") + { + auto result = run_for_dep("python >=3.11,<3.12", std::nullopt); + REQUIRE(result.has_value()); + 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("matches_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(matches_python_minor("numpy >=1.0", req("3.12"))); + REQUIRE(matches_python_minor("libstdcxx-ng >=12", req("3.12"))); + } + + SECTION("name starting with python but not the python package") + { + REQUIRE(matches_python_minor("python_abi 3.12 1_cp312", req("3.12"))); + } + + SECTION("python version range matches requested minor") + { + REQUIRE(matches_python_minor("python >=3.12,<3.13", req("3.12"))); + REQUIRE(matches_python_minor("python >=3.12", req("3.12"))); + } + + SECTION("python version range does not match requested minor") + { + REQUIRE_FALSE(matches_python_minor("python >=3.12,<3.13", req("3.11"))); + REQUIRE_FALSE(matches_python_minor("python >=3.12,<3.13", req("3.13"))); + } + + SECTION("exact three-token conda pin matches requested minor") + { + REQUIRE(matches_python_minor("python 3.7.12 0_73_pypy", req("3.7"))); + } + + SECTION("two-token exact pin matches requested minor") + { + REQUIRE(matches_python_minor("python 3.7.12", req("3.7"))); + } + + SECTION("exact pin does not match different minor") + { + REQUIRE_FALSE(matches_python_minor("python 3.8.0", req("3.7"))); + } + + SECTION("leading whitespace on dependency line") + { + REQUIRE(matches_python_minor(" python >=3.12,<3.13", req("3.12"))); + } + + SECTION("unparsable python dependency does not filter (passes)") + { + REQUIRE(matches_python_minor("python ,,not-a-valid-spec,,", req("3.12"))); + } + + SECTION("namespaced python pin") + { + REQUIRE(matches_python_minor("conda-forge::python 3.7.12 0_73_pypy", req("3.7"))); + } + + SECTION("only python in range with no upper bound") + { + REQUIRE(matches_python_minor("python", req("3.12"))); + } +} diff --git a/libmamba/tests/src/core/test_sharded_repodata_integration.cpp b/libmamba/tests/src/core/test_sharded_repodata_integration.cpp index 94a5820986..f4ed0f7938 100644 --- a/libmamba/tests/src/core/test_sharded_repodata_integration.cpp +++ b/libmamba/tests/src/core/test_sharded_repodata_integration.cpp @@ -502,7 +502,57 @@ TEST_CASE( REQUIRE(found_python); } -TEST_CASE("Sharded repodata - solver results consistency", "[mamba::core][sharded][.integration][!mayfail]") +// Exercises the same sharded path with a large dependency tree: shard index, per-package shards, +// repodata build, and solver. Ensures packages like tensorflow (many python-version-specific +// builds in shards) remain resolvable when `repodata_use_shards` is enabled. +TEST_CASE( + "Sharded repodata - solve tensorflow with conda-forge (anaconda.org)", + "[mamba::core][sharded][.integration]" +) +{ + auto& ctx = mambatests::context(); + const std::vector saved_channels = ctx.channels; + const bool saved_use_shards = ctx.repodata_use_shards; + const bool saved_offline = ctx.offline; + on_scope_exit restore_ctx{ [&] + { + ctx.channels = saved_channels; + ctx.repodata_use_shards = saved_use_shards; + ctx.offline = saved_offline; + } }; + + ctx.channels = { "conda-forge" }; + ctx.repodata_use_shards = true; + ctx.offline = false; + + const TemporaryDirectory tmp_dir; + const fs::u8path cache_dir = tmp_dir.path() / "cache"; + fs::create_directories(cache_dir); + + auto channel_context = ChannelContext::make_conda_compatible(ctx); + init_channels(ctx, channel_context); + + auto solved = solve_environment( + ctx, + channel_context, + std::vector{ "tensorflow" }, + true, + cache_dir + ); + REQUIRE(solved.has_value()); + bool found_tensorflow = false; + for (const auto& pkg : solved.value().packages()) + { + if (pkg.name == "tensorflow") + { + found_tensorflow = true; + break; + } + } + REQUIRE(found_tensorflow); +} + +TEST_CASE("Sharded repodata - solver results consistency", "[mamba::core][sharded][.integration]") { auto& ctx = mambatests::context(); ctx.channels = { "https://prefix.dev/conda-forge" }; diff --git a/micromamba/tests/test_create.py b/micromamba/tests/test_create.py index f1d86e8d82..2179988e07 100644 --- a/micromamba/tests/test_create.py +++ b/micromamba/tests/test_create.py @@ -2529,13 +2529,23 @@ def test_create_from_oci_mirrored_channels(tmp_home, tmp_root_prefix, tmp_path, assert res["success"] packages = helpers.umamba_list("-p", env_prefix, "--json") - assert len(packages) == 1 - pkg = packages[0] - assert pkg["name"] == "pandoc" - if spec == "pandoc=3.1.13": - assert pkg["version"] == "3.1.13" - assert pkg["base_url"] == "oci://ghcr.io/channel-mirrors/conda-forge" - assert pkg["channel"] == "oci://ghcr.io/channel-mirrors/conda-forge" + assert len(packages) >= 1 + + # All resolved packages must come from the mirrored OCI channel. + assert all( + package["base_url"] == "oci://ghcr.io/channel-mirrors/conda-forge" + and package["channel"] == "oci://ghcr.io/channel-mirrors/conda-forge" + for package in packages + ) + + requested_name = spec.split("=")[0] + requested_pkg = next( + (package for package in packages if package["name"] == requested_name), None + ) + assert requested_pkg is not None + if "=" in spec: + requested_version = spec.split("=", 1)[1] + assert requested_pkg["version"] == requested_version @pytest.mark.parametrize("shared_pkgs_dirs", [True], indirect=True) @@ -2561,18 +2571,13 @@ def test_create_from_oci_mirrored_channels_with_deps(tmp_home, tmp_root_prefix, packages = helpers.umamba_list("-p", env_prefix, "--json") assert len(packages) > 2 - assert any( - package["name"] == "xtensor" - and package["base_url"] == "oci://ghcr.io/channel-mirrors/conda-forge" - and package["channel"] == "oci://ghcr.io/channel-mirrors/conda-forge" - for package in packages - ) - assert any( - package["name"] == "xtl" - and package["base_url"] == "oci://ghcr.io/channel-mirrors/conda-forge" + assert all( + package["base_url"] == "oci://ghcr.io/channel-mirrors/conda-forge" and package["channel"] == "oci://ghcr.io/channel-mirrors/conda-forge" for package in packages ) + assert any(package["name"] == "xtensor" for package in packages) + assert any(package["name"] == "xtl" for package in packages) @pytest.mark.parametrize("shared_pkgs_dirs", [True], indirect=True) @@ -2601,11 +2606,13 @@ def test_create_from_oci_mirrored_channels_pkg_name_mapping( assert res["success"] packages = helpers.umamba_list("-p", env_prefix, "--json") - assert len(packages) == 1 - pkg = packages[0] - assert pkg["name"] == "_go_select" - assert pkg["base_url"] == "oci://ghcr.io/channel-mirrors/conda-forge" - assert pkg["channel"] == "oci://ghcr.io/channel-mirrors/conda-forge" + assert len(packages) >= 1 + assert all( + package["base_url"] == "oci://ghcr.io/channel-mirrors/conda-forge" + and package["channel"] == "oci://ghcr.io/channel-mirrors/conda-forge" + for package in packages + ) + assert any(package["name"] == "_go_select" for package in packages) @pytest.mark.parametrize("shared_pkgs_dirs", [True], indirect=True) diff --git a/micromamba/tests/test_install.py b/micromamba/tests/test_install.py index 40720db5b6..263477a37e 100644 --- a/micromamba/tests/test_install.py +++ b/micromamba/tests/test_install.py @@ -758,11 +758,16 @@ def test_python_abi_preserved_with_freethreading(tmp_home, tmp_root_prefix): try: helpers.install("-n", env_name, "--json", "matplotlib", no_dry_run=True) except subprocess.CalledProcessError as e: - assert "matplotlib =* * is installable with the potential options" in e.stderr.decode( - "utf-8" - ), ( - "Expected error message about matplotlib being installable with a non-free-threaded python_abi. " - "If this test fails, it might be because matplotlib is now installable with a non-free-threaded python_abi." + # With `--json`, stderr may hold the problem tree and stdout the JSON payload; the + # tree line format can change (e.g. `matplotlib =* *` vs `matplotlib`). JSON + # `solver_problems` uses libsolv strings, which omit the tree phrase. + combined = (e.stderr or b"").decode("utf-8") + (e.stdout or b"").decode("utf-8") + assert "matplotlib" in combined.lower(), combined + tree_explanation = "is installable with the potential options" in combined + mentions_abi = "python_abi" in combined + assert tree_explanation or mentions_abi, ( + "Expected a problem tree or python_abi mention for the matplotlib conflict. Output was:\n" + + combined ) # Verify python_abi is still the same (free-threaded)