From 844c5b8ef6e0d87fe900bd22b6aa3a1d05ff7a86 Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Fri, 10 Apr 2026 15:02:52 +0200 Subject: [PATCH 01/41] Fix clang-format violations Apply clang-format to ternary expression in database.cpp and a pre-existing line-break issue in helpers.cpp. --- libmamba/src/solver/libsolv/helpers.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/libmamba/src/solver/libsolv/helpers.cpp b/libmamba/src/solver/libsolv/helpers.cpp index f5b16d94e3..71864b6c0b 100644 --- a/libmamba/src/solver/libsolv/helpers.cpp +++ b/libmamba/src/solver/libsolv/helpers.cpp @@ -1533,8 +1533,7 @@ namespace mamba::solver::libsolv return true; } } - if constexpr (std::is_same_v - || std::is_same_v) + if constexpr (std::is_same_v || std::is_same_v) { if (action.what.name == pkg_name) { From d88fa4df9f9a6671441fcb435a7db1fb4df4a825 Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Mon, 8 Jun 2026 14:09:51 +0200 Subject: [PATCH 02/41] Address exclude-newer review feedback --- libmamba/src/solver/libsolv/helpers.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libmamba/src/solver/libsolv/helpers.cpp b/libmamba/src/solver/libsolv/helpers.cpp index 71864b6c0b..f5b16d94e3 100644 --- a/libmamba/src/solver/libsolv/helpers.cpp +++ b/libmamba/src/solver/libsolv/helpers.cpp @@ -1533,7 +1533,8 @@ namespace mamba::solver::libsolv return true; } } - if constexpr (std::is_same_v || std::is_same_v) + if constexpr (std::is_same_v + || std::is_same_v) { if (action.what.name == pkg_name) { From 40cfc6be76f631585cd917e6cd05480c22b22029 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Wed, 10 Jun 2026 11:07:48 +0200 Subject: [PATCH 03/41] feat: Wire global exclude_newer config and CLI to solver Signed-off-by: Julien Jerphanion --- libmamba/CMakeLists.txt | 1 + libmamba/include/mamba/core/context.hpp | 2 + libmamba/include/mamba/core/exclude_newer.hpp | 35 +++ .../include/mamba/solver/libsolv/database.hpp | 14 + libmamba/src/api/configuration.cpp | 23 ++ libmamba/src/api/install.cpp | 4 +- libmamba/src/api/remove.cpp | 2 +- libmamba/src/api/repoquery.cpp | 8 +- libmamba/src/api/utils.cpp | 50 +++- libmamba/src/api/utils.hpp | 2 +- libmamba/src/core/exclude_newer.cpp | 251 ++++++++++++++++++ libmamba/src/core/package_database_loader.cpp | 4 +- libmamba/tests/CMakeLists.txt | 1 + .../tests/src/core/test_exclude_newer.cpp | 52 ++++ micromamba/src/common_options.cpp | 7 + 15 files changed, 434 insertions(+), 22 deletions(-) create mode 100644 libmamba/include/mamba/core/exclude_newer.hpp create mode 100644 libmamba/src/core/exclude_newer.cpp create mode 100644 libmamba/tests/src/core/test_exclude_newer.cpp diff --git a/libmamba/CMakeLists.txt b/libmamba/CMakeLists.txt index 2f10878112..c8a3bc88fb 100644 --- a/libmamba/CMakeLists.txt +++ b/libmamba/CMakeLists.txt @@ -227,6 +227,7 @@ set( ${LIBMAMBA_SOURCE_DIR}/core/env_lockfile_conda.cpp ${LIBMAMBA_SOURCE_DIR}/core/env_lockfile_mambajs.cpp ${LIBMAMBA_SOURCE_DIR}/core/environments_manager.cpp + ${LIBMAMBA_SOURCE_DIR}/core/exclude_newer.cpp ${LIBMAMBA_SOURCE_DIR}/core/error_handling.cpp ${LIBMAMBA_SOURCE_DIR}/core/execution.cpp ${LIBMAMBA_SOURCE_DIR}/core/fsutil.cpp diff --git a/libmamba/include/mamba/core/context.hpp b/libmamba/include/mamba/core/context.hpp index 71160c2e1f..c289fb6a1a 100644 --- a/libmamba/include/mamba/core/context.hpp +++ b/libmamba/include/mamba/core/context.hpp @@ -130,6 +130,8 @@ namespace mamba // solver options solver::Request::Flags solver_flags = {}; + std::string exclude_newer; + std::map exclude_newer_package; // add start menu shortcuts on Windows (not implemented on Linux / macOS) bool shortcuts = true; diff --git a/libmamba/include/mamba/core/exclude_newer.hpp b/libmamba/include/mamba/core/exclude_newer.hpp new file mode 100644 index 0000000000..d9532a9d19 --- /dev/null +++ b/libmamba/include/mamba/core/exclude_newer.hpp @@ -0,0 +1,35 @@ +// 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_EXCLUDE_NEWER_HPP +#define MAMBA_CORE_EXCLUDE_NEWER_HPP + +#include +#include +#include + +namespace mamba +{ + /** + * Resolve a global ``exclude_newer`` configuration value to an absolute Unix + * timestamp cutoff in seconds. + * + * Matches conda's ``exclude_newer`` semantics: + * - Durations (``7d``, ``P7D``, plain seconds) resolve to ``now - duration`` + * - Date-only values (``YYYY-MM-DD``) resolve to the start of the next UTC day + * - Datetimes resolve to the given instant (naive values are UTC) + * - Zero durations (``0``, ``0d``, ``P0D``) resolve to ``now`` + * + * Returns ``std::nullopt`` when ``value`` is empty/whitespace-only. + * + * @throws std::invalid_argument when the value cannot be parsed. + */ + [[nodiscard]] auto resolve_exclude_newer_cutoff(std::string_view value, std::uint64_t now_seconds) + -> std::optional; + +} // namespace mamba + +#endif diff --git a/libmamba/include/mamba/solver/libsolv/database.hpp b/libmamba/include/mamba/solver/libsolv/database.hpp index 41b828a07e..aaf7a594d2 100644 --- a/libmamba/include/mamba/solver/libsolv/database.hpp +++ b/libmamba/include/mamba/solver/libsolv/database.hpp @@ -65,6 +65,20 @@ namespace mamba::solver::libsolv struct Settings { MatchSpecParser matchspec_parser = MatchSpecParser::Libsolv; + /** + * When set, packages with a policy timestamp newer than this Unix epoch cutoff + * (seconds) are excluded during repodata loading. + * + * For repodata JSON, `indexed_timestamp` is preferred over `timestamp` when both + * are present, matching conda's `--exclude-newer` semantics. + * + * This is a global cutoff for the whole `Database`. Callers that need + * channel- or package-specific policies (e.g. conda with overrides) should apply + * filtering before handing records to libmamba. + * + * The `.solv` cache path is not filtered; invalidate the cache when this value + * changes. + */ std::optional exclude_newer_timestamp = std::nullopt; }; diff --git a/libmamba/src/api/configuration.cpp b/libmamba/src/api/configuration.cpp index f1e2ae3251..acb1a6ffd3 100644 --- a/libmamba/src/api/configuration.cpp +++ b/libmamba/src/api/configuration.cpp @@ -1719,6 +1719,29 @@ namespace mamba .set_env_var_names() .description("Allow downgrade when installing packages. Default is false.")); + insert(Configurable("exclude_newer", &m_context.exclude_newer) + .group("Solver") + .set_rc_configurable() + .set_env_var_names({ "CONDA_EXCLUDE_NEWER", "MAMBA_EXCLUDE_NEWER" }) + .description("Exclude packages published more recently than the given duration or date") + .long_description(unindent(R"( + Exclude packages with a policy timestamp newer than the cutoff. + Accepts durations (e.g. 7d, 3d12h, 1w, P7D), ISO datetimes + (e.g. 2026-04-01T12:00:00Z), or date-only values (e.g. 2026-04-01, + interpreted as the start of the next UTC day). Plain integers are + treated as durations in seconds. Supply 0 for no delay, using the + current time as the cutoff.)"))); + + insert(Configurable("exclude_newer_package", &m_context.exclude_newer_package) + .group("Solver") + .set_rc_configurable() + .set_env_var_names({ "CONDA_EXCLUDE_NEWER_PACKAGE", "MAMBA_EXCLUDE_NEWER_PACKAGE" }) + .description("Per-package overrides for the exclude_newer policy") + .long_description(unindent(R"( + Maps package names to durations, dates, timestamps, or false to exempt + a package from the global exclude_newer policy. Only the global policy + is applied natively by the libmamba solver backend today.)"))); + insert(Configurable("order_solver_request", &m_context.solver_flags.order_request) .group("Solver") .set_rc_configurable() diff --git a/libmamba/src/api/install.cpp b/libmamba/src/api/install.cpp index 6b145ebe1f..5f1da52031 100644 --- a/libmamba/src/api/install.cpp +++ b/libmamba/src/api/install.cpp @@ -740,7 +740,7 @@ namespace mamba bool remove_prefix_on_failure ) { - auto database = make_solver_database(ctx.experimental_matchspec_parsing, channel_context); + auto database = make_solver_database(ctx, channel_context); init_channels(ctx, channel_context); // Some use cases provide a list of explicit specs, but an empty @@ -1232,7 +1232,7 @@ namespace mamba MultiPackageCache package_caches{ ctx.pkgs_dirs, ctx.validation_params }; - solver::libsolv::Database db{ channel_context.params() }; + auto db = make_solver_database(ctx, channel_context); add_logger_to_database(db); auto maybe_load = load_channels(ctx, channel_context, db, package_caches); diff --git a/libmamba/src/api/remove.cpp b/libmamba/src/api/remove.cpp index fab13188ba..8ab72d7b7f 100644 --- a/libmamba/src/api/remove.cpp +++ b/libmamba/src/api/remove.cpp @@ -126,7 +126,7 @@ namespace mamba ) { validate_target_prefix_and_channels(ctx, /* create_env= */ false); - auto database = make_solver_database(ctx.experimental_matchspec_parsing, channel_context); + auto database = make_solver_database(ctx, channel_context); auto prefix_data = load_prefix_data_and_installed(ctx, channel_context, database); const fs::u8path pkgs_dirs(ctx.prefix_params.root_prefix / "pkgs"); diff --git a/libmamba/src/api/repoquery.cpp b/libmamba/src/api/repoquery.cpp index 2302d7b7e7..5f35ff3d01 100644 --- a/libmamba/src/api/repoquery.cpp +++ b/libmamba/src/api/repoquery.cpp @@ -57,13 +57,7 @@ namespace mamba config.load(); auto channel_context = ChannelContext::make_conda_compatible(ctx); - solver::libsolv::Database db{ - channel_context.params(), - { - ctx.experimental_matchspec_parsing ? solver::libsolv::MatchSpecParser::Mamba - : solver::libsolv::MatchSpecParser::Libsolv, - }, - }; + auto db = make_solver_database(ctx, channel_context); add_logger_to_database(db); // bool installed = (type == QueryType::kDepends) || (type == QueryType::kWhoneeds); diff --git a/libmamba/src/api/utils.cpp b/libmamba/src/api/utils.cpp index 98769076f4..ff846b7ee5 100644 --- a/libmamba/src/api/utils.cpp +++ b/libmamba/src/api/utils.cpp @@ -24,6 +24,8 @@ #include "mamba/core/channel_context.hpp" #include "mamba/core/context.hpp" #include "mamba/core/environments_manager.hpp" +#include "mamba/core/exclude_newer.hpp" +#include "mamba/core/logging.hpp" #include "mamba/core/output.hpp" #include "mamba/core/package_cache.hpp" #include "mamba/core/package_database_loader.hpp" @@ -518,16 +520,34 @@ namespace mamba return outcome; } - solver::libsolv::Database - make_solver_database(bool experimental_matchspec_parsing, ChannelContext& channel_context) + namespace { - solver::libsolv::Database db{ - channel_context.params(), + [[nodiscard]] auto resolve_context_exclude_newer(const Context& ctx) + -> std::optional + { + if (ctx.exclude_newer.empty()) { - experimental_matchspec_parsing ? solver::libsolv::MatchSpecParser::Mamba - : solver::libsolv::MatchSpecParser::Libsolv, - }, - }; + return std::nullopt; + } + const auto now = static_cast(std::time(nullptr)); + return resolve_exclude_newer_cutoff(ctx.exclude_newer, now); + } + + [[nodiscard]] auto make_database_settings(const Context& ctx) + -> solver::libsolv::Database::Settings + { + return { + ctx.experimental_matchspec_parsing ? solver::libsolv::MatchSpecParser::Mamba + : solver::libsolv::MatchSpecParser::Libsolv, + resolve_context_exclude_newer(ctx), + }; + } + } // namespace + + solver::libsolv::Database + make_solver_database(const Context& ctx, ChannelContext& channel_context) + { + solver::libsolv::Database db{ channel_context.params(), make_database_settings(ctx) }; add_logger_to_database(db); return db; } @@ -575,7 +595,19 @@ namespace mamba ) { populate_context_channels_from_specs(raw_specs, ctx); - auto db = make_solver_database(ctx.experimental_matchspec_parsing, channel_context); + + if (!ctx.exclude_newer_package.empty()) + { + LOG_WARNING << "exclude_newer_package is configured but only the global exclude_newer " + "policy is applied natively by the libmamba solver backend"; + } + if (!ctx.exclude_newer.empty() && !ctx.mamba_repodata_parsing) + { + LOG_WARNING << "exclude_newer requires the Mamba repodata parser; packages loaded from " + "the libsolv parser will not be filtered"; + } + + auto db = make_solver_database(ctx, channel_context); MultiPackageCache package_caches(ctx.pkgs_dirs, ctx.validation_params); auto root_packages = ctx.use_sharded_repodata diff --git a/libmamba/src/api/utils.hpp b/libmamba/src/api/utils.hpp index ba61ad4a54..796f5dbc42 100644 --- a/libmamba/src/api/utils.hpp +++ b/libmamba/src/api/utils.hpp @@ -124,7 +124,7 @@ namespace mamba * Create a libsolv database configured for the current matching behavior. */ solver::libsolv::Database - make_solver_database(bool experimental_matchspec_parsing, ChannelContext& channel_context); + make_solver_database(const Context& ctx, ChannelContext& channel_context); /** * Apply shared prefix fallback defaults used by install/update entry points. diff --git a/libmamba/src/core/exclude_newer.cpp b/libmamba/src/core/exclude_newer.cpp new file mode 100644 index 0000000000..eb07b1134b --- /dev/null +++ b/libmamba/src/core/exclude_newer.cpp @@ -0,0 +1,251 @@ +// 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 +#include +#include +#include + +#include "mamba/core/exclude_newer.hpp" + +namespace mamba +{ + namespace + { + [[nodiscard]] auto trim(std::string_view value) -> std::string_view + { + while (!value.empty() && std::isspace(static_cast(value.front()))) + { + value.remove_prefix(1); + } + while (!value.empty() && std::isspace(static_cast(value.back()))) + { + value.remove_suffix(1); + } + return value; + } + + [[nodiscard]] auto invalid_exclude_newer(std::string_view value) -> std::invalid_argument + { + return std::invalid_argument( + "Invalid exclude_newer value '" + std::string(value) + + "'; use e.g. 7d, P7D, 2026-04-01, or 2026-04-01T12:00:00Z" + ); + } + + [[nodiscard]] auto parse_plain_seconds(std::string_view value) -> std::optional + { + std::uint64_t seconds = 0; + const auto* begin = value.data(); + const auto* end = begin + value.size(); + const auto [ptr, ec] = std::from_chars(begin, end, seconds); + if (ec != std::errc() || ptr != end) + { + return std::nullopt; + } + return seconds; + } + + [[nodiscard]] auto parse_iso8601_duration_seconds(std::string_view value) + -> std::optional + { + static const std::regex iso_duration_re( + R"re(^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$)re", + std::regex::icase + ); + std::cmatch match; + if (!std::regex_match(value.begin(), value.end(), match, iso_duration_re)) + { + return std::nullopt; + } + + bool has_component = false; + std::uint64_t total = 0; + const auto add = [&](const std::csub_match& group, std::uint64_t unit) + { + if (!group.matched) + { + return; + } + has_component = true; + total += static_cast(std::stoull(std::string(group))) * unit; + }; + + add(match[1], 604800); + add(match[2], 86400); + add(match[3], 3600); + add(match[4], 60); + add(match[5], 1); + + if (!has_component) + { + throw invalid_exclude_newer(value); + } + return total; + } + + [[nodiscard]] auto parse_compact_duration_seconds(std::string_view value) + -> std::optional + { + static const std::regex compact_duration_re(R"re(^(\d+)\s*([wdhms])$)re", std::regex::icase); + std::cmatch match; + if (!std::regex_match(value.begin(), value.end(), match, compact_duration_re)) + { + return std::nullopt; + } + + const auto amount = static_cast(std::stoull(std::string(match[1]))); + const auto unit = std::tolower(static_cast(match[2].first[0])); + switch (unit) + { + case 'w': + return amount * 604800; + case 'd': + return amount * 86400; + case 'h': + return amount * 3600; + case 'm': + return amount * 60; + case 's': + return amount; + default: + return std::nullopt; + } + } + + [[nodiscard]] auto parse_duration_seconds(std::string_view value) + -> std::optional + { + if (auto seconds = parse_plain_seconds(value)) + { + return seconds; + } + if (auto seconds = parse_iso8601_duration_seconds(value)) + { + return seconds; + } + return parse_compact_duration_seconds(value); + } + + [[nodiscard]] auto timegm_utc(std::tm tm) -> std::time_t + { +#if defined(_WIN32) + return _mkgmtime(&tm); +#else + return timegm(&tm); +#endif + } + + [[nodiscard]] auto parse_date_only_next_utc_day(std::string_view value) + -> std::optional + { + static const std::regex date_only_re(R"re(^(\d{4})-(\d{2})-(\d{2})$)re"); + std::cmatch match; + if (!std::regex_match(value.begin(), value.end(), match, date_only_re)) + { + return std::nullopt; + } + + std::tm tm = {}; + tm.tm_year = std::stoi(std::string(match[1])) - 1900; + tm.tm_mon = std::stoi(std::string(match[2])) - 1; + tm.tm_mday = std::stoi(std::string(match[3])) + 1; + tm.tm_hour = 0; + tm.tm_min = 0; + tm.tm_sec = 0; + tm.tm_isdst = 0; + return static_cast(timegm_utc(tm)); + } + + [[nodiscard]] auto parse_datetime_timestamp(std::string_view value) + -> std::optional + { + static const std::regex datetime_re( + R"re(^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(Z|([+-])(\d{2}):(\d{2}))?$)re" + ); + std::cmatch match; + if (!std::regex_match(value.begin(), value.end(), match, datetime_re)) + { + return std::nullopt; + } + + std::tm tm = {}; + tm.tm_year = std::stoi(std::string(match[1])) - 1900; + tm.tm_mon = std::stoi(std::string(match[2])) - 1; + tm.tm_mday = std::stoi(std::string(match[3])); + tm.tm_hour = std::stoi(std::string(match[4])); + tm.tm_min = std::stoi(std::string(match[5])); + tm.tm_sec = std::stoi(std::string(match[6])); + tm.tm_isdst = 0; + + auto timestamp = static_cast(timegm_utc(tm)); + + if (match[7].matched && match[7].str() != "Z") + { + const auto sign = match[8].str() == "+" ? 1 : -1; + const auto offset_hours = std::stoi(std::string(match[9])); + const auto offset_minutes = std::stoi(std::string(match[10])); + const auto offset_seconds = sign * (offset_hours * 3600 + offset_minutes * 60); + timestamp -= offset_seconds; + } + + if (timestamp < 0) + { + return std::nullopt; + } + return static_cast(timestamp); + } + + [[nodiscard]] auto + duration_cutoff(std::uint64_t duration_seconds, std::uint64_t now_seconds) -> std::uint64_t + { + if (duration_seconds > now_seconds) + { + return 0; + } + return now_seconds - duration_seconds; + } + + [[nodiscard]] auto + resolve_exclude_newer_cutoff_impl(std::string_view value, std::uint64_t now_seconds) + -> std::optional + { + value = trim(value); + if (value.empty()) + { + return std::nullopt; + } + + if (auto duration_seconds = parse_duration_seconds(value)) + { + return duration_cutoff(*duration_seconds, now_seconds); + } + + if (auto timestamp = parse_date_only_next_utc_day(value)) + { + return *timestamp; + } + + if (auto timestamp = parse_datetime_timestamp(value)) + { + return *timestamp; + } + + throw invalid_exclude_newer(value); + } + + } // namespace + + auto resolve_exclude_newer_cutoff(std::string_view value, std::uint64_t now_seconds) + -> std::optional + { + return resolve_exclude_newer_cutoff_impl(value, now_seconds); + } + +} // namespace mamba diff --git a/libmamba/src/core/package_database_loader.cpp b/libmamba/src/core/package_database_loader.cpp index edc3b334d3..1468bae87c 100644 --- a/libmamba/src/core/package_database_loader.cpp +++ b/libmamba/src/core/package_database_loader.cpp @@ -89,8 +89,8 @@ namespace mamba ? solver::libsolv::RepodataParser::Mamba : solver::libsolv::RepodataParser::Libsolv; - // Solv files are too slow on Windows. - if (!util::on_win) + // Solv files are too slow on Windows. They also bypass exclude_newer filtering. + if (!util::on_win && ctx.exclude_newer.empty()) { auto maybe_repo = subdir.valid_libsolv_cache_path().and_then( [&](fs::u8path&& solv_file) diff --git a/libmamba/tests/CMakeLists.txt b/libmamba/tests/CMakeLists.txt index db0e38d240..69949867f2 100644 --- a/libmamba/tests/CMakeLists.txt +++ b/libmamba/tests/CMakeLists.txt @@ -95,6 +95,7 @@ set( src/core/test_cpp.cpp src/core/test_env_file_reading.cpp src/core/test_env_lockfile.cpp + src/core/test_exclude_newer.cpp src/core/test_environments_manager.cpp src/core/test_execution.cpp src/core/test_filesystem.cpp diff --git a/libmamba/tests/src/core/test_exclude_newer.cpp b/libmamba/tests/src/core/test_exclude_newer.cpp new file mode 100644 index 0000000000..2fa716aaac --- /dev/null +++ b/libmamba/tests/src/core/test_exclude_newer.cpp @@ -0,0 +1,52 @@ +// 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 "mamba/core/exclude_newer.hpp" + +using namespace mamba; + +namespace +{ + TEST_CASE("resolve_exclude_newer_cutoff") + { + constexpr std::uint64_t now = 1'700'000'000; + + SECTION("empty value is disabled") + { + REQUIRE(resolve_exclude_newer_cutoff("", now) == std::nullopt); + REQUIRE(resolve_exclude_newer_cutoff(" ", now) == std::nullopt); + } + + SECTION("durations resolve relative to now") + { + REQUIRE(resolve_exclude_newer_cutoff("7d", now) == now - 7 * 86400); + REQUIRE(resolve_exclude_newer_cutoff("P7D", now) == now - 7 * 86400); + REQUIRE(resolve_exclude_newer_cutoff("3600", now) == now - 3600); + REQUIRE(resolve_exclude_newer_cutoff("0d", now) == now); + REQUIRE(resolve_exclude_newer_cutoff("P0D", now) == now); + } + + SECTION("date-only values use the start of the next UTC day") + { + REQUIRE(resolve_exclude_newer_cutoff("2026-04-01", now) == 1'775'088'000); + } + + SECTION("datetimes resolve to absolute instants") + { + REQUIRE(resolve_exclude_newer_cutoff("2026-04-01T12:00:00", now) == 1'775'044'800); + REQUIRE(resolve_exclude_newer_cutoff("2026-04-01T10:00:00Z", now) == 1'775'037'600); + REQUIRE(resolve_exclude_newer_cutoff("2026-04-01T12:00:00+02:00", now) == 1'775'037'600); + } + + SECTION("invalid values throw") + { + REQUIRE_THROWS_AS(resolve_exclude_newer_cutoff("not-a-duration", now), std::invalid_argument); + REQUIRE_THROWS_AS(resolve_exclude_newer_cutoff("P", now), std::invalid_argument); + } + } +} // namespace diff --git a/micromamba/src/common_options.cpp b/micromamba/src/common_options.cpp index 5c2de81717..665ff320ed 100644 --- a/micromamba/src/common_options.cpp +++ b/micromamba/src/common_options.cpp @@ -397,6 +397,13 @@ init_install_options(CLI::App* subcom, Configuration& config) allow_downgrade.description() ); + auto& exclude_newer = config.at("exclude_newer"); + subcom->add_option( + "--exclude-newer", + exclude_newer.get_cli_config(), + exclude_newer.description() + ); + auto& allow_softlinks = config.at("allow_softlinks"); subcom->add_flag( "--allow-softlinks,!--no-allow-softlinks", From 386634fdf9b7589b0689b50aa5073df865fac1ac Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Wed, 10 Jun 2026 15:52:44 +0200 Subject: [PATCH 04/41] Remove useless `static_cast` Signed-off-by: Julien Jerphanion --- libmamba/src/core/exclude_newer.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libmamba/src/core/exclude_newer.cpp b/libmamba/src/core/exclude_newer.cpp index eb07b1134b..e5caddbbd1 100644 --- a/libmamba/src/core/exclude_newer.cpp +++ b/libmamba/src/core/exclude_newer.cpp @@ -184,7 +184,7 @@ namespace mamba tm.tm_sec = std::stoi(std::string(match[6])); tm.tm_isdst = 0; - auto timestamp = static_cast(timegm_utc(tm)); + auto timestamp = timegm_utc(tm); if (match[7].matched && match[7].str() != "Z") { From 407996e022f4b9a8e29912ea8803ec3d29cf066b Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Wed, 10 Jun 2026 15:57:47 +0200 Subject: [PATCH 05/41] Do not pass the context's instance Signed-off-by: Julien Jerphanion --- libmamba/src/api/install.cpp | 12 ++++++++++-- libmamba/src/api/remove.cpp | 6 +++++- libmamba/src/api/repoquery.cpp | 6 +++++- libmamba/src/api/utils.cpp | 33 ++++++++++++++++++++++----------- libmamba/src/api/utils.hpp | 7 +++++-- 5 files changed, 47 insertions(+), 17 deletions(-) diff --git a/libmamba/src/api/install.cpp b/libmamba/src/api/install.cpp index 5f1da52031..4ce36cacce 100644 --- a/libmamba/src/api/install.cpp +++ b/libmamba/src/api/install.cpp @@ -740,7 +740,11 @@ namespace mamba bool remove_prefix_on_failure ) { - auto database = make_solver_database(ctx, channel_context); + auto database = make_solver_database( + channel_context, + ctx.experimental_matchspec_parsing, + ctx.exclude_newer + ); init_channels(ctx, channel_context); // Some use cases provide a list of explicit specs, but an empty @@ -1232,7 +1236,11 @@ namespace mamba MultiPackageCache package_caches{ ctx.pkgs_dirs, ctx.validation_params }; - auto db = make_solver_database(ctx, channel_context); + auto db = make_solver_database( + channel_context, + ctx.experimental_matchspec_parsing, + ctx.exclude_newer + ); add_logger_to_database(db); auto maybe_load = load_channels(ctx, channel_context, db, package_caches); diff --git a/libmamba/src/api/remove.cpp b/libmamba/src/api/remove.cpp index 8ab72d7b7f..4b896337e9 100644 --- a/libmamba/src/api/remove.cpp +++ b/libmamba/src/api/remove.cpp @@ -126,7 +126,11 @@ namespace mamba ) { validate_target_prefix_and_channels(ctx, /* create_env= */ false); - auto database = make_solver_database(ctx, channel_context); + auto database = make_solver_database( + channel_context, + ctx.experimental_matchspec_parsing, + ctx.exclude_newer + ); auto prefix_data = load_prefix_data_and_installed(ctx, channel_context, database); const fs::u8path pkgs_dirs(ctx.prefix_params.root_prefix / "pkgs"); diff --git a/libmamba/src/api/repoquery.cpp b/libmamba/src/api/repoquery.cpp index 5f35ff3d01..5a52eb7b89 100644 --- a/libmamba/src/api/repoquery.cpp +++ b/libmamba/src/api/repoquery.cpp @@ -57,7 +57,11 @@ namespace mamba config.load(); auto channel_context = ChannelContext::make_conda_compatible(ctx); - auto db = make_solver_database(ctx, channel_context); + auto db = make_solver_database( + channel_context, + ctx.experimental_matchspec_parsing, + ctx.exclude_newer + ); add_logger_to_database(db); // bool installed = (type == QueryType::kDepends) || (type == QueryType::kWhoneeds); diff --git a/libmamba/src/api/utils.cpp b/libmamba/src/api/utils.cpp index ff846b7ee5..688e0f56ab 100644 --- a/libmamba/src/api/utils.cpp +++ b/libmamba/src/api/utils.cpp @@ -522,32 +522,39 @@ namespace mamba namespace { - [[nodiscard]] auto resolve_context_exclude_newer(const Context& ctx) + [[nodiscard]] auto resolve_exclude_newer_timestamp(const std::string& exclude_newer) -> std::optional { - if (ctx.exclude_newer.empty()) + if (exclude_newer.empty()) { return std::nullopt; } const auto now = static_cast(std::time(nullptr)); - return resolve_exclude_newer_cutoff(ctx.exclude_newer, now); + return resolve_exclude_newer_cutoff(exclude_newer, now); } - [[nodiscard]] auto make_database_settings(const Context& ctx) + [[nodiscard]] auto + make_database_settings(bool experimental_matchspec_parsing, const std::string& exclude_newer) -> solver::libsolv::Database::Settings { return { - ctx.experimental_matchspec_parsing ? solver::libsolv::MatchSpecParser::Mamba - : solver::libsolv::MatchSpecParser::Libsolv, - resolve_context_exclude_newer(ctx), + experimental_matchspec_parsing ? solver::libsolv::MatchSpecParser::Mamba + : solver::libsolv::MatchSpecParser::Libsolv, + resolve_exclude_newer_timestamp(exclude_newer), }; } } // namespace - solver::libsolv::Database - make_solver_database(const Context& ctx, ChannelContext& channel_context) + solver::libsolv::Database make_solver_database( + ChannelContext& channel_context, + bool experimental_matchspec_parsing, + const std::string& exclude_newer + ) { - solver::libsolv::Database db{ channel_context.params(), make_database_settings(ctx) }; + solver::libsolv::Database db{ + channel_context.params(), + make_database_settings(experimental_matchspec_parsing, exclude_newer), + }; add_logger_to_database(db); return db; } @@ -607,7 +614,11 @@ namespace mamba "the libsolv parser will not be filtered"; } - auto db = make_solver_database(ctx, channel_context); + auto db = make_solver_database( + channel_context, + ctx.experimental_matchspec_parsing, + ctx.exclude_newer + ); MultiPackageCache package_caches(ctx.pkgs_dirs, ctx.validation_params); auto root_packages = ctx.use_sharded_repodata diff --git a/libmamba/src/api/utils.hpp b/libmamba/src/api/utils.hpp index 796f5dbc42..151a938cb7 100644 --- a/libmamba/src/api/utils.hpp +++ b/libmamba/src/api/utils.hpp @@ -123,8 +123,11 @@ namespace mamba /** * Create a libsolv database configured for the current matching behavior. */ - solver::libsolv::Database - make_solver_database(const Context& ctx, ChannelContext& channel_context); + solver::libsolv::Database make_solver_database( + ChannelContext& channel_context, + bool experimental_matchspec_parsing, + const std::string& exclude_newer + ); /** * Apply shared prefix fallback defaults used by install/update entry points. From 4ec42c741334f5dabb1046557e3085b2a8e8ff90 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Wed, 10 Jun 2026 16:09:32 +0200 Subject: [PATCH 06/41] Support `exclude_newer_package` Signed-off-by: Julien Jerphanion --- libmamba/include/mamba/core/exclude_newer.hpp | 65 +++++++++++++++++++ .../include/mamba/solver/libsolv/database.hpp | 13 ++-- libmamba/src/api/configuration.cpp | 4 +- libmamba/src/api/install.cpp | 6 +- libmamba/src/api/remove.cpp | 3 +- libmamba/src/api/repoquery.cpp | 3 +- libmamba/src/api/utils.cpp | 38 +++++------ libmamba/src/api/utils.hpp | 4 +- libmamba/src/core/exclude_newer.cpp | 46 +++++++++++++ libmamba/src/core/package_database_loader.cpp | 2 +- libmamba/src/solver/libsolv/database.cpp | 17 +++-- libmamba/src/solver/libsolv/helpers.cpp | 26 ++++---- libmamba/src/solver/libsolv/helpers.hpp | 3 +- .../tests/src/core/test_exclude_newer.cpp | 51 +++++++++++++++ .../src/solver/libsolv/test_database.cpp | 57 ++++++++++++++++ 15 files changed, 282 insertions(+), 56 deletions(-) diff --git a/libmamba/include/mamba/core/exclude_newer.hpp b/libmamba/include/mamba/core/exclude_newer.hpp index d9532a9d19..7076e63c44 100644 --- a/libmamba/include/mamba/core/exclude_newer.hpp +++ b/libmamba/include/mamba/core/exclude_newer.hpp @@ -8,11 +8,76 @@ #define MAMBA_CORE_EXCLUDE_NEWER_HPP #include +#include +#include #include +#include #include +#include namespace mamba { + namespace detail + { + struct ExcludeNewerPackageHash + { + using is_transparent = void; + + [[nodiscard]] auto operator()(std::string_view value) const noexcept -> std::size_t + { + return std::hash{}(value); + } + }; + + struct ExcludeNewerPackageEqual + { + using is_transparent = void; + + [[nodiscard]] auto operator()(std::string_view lhs, std::string_view rhs) const noexcept + -> bool + { + return lhs == rhs; + } + }; + } // namespace detail + + /** + * Resolved per-package ``exclude_newer`` cutoffs. + * + * When a package name is present: + * - ``std::nullopt`` exempts the package from the global policy (``false`` in config) + * - a timestamp value applies a package-specific cutoff + * + * Packages not listed fall back to the global cutoff. + */ + using ExcludeNewerPackageCutoffs = std::unordered_map< + std::string, + std::optional, + detail::ExcludeNewerPackageHash, + detail::ExcludeNewerPackageEqual>; + + struct ExcludeNewerCutoffPolicy + { + std::optional global = std::nullopt; + ExcludeNewerPackageCutoffs per_package = {}; + + [[nodiscard]] auto cutoff_for(std::string_view package_name) const + -> std::optional; + + [[nodiscard]] auto + excludes(std::string_view package_name, std::uint64_t pkg_timestamp) const -> bool; + }; + + /** + * Resolve raw per-package ``exclude_newer`` configuration values. + * + * @throws std::invalid_argument when a non-``false`` value cannot be parsed. + */ + [[nodiscard]] auto resolve_exclude_newer_package_cutoffs( + const std::map& exclude_newer_package, + std::uint64_t now_seconds + ) -> ExcludeNewerPackageCutoffs; + /** * Resolve a global ``exclude_newer`` configuration value to an absolute Unix * timestamp cutoff in seconds. diff --git a/libmamba/include/mamba/solver/libsolv/database.hpp b/libmamba/include/mamba/solver/libsolv/database.hpp index aaf7a594d2..581106b73c 100644 --- a/libmamba/include/mamba/solver/libsolv/database.hpp +++ b/libmamba/include/mamba/solver/libsolv/database.hpp @@ -18,6 +18,7 @@ #include #include "mamba/core/error_handling.hpp" +#include "mamba/core/exclude_newer.hpp" #include "mamba/solver/libsolv/parameters.hpp" #include "mamba/solver/libsolv/repo_info.hpp" #include "mamba/specs/channel.hpp" @@ -72,14 +73,16 @@ namespace mamba::solver::libsolv * For repodata JSON, `indexed_timestamp` is preferred over `timestamp` when both * are present, matching conda's `--exclude-newer` semantics. * - * This is a global cutoff for the whole `Database`. Callers that need - * channel- or package-specific policies (e.g. conda with overrides) should apply - * filtering before handing records to libmamba. + * Per-package overrides take precedence over the global cutoff. A package mapped + * to ``std::nullopt`` is exempt from the global policy (``false`` in config). * - * The `.solv` cache path is not filtered; invalidate the cache when this value - * changes. + * The `.solv` cache path is not filtered; invalidate the cache when these values + * change. */ std::optional exclude_newer_timestamp = std::nullopt; + ExcludeNewerPackageCutoffs exclude_newer_package = {}; + + [[nodiscard]] auto exclude_newer_policy() const -> ExcludeNewerCutoffPolicy; }; using logger_type = std::function; diff --git a/libmamba/src/api/configuration.cpp b/libmamba/src/api/configuration.cpp index acb1a6ffd3..c57c7f0ca2 100644 --- a/libmamba/src/api/configuration.cpp +++ b/libmamba/src/api/configuration.cpp @@ -1739,8 +1739,8 @@ namespace mamba .description("Per-package overrides for the exclude_newer policy") .long_description(unindent(R"( Maps package names to durations, dates, timestamps, or false to exempt - a package from the global exclude_newer policy. Only the global policy - is applied natively by the libmamba solver backend today.)"))); + a package from the global exclude_newer policy. Package-specific values + take precedence over the global cutoff.)"))); insert(Configurable("order_solver_request", &m_context.solver_flags.order_request) .group("Solver") diff --git a/libmamba/src/api/install.cpp b/libmamba/src/api/install.cpp index 4ce36cacce..7fe35180b3 100644 --- a/libmamba/src/api/install.cpp +++ b/libmamba/src/api/install.cpp @@ -743,7 +743,8 @@ namespace mamba auto database = make_solver_database( channel_context, ctx.experimental_matchspec_parsing, - ctx.exclude_newer + ctx.exclude_newer, + ctx.exclude_newer_package ); init_channels(ctx, channel_context); @@ -1239,7 +1240,8 @@ namespace mamba auto db = make_solver_database( channel_context, ctx.experimental_matchspec_parsing, - ctx.exclude_newer + ctx.exclude_newer, + ctx.exclude_newer_package ); add_logger_to_database(db); diff --git a/libmamba/src/api/remove.cpp b/libmamba/src/api/remove.cpp index 4b896337e9..1d5bf34e79 100644 --- a/libmamba/src/api/remove.cpp +++ b/libmamba/src/api/remove.cpp @@ -129,7 +129,8 @@ namespace mamba auto database = make_solver_database( channel_context, ctx.experimental_matchspec_parsing, - ctx.exclude_newer + ctx.exclude_newer, + ctx.exclude_newer_package ); auto prefix_data = load_prefix_data_and_installed(ctx, channel_context, database); diff --git a/libmamba/src/api/repoquery.cpp b/libmamba/src/api/repoquery.cpp index 5a52eb7b89..a8a3ef4821 100644 --- a/libmamba/src/api/repoquery.cpp +++ b/libmamba/src/api/repoquery.cpp @@ -60,7 +60,8 @@ namespace mamba auto db = make_solver_database( channel_context, ctx.experimental_matchspec_parsing, - ctx.exclude_newer + ctx.exclude_newer, + ctx.exclude_newer_package ); add_logger_to_database(db); diff --git a/libmamba/src/api/utils.cpp b/libmamba/src/api/utils.cpp index 688e0f56ab..28f88e7614 100644 --- a/libmamba/src/api/utils.cpp +++ b/libmamba/src/api/utils.cpp @@ -522,25 +522,19 @@ namespace mamba namespace { - [[nodiscard]] auto resolve_exclude_newer_timestamp(const std::string& exclude_newer) - -> std::optional + [[nodiscard]] auto make_database_settings( + bool experimental_matchspec_parsing, + const std::string& exclude_newer, + const std::map& exclude_newer_package + ) -> solver::libsolv::Database::Settings { - if (exclude_newer.empty()) - { - return std::nullopt; - } const auto now = static_cast(std::time(nullptr)); - return resolve_exclude_newer_cutoff(exclude_newer, now); - } - - [[nodiscard]] auto - make_database_settings(bool experimental_matchspec_parsing, const std::string& exclude_newer) - -> solver::libsolv::Database::Settings - { return { experimental_matchspec_parsing ? solver::libsolv::MatchSpecParser::Mamba : solver::libsolv::MatchSpecParser::Libsolv, - resolve_exclude_newer_timestamp(exclude_newer), + exclude_newer.empty() ? std::nullopt + : resolve_exclude_newer_cutoff(exclude_newer, now), + resolve_exclude_newer_package_cutoffs(exclude_newer_package, now), }; } } // namespace @@ -548,12 +542,13 @@ namespace mamba solver::libsolv::Database make_solver_database( ChannelContext& channel_context, bool experimental_matchspec_parsing, - const std::string& exclude_newer + const std::string& exclude_newer, + const std::map& exclude_newer_package ) { solver::libsolv::Database db{ channel_context.params(), - make_database_settings(experimental_matchspec_parsing, exclude_newer), + make_database_settings(experimental_matchspec_parsing, exclude_newer, exclude_newer_package), }; add_logger_to_database(db); return db; @@ -603,12 +598,8 @@ namespace mamba { populate_context_channels_from_specs(raw_specs, ctx); - if (!ctx.exclude_newer_package.empty()) - { - LOG_WARNING << "exclude_newer_package is configured but only the global exclude_newer " - "policy is applied natively by the libmamba solver backend"; - } - if (!ctx.exclude_newer.empty() && !ctx.mamba_repodata_parsing) + if ((!ctx.exclude_newer.empty() || !ctx.exclude_newer_package.empty()) + && !ctx.mamba_repodata_parsing) { LOG_WARNING << "exclude_newer requires the Mamba repodata parser; packages loaded from " "the libsolv parser will not be filtered"; @@ -617,7 +608,8 @@ namespace mamba auto db = make_solver_database( channel_context, ctx.experimental_matchspec_parsing, - ctx.exclude_newer + ctx.exclude_newer, + ctx.exclude_newer_package ); MultiPackageCache package_caches(ctx.pkgs_dirs, ctx.validation_params); diff --git a/libmamba/src/api/utils.hpp b/libmamba/src/api/utils.hpp index 151a938cb7..4fa4dd3466 100644 --- a/libmamba/src/api/utils.hpp +++ b/libmamba/src/api/utils.hpp @@ -8,6 +8,7 @@ #define MAMBA_UTILS_HPP #include +#include #include #include #include @@ -126,7 +127,8 @@ namespace mamba solver::libsolv::Database make_solver_database( ChannelContext& channel_context, bool experimental_matchspec_parsing, - const std::string& exclude_newer + const std::string& exclude_newer, + const std::map& exclude_newer_package ); /** diff --git a/libmamba/src/core/exclude_newer.cpp b/libmamba/src/core/exclude_newer.cpp index e5caddbbd1..da2bad24bf 100644 --- a/libmamba/src/core/exclude_newer.cpp +++ b/libmamba/src/core/exclude_newer.cpp @@ -248,4 +248,50 @@ namespace mamba return resolve_exclude_newer_cutoff_impl(value, now_seconds); } + auto ExcludeNewerCutoffPolicy::cutoff_for(std::string_view package_name) const + -> std::optional + { + if (const auto it = per_package.find(package_name); it != per_package.end()) + { + return it->second; + } + return global; + } + + auto + ExcludeNewerCutoffPolicy::excludes(std::string_view package_name, std::uint64_t pkg_timestamp) const + -> bool + { + if (const auto cutoff = cutoff_for(package_name)) + { + return pkg_timestamp > *cutoff; + } + return false; + } + + auto resolve_exclude_newer_package_cutoffs( + const std::map& exclude_newer_package, + std::uint64_t now_seconds + ) -> ExcludeNewerPackageCutoffs + { + auto out = ExcludeNewerPackageCutoffs{}; + for (const auto& [name, value] : exclude_newer_package) + { + const auto trimmed = trim(value); + if (trimmed.size() == 5 && (trimmed[0] == 'f' || trimmed[0] == 'F') + && (trimmed[1] == 'a' || trimmed[1] == 'A') + && (trimmed[2] == 'l' || trimmed[2] == 'L') + && (trimmed[3] == 's' || trimmed[3] == 'S') + && (trimmed[4] == 'e' || trimmed[4] == 'E')) + { + out.emplace(name, std::nullopt); + } + else + { + out.emplace(name, resolve_exclude_newer_cutoff(trimmed, now_seconds)); + } + } + return out; + } + } // namespace mamba diff --git a/libmamba/src/core/package_database_loader.cpp b/libmamba/src/core/package_database_loader.cpp index 1468bae87c..cedc59cc21 100644 --- a/libmamba/src/core/package_database_loader.cpp +++ b/libmamba/src/core/package_database_loader.cpp @@ -90,7 +90,7 @@ namespace mamba : solver::libsolv::RepodataParser::Libsolv; // Solv files are too slow on Windows. They also bypass exclude_newer filtering. - if (!util::on_win && ctx.exclude_newer.empty()) + if (!util::on_win && ctx.exclude_newer.empty() && ctx.exclude_newer_package.empty()) { auto maybe_repo = subdir.valid_libsolv_cache_path().and_then( [&](fs::u8path&& solv_file) diff --git a/libmamba/src/solver/libsolv/database.cpp b/libmamba/src/solver/libsolv/database.cpp index acb8bb1011..2a95c048bf 100644 --- a/libmamba/src/solver/libsolv/database.cpp +++ b/libmamba/src/solver/libsolv/database.cpp @@ -99,6 +99,11 @@ namespace mamba::solver::libsolv return m_data->settings; } + auto Database::Settings::exclude_newer_policy() const -> ExcludeNewerCutoffPolicy + { + return { exclude_newer_timestamp, exclude_newer_package }; + } + namespace { auto libsolv_to_log_level(int type) -> LogLevel @@ -184,7 +189,7 @@ namespace mamba::solver::libsolv package_types, settings().matchspec_parser, verify_artifacts, - settings().exclude_newer_timestamp + settings().exclude_newer_policy() ); } @@ -262,12 +267,12 @@ namespace mamba::solver::libsolv void Database::add_repo_from_packages_impl_loop(const RepoInfo& repo, const specs::PackageInfo& pkg) { - if (const auto cutoff = settings().exclude_newer_timestamp) + if (settings().exclude_newer_policy().excludes( + pkg.name, + normalize_conda_timestamp(pkg.timestamp) + )) { - if (normalize_conda_timestamp(pkg.timestamp) > *cutoff) - { - return; - } + return; } auto s_repo = solv::ObjRepoView(*repo.m_ptr); auto [id, solv] = s_repo.add_solvable(); diff --git a/libmamba/src/solver/libsolv/helpers.cpp b/libmamba/src/solver/libsolv/helpers.cpp index f5b16d94e3..fec3eb3869 100644 --- a/libmamba/src/solver/libsolv/helpers.cpp +++ b/libmamba/src/solver/libsolv/helpers.cpp @@ -453,7 +453,7 @@ namespace mamba::solver::libsolv Filter&& filter, OnParsed&& on_parsed, MatchSpecParser parser, - std::optional exclude_newer_timestamp = std::nullopt + ExcludeNewerCutoffPolicy exclude_newer_policy = {} ) { auto packages_as_object = packages.get_object(); @@ -478,7 +478,7 @@ namespace mamba::solver::libsolv ); if (parsed) { - if (exclude_newer_timestamp && pkg_timestamp > *exclude_newer_timestamp) + if (exclude_newer_policy.excludes(solv.name(), pkg_timestamp)) { repo.remove_solvable(id, /* reuse_id= */ true); } @@ -506,7 +506,7 @@ namespace mamba::solver::libsolv JSONObject& packages, const std::optional& signatures, MatchSpecParser parser, - std::optional exclude_newer_timestamp = std::nullopt + ExcludeNewerCutoffPolicy exclude_newer_policy = {} ) { return set_repo_solvables_impl( @@ -520,7 +520,7 @@ namespace mamba::solver::libsolv /* filter= */ [](const auto&) { return true; }, /* on_parsed= */ [](const auto&) {}, parser, - exclude_newer_timestamp + exclude_newer_policy ); } @@ -534,7 +534,7 @@ namespace mamba::solver::libsolv JSONObject& packages, const std::optional& signatures, MatchSpecParser parser, - std::optional exclude_newer_timestamp = std::nullopt + ExcludeNewerCutoffPolicy exclude_newer_policy = {} ) -> util::flat_set { auto filenames = util::flat_set(); @@ -551,7 +551,7 @@ namespace mamba::solver::libsolv [&](const auto& fn) { filenames.insert(std::string(specs::strip_archive_extension(fn))); }, parser, - exclude_newer_timestamp + exclude_newer_policy ); // Sort only once return filenames; @@ -568,7 +568,7 @@ namespace mamba::solver::libsolv const std::optional& signatures, const SortedStringRange& added, MatchSpecParser parser, - std::optional exclude_newer_timestamp = std::nullopt + ExcludeNewerCutoffPolicy exclude_newer_policy = {} ) { return set_repo_solvables_impl( @@ -583,7 +583,7 @@ namespace mamba::solver::libsolv [&](const auto& fn) { return !added.contains(specs::strip_archive_extension(fn)); }, /* on_parsed= */ [&](const auto&) {}, parser, - exclude_newer_timestamp + exclude_newer_policy ); } } @@ -649,7 +649,7 @@ namespace mamba::solver::libsolv PackageTypes package_types, MatchSpecParser ms_parser, bool verify_artifacts, - std::optional exclude_newer_timestamp + ExcludeNewerCutoffPolicy exclude_newer_policy ) -> expected_t { LOG_INFO << "Reading repodata.json file " << filename << " for repo " << repo.name() @@ -766,7 +766,7 @@ namespace mamba::solver::libsolv pkgs, json_signatures, ms_parser, - exclude_newer_timestamp + exclude_newer_policy ); } if (auto pkgs = repodata_doc["packages"]; !pkgs.error()) @@ -781,7 +781,7 @@ namespace mamba::solver::libsolv json_signatures, added, ms_parser, - exclude_newer_timestamp + exclude_newer_policy ); } } @@ -799,7 +799,7 @@ namespace mamba::solver::libsolv pkgs, json_signatures, ms_parser, - exclude_newer_timestamp + exclude_newer_policy ); } @@ -815,7 +815,7 @@ namespace mamba::solver::libsolv pkgs, json_signatures, ms_parser, - exclude_newer_timestamp + exclude_newer_policy ); } } diff --git a/libmamba/src/solver/libsolv/helpers.hpp b/libmamba/src/solver/libsolv/helpers.hpp index 459236e096..fe9009edb6 100644 --- a/libmamba/src/solver/libsolv/helpers.hpp +++ b/libmamba/src/solver/libsolv/helpers.hpp @@ -13,6 +13,7 @@ #include #include "mamba/core/error_handling.hpp" +#include "mamba/core/exclude_newer.hpp" #include "mamba/solver/libsolv/parameters.hpp" #include "mamba/solver/request.hpp" #include "mamba/solver/solution.hpp" @@ -72,7 +73,7 @@ namespace mamba::solver::libsolv PackageTypes types, MatchSpecParser parser, bool verify_artifacts, - std::optional exclude_newer_timestamp = std::nullopt + ExcludeNewerCutoffPolicy exclude_newer_policy = {} ) -> expected_t; [[nodiscard]] auto read_solv( diff --git a/libmamba/tests/src/core/test_exclude_newer.cpp b/libmamba/tests/src/core/test_exclude_newer.cpp index 2fa716aaac..7dcaefcb5b 100644 --- a/libmamba/tests/src/core/test_exclude_newer.cpp +++ b/libmamba/tests/src/core/test_exclude_newer.cpp @@ -49,4 +49,55 @@ namespace REQUIRE_THROWS_AS(resolve_exclude_newer_cutoff("P", now), std::invalid_argument); } } + + TEST_CASE("resolve_exclude_newer_package_cutoffs") + { + constexpr std::uint64_t now = 1'700'000'000; + + SECTION("false exempts a package") + { + const auto cutoffs = resolve_exclude_newer_package_cutoffs({ { "numpy", "false" } }, now); + REQUIRE(cutoffs.at("numpy") == std::nullopt); + } + + SECTION("package-specific durations override global semantics at resolve time") + { + const auto cutoffs = resolve_exclude_newer_package_cutoffs({ { "pandas", "7d" } }, now); + REQUIRE(cutoffs.at("pandas") == now - 7 * 86400); + } + } + + TEST_CASE("ExcludeNewerCutoffPolicy") + { + constexpr std::uint64_t global_cutoff = 2000; + + const ExcludeNewerCutoffPolicy policy{ + /* .global= */ global_cutoff, + /* .per_package= */ + { + { "exempt-pkg", std::nullopt }, + { "custom-pkg", 1500 }, + }, + }; + + SECTION("unknown packages use the global cutoff") + { + REQUIRE(policy.cutoff_for("other-pkg") == global_cutoff); + REQUIRE(policy.excludes("other-pkg", 2500)); + REQUIRE_FALSE(policy.excludes("other-pkg", 1500)); + } + + SECTION("exempt packages ignore the global cutoff") + { + REQUIRE(policy.cutoff_for("exempt-pkg") == std::nullopt); + REQUIRE_FALSE(policy.excludes("exempt-pkg", 999999)); + } + + SECTION("custom packages use their own cutoff") + { + REQUIRE(policy.cutoff_for("custom-pkg") == 1500); + REQUIRE(policy.excludes("custom-pkg", 2000)); + REQUIRE_FALSE(policy.excludes("custom-pkg", 1000)); + } + } } // namespace diff --git a/libmamba/tests/src/solver/libsolv/test_database.cpp b/libmamba/tests/src/solver/libsolv/test_database.cpp index 0a52d2a7dc..274645635c 100644 --- a/libmamba/tests/src/solver/libsolv/test_database.cpp +++ b/libmamba/tests/src/solver/libsolv/test_database.cpp @@ -11,6 +11,7 @@ #include +#include "mamba/core/exclude_newer.hpp" #include "mamba/core/util.hpp" #include "mamba/solver/libsolv/database.hpp" #include "mamba/specs/match_spec.hpp" @@ -294,6 +295,62 @@ namespace REQUIRE(unfiltered_repo->package_count() > repo1->package_count()); } + SECTION("exclude_newer_package overrides the global cutoff") + { + auto tmp_dir = TemporaryDirectory(); + const auto repodata = tmp_dir.path() / "repodata.json"; + std::ofstream out_file(repodata.std_path()); + out_file << R"({ + "packages": { + "exempt-pkg-1.0-bld.tar.bz2": { + "name": "exempt-pkg", + "version": "1.0", + "build": "bld", + "build_number": 0, + "subdir": "linux-64", + "depends": [], + "timestamp": 3000 + }, + "filtered-pkg-1.0-bld.tar.bz2": { + "name": "filtered-pkg", + "version": "1.0", + "build": "bld", + "build_number": 0, + "subdir": "linux-64", + "depends": [], + "timestamp": 3000 + } + }, + "packages.conda": {} + })"; + out_file.close(); + + auto db_filtered = libsolv::Database( + {}, + { + matchspec_parser, + /* exclude_newer_timestamp= */ std::uint64_t(2000), + /* exclude_newer_package= */ + ExcludeNewerPackageCutoffs{ + { "exempt-pkg", std::nullopt }, + }, + } + ); + auto repo1 = db_filtered.add_repo_from_repodata_json( + repodata, + "https://conda.anaconda.org/conda-forge/linux-64", + "conda-forge", + libsolv::PipAsPythonDependency::No + ); + REQUIRE(repo1.has_value()); + REQUIRE(repo1->package_count() == 1); + + db_filtered.for_each_package_in_repo( + *repo1, + [](const auto& p) { REQUIRE(p.name == "exempt-pkg"); } + ); + } + SECTION("exclude_newer_timestamp prefers indexed_timestamp from repodata JSON") { auto tmp_dir = TemporaryDirectory(); From 59fff4d327cea3beec3a528cd0bf38d7f6a75056 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Wed, 10 Jun 2026 17:26:53 +0200 Subject: [PATCH 07/41] Use custom regex match for string_view Signed-off-by: Julien Jerphanion --- libmamba/src/core/exclude_newer.cpp | 228 ++++++++++++++++++++-------- 1 file changed, 163 insertions(+), 65 deletions(-) diff --git a/libmamba/src/core/exclude_newer.cpp b/libmamba/src/core/exclude_newer.cpp index da2bad24bf..4f922de1c5 100644 --- a/libmamba/src/core/exclude_newer.cpp +++ b/libmamba/src/core/exclude_newer.cpp @@ -8,7 +8,6 @@ #include #include #include -#include #include #include @@ -39,6 +38,33 @@ namespace mamba ); } + [[nodiscard]] auto parse_uint_prefix(std::string_view& value) -> std::optional + { + if (value.empty() || !std::isdigit(static_cast(value.front()))) + { + return std::nullopt; + } + std::uint64_t number = 0; + const auto [ptr, ec] = std::from_chars(value.data(), value.data() + value.size(), number); + if (ec != std::errc()) + { + return std::nullopt; + } + value.remove_prefix(static_cast(ptr - value.data())); + return number; + } + + [[nodiscard]] auto parse_fixed_uint(std::string_view value) -> std::optional + { + int number = 0; + const auto [ptr, ec] = std::from_chars(value.data(), value.data() + value.size(), number); + if (ec != std::errc() || ptr != value.data() + value.size()) + { + return std::nullopt; + } + return number; + } + [[nodiscard]] auto parse_plain_seconds(std::string_view value) -> std::optional { std::uint64_t seconds = 0; @@ -52,71 +78,110 @@ namespace mamba return seconds; } + [[nodiscard]] auto parse_compact_duration_seconds(std::string_view value) + -> std::optional + { + auto remaining = value; + const auto amount = parse_uint_prefix(remaining); + if (!amount) + { + return std::nullopt; + } + while (!remaining.empty() && std::isspace(static_cast(remaining.front()))) + { + remaining.remove_prefix(1); + } + if (remaining.size() != 1) + { + return std::nullopt; + } + switch (std::tolower(static_cast(remaining.front()))) + { + case 'w': + return *amount * 604800; + case 'd': + return *amount * 86400; + case 'h': + return *amount * 3600; + case 'm': + return *amount * 60; + case 's': + return *amount; + default: + return std::nullopt; + } + } + [[nodiscard]] auto parse_iso8601_duration_seconds(std::string_view value) -> std::optional { - static const std::regex iso_duration_re( - R"re(^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$)re", - std::regex::icase - ); - std::cmatch match; - if (!std::regex_match(value.begin(), value.end(), match, iso_duration_re)) + if (value.empty() || std::tolower(static_cast(value.front())) != 'p') { return std::nullopt; } - bool has_component = false; + auto remaining = value.substr(1); std::uint64_t total = 0; - const auto add = [&](const std::csub_match& group, std::uint64_t unit) + bool has_component = false; + + const auto consume_if_unit = [&](char unit, std::uint64_t multiplier) -> bool { - if (!group.matched) + std::size_t digits = 0; + while (digits < remaining.size() + && std::isdigit(static_cast(remaining[digits]))) + { + ++digits; + } + if (digits == 0) + { + return true; + } + if (remaining.size() < digits + 1) + { + return false; + } + if (std::tolower(static_cast(remaining[digits])) + != std::tolower(static_cast(unit))) { - return; + return true; } + + const auto amount = parse_fixed_uint(remaining.substr(0, digits)); + if (!amount) + { + return false; + } + remaining.remove_prefix(digits + 1); has_component = true; - total += static_cast(std::stoull(std::string(group))) * unit; + total += static_cast(*amount) * multiplier; + return true; }; - add(match[1], 604800); - add(match[2], 86400); - add(match[3], 3600); - add(match[4], 60); - add(match[5], 1); + if (!consume_if_unit('W', 604800) || !consume_if_unit('D', 86400)) + { + return std::nullopt; + } - if (!has_component) + if (!remaining.empty() + && std::tolower(static_cast(remaining.front())) == 't') { - throw invalid_exclude_newer(value); + remaining.remove_prefix(1); + if (!consume_if_unit('H', 3600) || !consume_if_unit('M', 60) + || !consume_if_unit('S', 1)) + { + return std::nullopt; + } } - return total; - } - [[nodiscard]] auto parse_compact_duration_seconds(std::string_view value) - -> std::optional - { - static const std::regex compact_duration_re(R"re(^(\d+)\s*([wdhms])$)re", std::regex::icase); - std::cmatch match; - if (!std::regex_match(value.begin(), value.end(), match, compact_duration_re)) + if (!remaining.empty()) { return std::nullopt; } - - const auto amount = static_cast(std::stoull(std::string(match[1]))); - const auto unit = std::tolower(static_cast(match[2].first[0])); - switch (unit) + if (!has_component) { - case 'w': - return amount * 604800; - case 'd': - return amount * 86400; - case 'h': - return amount * 3600; - case 'm': - return amount * 60; - case 's': - return amount; - default: - return std::nullopt; + throw invalid_exclude_newer(value); } + return total; } [[nodiscard]] auto parse_duration_seconds(std::string_view value) @@ -145,17 +210,23 @@ namespace mamba [[nodiscard]] auto parse_date_only_next_utc_day(std::string_view value) -> std::optional { - static const std::regex date_only_re(R"re(^(\d{4})-(\d{2})-(\d{2})$)re"); - std::cmatch match; - if (!std::regex_match(value.begin(), value.end(), match, date_only_re)) + if (value.size() != 10 || value[4] != '-' || value[7] != '-') + { + return std::nullopt; + } + + const auto year = parse_fixed_uint(value.substr(0, 4)); + const auto month = parse_fixed_uint(value.substr(5, 2)); + const auto day = parse_fixed_uint(value.substr(8, 2)); + if (!year || !month || !day) { return std::nullopt; } std::tm tm = {}; - tm.tm_year = std::stoi(std::string(match[1])) - 1900; - tm.tm_mon = std::stoi(std::string(match[2])) - 1; - tm.tm_mday = std::stoi(std::string(match[3])) + 1; + tm.tm_year = *year - 1900; + tm.tm_mon = *month - 1; + tm.tm_mday = *day + 1; tm.tm_hour = 0; tm.tm_min = 0; tm.tm_sec = 0; @@ -166,34 +237,61 @@ namespace mamba [[nodiscard]] auto parse_datetime_timestamp(std::string_view value) -> std::optional { - static const std::regex datetime_re( - R"re(^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(Z|([+-])(\d{2}):(\d{2}))?$)re" - ); - std::cmatch match; - if (!std::regex_match(value.begin(), value.end(), match, datetime_re)) + if (value.size() < 19 || value[4] != '-' || value[7] != '-' || value[10] != 'T' + || value[13] != ':' || value[16] != ':') + { + return std::nullopt; + } + + const auto year = parse_fixed_uint(value.substr(0, 4)); + const auto month = parse_fixed_uint(value.substr(5, 2)); + const auto day = parse_fixed_uint(value.substr(8, 2)); + const auto hour = parse_fixed_uint(value.substr(11, 2)); + const auto minute = parse_fixed_uint(value.substr(14, 2)); + const auto second = parse_fixed_uint(value.substr(17, 2)); + if (!year || !month || !day || !hour || !minute || !second) { return std::nullopt; } std::tm tm = {}; - tm.tm_year = std::stoi(std::string(match[1])) - 1900; - tm.tm_mon = std::stoi(std::string(match[2])) - 1; - tm.tm_mday = std::stoi(std::string(match[3])); - tm.tm_hour = std::stoi(std::string(match[4])); - tm.tm_min = std::stoi(std::string(match[5])); - tm.tm_sec = std::stoi(std::string(match[6])); + tm.tm_year = *year - 1900; + tm.tm_mon = *month - 1; + tm.tm_mday = *day; + tm.tm_hour = *hour; + tm.tm_min = *minute; + tm.tm_sec = *second; tm.tm_isdst = 0; auto timestamp = timegm_utc(tm); + auto suffix = value.substr(19); - if (match[7].matched && match[7].str() != "Z") + if (suffix.empty()) { - const auto sign = match[8].str() == "+" ? 1 : -1; - const auto offset_hours = std::stoi(std::string(match[9])); - const auto offset_minutes = std::stoi(std::string(match[10])); - const auto offset_seconds = sign * (offset_hours * 3600 + offset_minutes * 60); + // Naive datetimes are interpreted as UTC. + } + else if (suffix.size() == 1 + && std::tolower(static_cast(suffix.front())) == 'z') + { + // Explicit UTC. + } + else if (suffix.size() == 6 && (suffix.front() == '+' || suffix.front() == '-') + && suffix[3] == ':') + { + const auto offset_hours = parse_fixed_uint(suffix.substr(1, 2)); + const auto offset_minutes = parse_fixed_uint(suffix.substr(4, 2)); + if (!offset_hours || !offset_minutes) + { + return std::nullopt; + } + const auto sign = suffix.front() == '+' ? 1 : -1; + const auto offset_seconds = sign * (*offset_hours * 3600 + *offset_minutes * 60); timestamp -= offset_seconds; } + else + { + return std::nullopt; + } if (timestamp < 0) { From f614ddc9473bf2d5b28b470fc5cc987788a837c1 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 16 Jun 2026 11:13:41 +0200 Subject: [PATCH 08/41] Rename to and use `ExcludeNewerPolicy` Signed-off-by: Julien Jerphanion Co-authored-by: Hind Montassif --- libmamba/include/mamba/core/context.hpp | 4 ++-- libmamba/include/mamba/core/exclude_newer.hpp | 23 ++++++++++++++++++- .../include/mamba/solver/libsolv/database.hpp | 2 +- libmamba/src/api/configuration.cpp | 4 ++-- libmamba/src/api/install.cpp | 6 ++--- libmamba/src/api/remove.cpp | 3 +-- libmamba/src/api/repoquery.cpp | 3 +-- libmamba/src/api/utils.cpp | 21 ++++++++--------- libmamba/src/api/utils.hpp | 4 ++-- libmamba/src/core/exclude_newer.cpp | 5 ++-- libmamba/src/core/package_database_loader.cpp | 2 +- libmamba/src/solver/libsolv/database.cpp | 9 ++++++-- libmamba/src/solver/libsolv/helpers.cpp | 10 ++++---- libmamba/src/solver/libsolv/helpers.hpp | 2 +- .../tests/src/core/test_exclude_newer.cpp | 6 +++-- 15 files changed, 62 insertions(+), 42 deletions(-) diff --git a/libmamba/include/mamba/core/context.hpp b/libmamba/include/mamba/core/context.hpp index c289fb6a1a..2dea3ccbfd 100644 --- a/libmamba/include/mamba/core/context.hpp +++ b/libmamba/include/mamba/core/context.hpp @@ -13,6 +13,7 @@ #include #include "mamba/core/context_params.hpp" +#include "mamba/core/exclude_newer.hpp" #include "mamba/core/logging.hpp" #include "mamba/core/palette.hpp" #include "mamba/core/subdir_parameters.hpp" @@ -130,8 +131,7 @@ namespace mamba // solver options solver::Request::Flags solver_flags = {}; - std::string exclude_newer; - std::map exclude_newer_package; + ExcludeNewerPolicy exclude_newer_policy; // add start menu shortcuts on Windows (not implemented on Linux / macOS) bool shortcuts = true; diff --git a/libmamba/include/mamba/core/exclude_newer.hpp b/libmamba/include/mamba/core/exclude_newer.hpp index 7076e63c44..f0aa3385e0 100644 --- a/libmamba/include/mamba/core/exclude_newer.hpp +++ b/libmamba/include/mamba/core/exclude_newer.hpp @@ -56,11 +56,32 @@ namespace mamba detail::ExcludeNewerPackageHash, detail::ExcludeNewerPackageEqual>; - struct ExcludeNewerCutoffPolicy + struct ExcludeNewerPolicy { + /** + * Raw ``exclude_newer`` configuration values from CLI or configuration file. + * + * Background and cross-ecosystem tracking: + * https://github.com/conda/conda/issues/15759 + */ + std::string exclude_newer; + std::map exclude_newer_package; + + /** + * Resolved global cutoff timestamp in seconds. + */ std::optional global = std::nullopt; + + /** + * Resolved per-package timestamp cutoffs. + */ ExcludeNewerPackageCutoffs per_package = {}; + [[nodiscard]] auto empty() const -> bool + { + return exclude_newer.empty() && exclude_newer_package.empty(); + } + [[nodiscard]] auto cutoff_for(std::string_view package_name) const -> std::optional; diff --git a/libmamba/include/mamba/solver/libsolv/database.hpp b/libmamba/include/mamba/solver/libsolv/database.hpp index 581106b73c..3556843bc5 100644 --- a/libmamba/include/mamba/solver/libsolv/database.hpp +++ b/libmamba/include/mamba/solver/libsolv/database.hpp @@ -82,7 +82,7 @@ namespace mamba::solver::libsolv std::optional exclude_newer_timestamp = std::nullopt; ExcludeNewerPackageCutoffs exclude_newer_package = {}; - [[nodiscard]] auto exclude_newer_policy() const -> ExcludeNewerCutoffPolicy; + [[nodiscard]] auto exclude_newer_policy() const -> ExcludeNewerPolicy; }; using logger_type = std::function; diff --git a/libmamba/src/api/configuration.cpp b/libmamba/src/api/configuration.cpp index c57c7f0ca2..f3b8daaf96 100644 --- a/libmamba/src/api/configuration.cpp +++ b/libmamba/src/api/configuration.cpp @@ -1719,7 +1719,7 @@ namespace mamba .set_env_var_names() .description("Allow downgrade when installing packages. Default is false.")); - insert(Configurable("exclude_newer", &m_context.exclude_newer) + insert(Configurable("exclude_newer", &m_context.exclude_newer_policy.exclude_newer) .group("Solver") .set_rc_configurable() .set_env_var_names({ "CONDA_EXCLUDE_NEWER", "MAMBA_EXCLUDE_NEWER" }) @@ -1732,7 +1732,7 @@ namespace mamba treated as durations in seconds. Supply 0 for no delay, using the current time as the cutoff.)"))); - insert(Configurable("exclude_newer_package", &m_context.exclude_newer_package) + insert(Configurable("exclude_newer_package", &m_context.exclude_newer_policy.exclude_newer_package) .group("Solver") .set_rc_configurable() .set_env_var_names({ "CONDA_EXCLUDE_NEWER_PACKAGE", "MAMBA_EXCLUDE_NEWER_PACKAGE" }) diff --git a/libmamba/src/api/install.cpp b/libmamba/src/api/install.cpp index 7fe35180b3..9f54d3f437 100644 --- a/libmamba/src/api/install.cpp +++ b/libmamba/src/api/install.cpp @@ -743,8 +743,7 @@ namespace mamba auto database = make_solver_database( channel_context, ctx.experimental_matchspec_parsing, - ctx.exclude_newer, - ctx.exclude_newer_package + ctx.exclude_newer_policy ); init_channels(ctx, channel_context); @@ -1240,8 +1239,7 @@ namespace mamba auto db = make_solver_database( channel_context, ctx.experimental_matchspec_parsing, - ctx.exclude_newer, - ctx.exclude_newer_package + ctx.exclude_newer_policy ); add_logger_to_database(db); diff --git a/libmamba/src/api/remove.cpp b/libmamba/src/api/remove.cpp index 1d5bf34e79..fa23bbdcf9 100644 --- a/libmamba/src/api/remove.cpp +++ b/libmamba/src/api/remove.cpp @@ -129,8 +129,7 @@ namespace mamba auto database = make_solver_database( channel_context, ctx.experimental_matchspec_parsing, - ctx.exclude_newer, - ctx.exclude_newer_package + ctx.exclude_newer_policy ); auto prefix_data = load_prefix_data_and_installed(ctx, channel_context, database); diff --git a/libmamba/src/api/repoquery.cpp b/libmamba/src/api/repoquery.cpp index a8a3ef4821..fa184e484c 100644 --- a/libmamba/src/api/repoquery.cpp +++ b/libmamba/src/api/repoquery.cpp @@ -60,8 +60,7 @@ namespace mamba auto db = make_solver_database( channel_context, ctx.experimental_matchspec_parsing, - ctx.exclude_newer, - ctx.exclude_newer_package + ctx.exclude_newer_policy ); add_logger_to_database(db); diff --git a/libmamba/src/api/utils.cpp b/libmamba/src/api/utils.cpp index 28f88e7614..80e74f3663 100644 --- a/libmamba/src/api/utils.cpp +++ b/libmamba/src/api/utils.cpp @@ -524,17 +524,17 @@ namespace mamba { [[nodiscard]] auto make_database_settings( bool experimental_matchspec_parsing, - const std::string& exclude_newer, - const std::map& exclude_newer_package + const ExcludeNewerPolicy& exclude_newer_policy ) -> solver::libsolv::Database::Settings { const auto now = static_cast(std::time(nullptr)); return { experimental_matchspec_parsing ? solver::libsolv::MatchSpecParser::Mamba : solver::libsolv::MatchSpecParser::Libsolv, - exclude_newer.empty() ? std::nullopt - : resolve_exclude_newer_cutoff(exclude_newer, now), - resolve_exclude_newer_package_cutoffs(exclude_newer_package, now), + exclude_newer_policy.exclude_newer.empty() + ? std::nullopt + : resolve_exclude_newer_cutoff(exclude_newer_policy.exclude_newer, now), + resolve_exclude_newer_package_cutoffs(exclude_newer_policy.exclude_newer_package, now), }; } } // namespace @@ -542,13 +542,12 @@ namespace mamba solver::libsolv::Database make_solver_database( ChannelContext& channel_context, bool experimental_matchspec_parsing, - const std::string& exclude_newer, - const std::map& exclude_newer_package + const ExcludeNewerPolicy& exclude_newer_policy ) { solver::libsolv::Database db{ channel_context.params(), - make_database_settings(experimental_matchspec_parsing, exclude_newer, exclude_newer_package), + make_database_settings(experimental_matchspec_parsing, exclude_newer_policy), }; add_logger_to_database(db); return db; @@ -598,8 +597,7 @@ namespace mamba { populate_context_channels_from_specs(raw_specs, ctx); - if ((!ctx.exclude_newer.empty() || !ctx.exclude_newer_package.empty()) - && !ctx.mamba_repodata_parsing) + if (!ctx.exclude_newer_policy.empty() && !ctx.mamba_repodata_parsing) { LOG_WARNING << "exclude_newer requires the Mamba repodata parser; packages loaded from " "the libsolv parser will not be filtered"; @@ -608,8 +606,7 @@ namespace mamba auto db = make_solver_database( channel_context, ctx.experimental_matchspec_parsing, - ctx.exclude_newer, - ctx.exclude_newer_package + ctx.exclude_newer_policy ); MultiPackageCache package_caches(ctx.pkgs_dirs, ctx.validation_params); diff --git a/libmamba/src/api/utils.hpp b/libmamba/src/api/utils.hpp index 4fa4dd3466..f1c90fc017 100644 --- a/libmamba/src/api/utils.hpp +++ b/libmamba/src/api/utils.hpp @@ -26,6 +26,7 @@ namespace mamba class ChannelContext; class Configuration; class Context; + struct ExcludeNewerPolicy; class MTransaction; class PrefixData; class MultiPackageCache; @@ -127,8 +128,7 @@ namespace mamba solver::libsolv::Database make_solver_database( ChannelContext& channel_context, bool experimental_matchspec_parsing, - const std::string& exclude_newer, - const std::map& exclude_newer_package + const ExcludeNewerPolicy& exclude_newer_policy ); /** diff --git a/libmamba/src/core/exclude_newer.cpp b/libmamba/src/core/exclude_newer.cpp index 4f922de1c5..1a8e837a57 100644 --- a/libmamba/src/core/exclude_newer.cpp +++ b/libmamba/src/core/exclude_newer.cpp @@ -346,7 +346,7 @@ namespace mamba return resolve_exclude_newer_cutoff_impl(value, now_seconds); } - auto ExcludeNewerCutoffPolicy::cutoff_for(std::string_view package_name) const + auto ExcludeNewerPolicy::cutoff_for(std::string_view package_name) const -> std::optional { if (const auto it = per_package.find(package_name); it != per_package.end()) @@ -356,8 +356,7 @@ namespace mamba return global; } - auto - ExcludeNewerCutoffPolicy::excludes(std::string_view package_name, std::uint64_t pkg_timestamp) const + auto ExcludeNewerPolicy::excludes(std::string_view package_name, std::uint64_t pkg_timestamp) const -> bool { if (const auto cutoff = cutoff_for(package_name)) diff --git a/libmamba/src/core/package_database_loader.cpp b/libmamba/src/core/package_database_loader.cpp index cedc59cc21..7bf7b7d439 100644 --- a/libmamba/src/core/package_database_loader.cpp +++ b/libmamba/src/core/package_database_loader.cpp @@ -90,7 +90,7 @@ namespace mamba : solver::libsolv::RepodataParser::Libsolv; // Solv files are too slow on Windows. They also bypass exclude_newer filtering. - if (!util::on_win && ctx.exclude_newer.empty() && ctx.exclude_newer_package.empty()) + if (!util::on_win && ctx.exclude_newer_policy.empty()) { auto maybe_repo = subdir.valid_libsolv_cache_path().and_then( [&](fs::u8path&& solv_file) diff --git a/libmamba/src/solver/libsolv/database.cpp b/libmamba/src/solver/libsolv/database.cpp index 2a95c048bf..11fc752261 100644 --- a/libmamba/src/solver/libsolv/database.cpp +++ b/libmamba/src/solver/libsolv/database.cpp @@ -99,9 +99,14 @@ namespace mamba::solver::libsolv return m_data->settings; } - auto Database::Settings::exclude_newer_policy() const -> ExcludeNewerCutoffPolicy + auto Database::Settings::exclude_newer_policy() const -> ExcludeNewerPolicy { - return { exclude_newer_timestamp, exclude_newer_package }; + return { + /* .exclude_newer= */ "", + /* .exclude_newer_package= */ {}, + /* .global= */ exclude_newer_timestamp, + /* .per_package= */ exclude_newer_package, + }; } namespace diff --git a/libmamba/src/solver/libsolv/helpers.cpp b/libmamba/src/solver/libsolv/helpers.cpp index fec3eb3869..6254c24d61 100644 --- a/libmamba/src/solver/libsolv/helpers.cpp +++ b/libmamba/src/solver/libsolv/helpers.cpp @@ -453,7 +453,7 @@ namespace mamba::solver::libsolv Filter&& filter, OnParsed&& on_parsed, MatchSpecParser parser, - ExcludeNewerCutoffPolicy exclude_newer_policy = {} + ExcludeNewerPolicy exclude_newer_policy = {} ) { auto packages_as_object = packages.get_object(); @@ -506,7 +506,7 @@ namespace mamba::solver::libsolv JSONObject& packages, const std::optional& signatures, MatchSpecParser parser, - ExcludeNewerCutoffPolicy exclude_newer_policy = {} + ExcludeNewerPolicy exclude_newer_policy = {} ) { return set_repo_solvables_impl( @@ -534,7 +534,7 @@ namespace mamba::solver::libsolv JSONObject& packages, const std::optional& signatures, MatchSpecParser parser, - ExcludeNewerCutoffPolicy exclude_newer_policy = {} + ExcludeNewerPolicy exclude_newer_policy = {} ) -> util::flat_set { auto filenames = util::flat_set(); @@ -568,7 +568,7 @@ namespace mamba::solver::libsolv const std::optional& signatures, const SortedStringRange& added, MatchSpecParser parser, - ExcludeNewerCutoffPolicy exclude_newer_policy = {} + ExcludeNewerPolicy exclude_newer_policy = {} ) { return set_repo_solvables_impl( @@ -649,7 +649,7 @@ namespace mamba::solver::libsolv PackageTypes package_types, MatchSpecParser ms_parser, bool verify_artifacts, - ExcludeNewerCutoffPolicy exclude_newer_policy + ExcludeNewerPolicy exclude_newer_policy ) -> expected_t { LOG_INFO << "Reading repodata.json file " << filename << " for repo " << repo.name() diff --git a/libmamba/src/solver/libsolv/helpers.hpp b/libmamba/src/solver/libsolv/helpers.hpp index fe9009edb6..a01267ca5a 100644 --- a/libmamba/src/solver/libsolv/helpers.hpp +++ b/libmamba/src/solver/libsolv/helpers.hpp @@ -73,7 +73,7 @@ namespace mamba::solver::libsolv PackageTypes types, MatchSpecParser parser, bool verify_artifacts, - ExcludeNewerCutoffPolicy exclude_newer_policy = {} + ExcludeNewerPolicy exclude_newer_policy = {} ) -> expected_t; [[nodiscard]] auto read_solv( diff --git a/libmamba/tests/src/core/test_exclude_newer.cpp b/libmamba/tests/src/core/test_exclude_newer.cpp index 7dcaefcb5b..f81660465d 100644 --- a/libmamba/tests/src/core/test_exclude_newer.cpp +++ b/libmamba/tests/src/core/test_exclude_newer.cpp @@ -67,11 +67,13 @@ namespace } } - TEST_CASE("ExcludeNewerCutoffPolicy") + TEST_CASE("ExcludeNewerPolicy cutoff behavior") { constexpr std::uint64_t global_cutoff = 2000; - const ExcludeNewerCutoffPolicy policy{ + const ExcludeNewerPolicy policy{ + /* .exclude_newer= */ "", + /* .exclude_newer_package= */ {}, /* .global= */ global_cutoff, /* .per_package= */ { From 376768bd3614e11e14133e23787b644d70f28145 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 16 Jun 2026 11:21:03 +0200 Subject: [PATCH 09/41] Replace `trim` in preference of `util::strip_if` Signed-off-by: Julien Jerphanion Co-authored-by: Hind Montassif --- libmamba/src/core/exclude_newer.cpp | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/libmamba/src/core/exclude_newer.cpp b/libmamba/src/core/exclude_newer.cpp index 1a8e837a57..8a503ac202 100644 --- a/libmamba/src/core/exclude_newer.cpp +++ b/libmamba/src/core/exclude_newer.cpp @@ -12,24 +12,12 @@ #include #include "mamba/core/exclude_newer.hpp" +#include "mamba/util/string.hpp" namespace mamba { namespace { - [[nodiscard]] auto trim(std::string_view value) -> std::string_view - { - while (!value.empty() && std::isspace(static_cast(value.front()))) - { - value.remove_prefix(1); - } - while (!value.empty() && std::isspace(static_cast(value.back()))) - { - value.remove_suffix(1); - } - return value; - } - [[nodiscard]] auto invalid_exclude_newer(std::string_view value) -> std::invalid_argument { return std::invalid_argument( @@ -314,7 +302,7 @@ namespace mamba resolve_exclude_newer_cutoff_impl(std::string_view value, std::uint64_t now_seconds) -> std::optional { - value = trim(value); + value = util::strip_if(value, [](unsigned char c) { return std::isspace(c); }); if (value.empty()) { return std::nullopt; @@ -374,7 +362,10 @@ namespace mamba auto out = ExcludeNewerPackageCutoffs{}; for (const auto& [name, value] : exclude_newer_package) { - const auto trimmed = trim(value); + const auto trimmed = util::strip_if( + std::string_view{ value }, + [](unsigned char c) { return std::isspace(c); } + ); if (trimmed.size() == 5 && (trimmed[0] == 'f' || trimmed[0] == 'F') && (trimmed[1] == 'a' || trimmed[1] == 'A') && (trimmed[2] == 'l' || trimmed[2] == 'L') From 9c03ead413ce097c7dc6ff8e0f237df126297b09 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 16 Jun 2026 11:28:10 +0200 Subject: [PATCH 10/41] Include missing header file in libmamba sources list Signed-off-by: Julien Jerphanion Co-authored-by: Hind Montassif --- libmamba/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/libmamba/CMakeLists.txt b/libmamba/CMakeLists.txt index c8a3bc88fb..bad321811e 100644 --- a/libmamba/CMakeLists.txt +++ b/libmamba/CMakeLists.txt @@ -384,6 +384,7 @@ set( ${LIBMAMBA_INCLUDE_DIR}/mamba/core/download_progress_bar.hpp ${LIBMAMBA_INCLUDE_DIR}/mamba/core/env_lockfile.hpp ${LIBMAMBA_INCLUDE_DIR}/mamba/core/environments_manager.hpp + ${LIBMAMBA_INCLUDE_DIR}/mamba/core/exclude_newer.hpp ${LIBMAMBA_INCLUDE_DIR}/mamba/core/error_handling.hpp ${LIBMAMBA_INCLUDE_DIR}/mamba/core/execution.hpp ${LIBMAMBA_INCLUDE_DIR}/mamba/core/fsutil.hpp From d005385a23e10faed3962903277d8a70c958f34e Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 16 Jun 2026 11:31:29 +0200 Subject: [PATCH 11/41] Minor adaptations to utility functions Signed-off-by: Julien Jerphanion --- libmamba/src/core/exclude_newer.cpp | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/libmamba/src/core/exclude_newer.cpp b/libmamba/src/core/exclude_newer.cpp index 8a503ac202..70f47335ab 100644 --- a/libmamba/src/core/exclude_newer.cpp +++ b/libmamba/src/core/exclude_newer.cpp @@ -18,6 +18,17 @@ namespace mamba { namespace { + [[nodiscard]] auto equals_ci(std::string_view value, std::string_view expected) -> bool + { + return value.size() == expected.size() + && std::equal( + value.begin(), + value.end(), + expected.begin(), + [](char lhs, char rhs) { return util::to_lower(lhs) == util::to_lower(rhs); } + ); + } + [[nodiscard]] auto invalid_exclude_newer(std::string_view value) -> std::invalid_argument { return std::invalid_argument( @@ -75,7 +86,7 @@ namespace mamba { return std::nullopt; } - while (!remaining.empty() && std::isspace(static_cast(remaining.front()))) + while (!remaining.empty() && util::is_space(remaining.front())) { remaining.remove_prefix(1); } @@ -302,7 +313,7 @@ namespace mamba resolve_exclude_newer_cutoff_impl(std::string_view value, std::uint64_t now_seconds) -> std::optional { - value = util::strip_if(value, [](unsigned char c) { return std::isspace(c); }); + value = util::strip_if(value, [](char c) { return util::is_space(c); }); if (value.empty()) { return std::nullopt; @@ -364,13 +375,9 @@ namespace mamba { const auto trimmed = util::strip_if( std::string_view{ value }, - [](unsigned char c) { return std::isspace(c); } + [](char c) { return util::is_space(c); } ); - if (trimmed.size() == 5 && (trimmed[0] == 'f' || trimmed[0] == 'F') - && (trimmed[1] == 'a' || trimmed[1] == 'A') - && (trimmed[2] == 'l' || trimmed[2] == 'L') - && (trimmed[3] == 's' || trimmed[3] == 'S') - && (trimmed[4] == 'e' || trimmed[4] == 'E')) + if (equals_ci(trimmed, "false")) { out.emplace(name, std::nullopt); } From d669e92692c62ceca10cbce1084531324b47acfa Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 16 Jun 2026 11:38:43 +0200 Subject: [PATCH 12/41] Add tests for `{CONDA,MAMBA}_EXCLUDE_NEWER{,_PACKAGE}` Signed-off-by: Julien Jerphanion Co-authored-by: Hind Montassif --- micromamba/tests/test_config.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/micromamba/tests/test_config.py b/micromamba/tests/test_config.py index cdc1f8b652..1c70565c0b 100644 --- a/micromamba/tests/test_config.py +++ b/micromamba/tests/test_config.py @@ -269,6 +269,21 @@ def test_env_vars(self): ) os.environ.pop("MAMBA_OFFLINE") + @pytest.mark.parametrize("env_name", ["CONDA_EXCLUDE_NEWER", "MAMBA_EXCLUDE_NEWER"]) + def test_exclude_newer_env_var(self, monkeypatch, env_name): + monkeypatch.setenv(env_name, "7d") + values = config("list", "exclude_newer", "--no-rc", "--json") + assert values["exclude_newer"] == "7d" + + @pytest.mark.parametrize( + "env_name", + ["CONDA_EXCLUDE_NEWER_PACKAGE", "MAMBA_EXCLUDE_NEWER_PACKAGE"], + ) + def test_exclude_newer_package_env_var(self, monkeypatch, env_name): + monkeypatch.setenv(env_name, "{numpy: 'false', pandas: '7d'}") + values = config("list", "exclude_newer_package", "--no-rc", "--json") + assert values["exclude_newer_package"] == {"numpy": "false", "pandas": "7d"} + def test_no_env(self): os.environ["MAMBA_OFFLINE"] = "false" From 12c7fb6b4b097f215360340d9801534727a7be8c Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 16 Jun 2026 11:42:16 +0200 Subject: [PATCH 13/41] Test abscence of `timestamp` and `indexed_timestamp` Signed-off-by: Julien Jerphanion Co-authored-by: Hind Montassif --- libmambapy/tests/test_solver_libsolv.py | 88 +++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/libmambapy/tests/test_solver_libsolv.py b/libmambapy/tests/test_solver_libsolv.py index 8c006d2edf..eb85dc716c 100644 --- a/libmambapy/tests/test_solver_libsolv.py +++ b/libmambapy/tests/test_solver_libsolv.py @@ -266,6 +266,94 @@ def test_Database_exclude_newer_timestamp_repodata(tmp_path): assert pkgs[0].name == "included-pkg" +def test_Database_exclude_newer_timestamp_repodata_timestamp_only(tmp_path): + repodata_file = tmp_path / "repodata_timestamp_only.json" + with open(repodata_file, "w+") as f: + json.dump( + { + "packages": { + "excluded-by-timestamp-1.0-bld.tar.bz2": { + "name": "excluded-by-timestamp", + "version": "1.0", + "build": "bld", + "build_number": 0, + "subdir": "linux-64", + "depends": [], + "timestamp": 3000, + }, + "included-by-timestamp-1.0-bld.tar.bz2": { + "name": "included-by-timestamp", + "version": "1.0", + "build": "bld", + "build_number": 0, + "subdir": "linux-64", + "depends": [], + "timestamp": 1000, + }, + }, + "packages.conda": {}, + }, + f, + ) + + db = libsolv.Database( + libmambapy.specs.ChannelResolveParams(), + exclude_newer_timestamp=2000, + ) + repo = db.add_repo_from_repodata_json( + repodata_file, + "https://example.com/linux-64", + "test-channel", + ) + assert repo is not None + assert repo.package_count() == 1 + pkgs = db.packages_in_repo(repo) + assert pkgs[0].name == "included-by-timestamp" + + +def test_Database_exclude_newer_timestamp_repodata_without_timestamps(tmp_path): + repodata_file = tmp_path / "repodata_no_timestamps.json" + with open(repodata_file, "w+") as f: + json.dump( + { + "packages": { + "pkg-no-ts-a-1.0-bld.tar.bz2": { + "name": "pkg-no-ts-a", + "version": "1.0", + "build": "bld", + "build_number": 0, + "subdir": "linux-64", + "depends": [], + }, + "pkg-no-ts-b-1.0-bld.tar.bz2": { + "name": "pkg-no-ts-b", + "version": "1.0", + "build": "bld", + "build_number": 0, + "subdir": "linux-64", + "depends": [], + }, + }, + "packages.conda": {}, + }, + f, + ) + + db = libsolv.Database( + libmambapy.specs.ChannelResolveParams(), + exclude_newer_timestamp=2000, + ) + repo = db.add_repo_from_repodata_json( + repodata_file, + "https://example.com/linux-64", + "test-channel", + ) + assert repo is not None + assert repo.package_count() == 2 + pkgs = db.packages_in_repo(repo) + assert {pkg.name for pkg in pkgs} == {"pkg-no-ts-a", "pkg-no-ts-b"} + + @pytest.fixture def tmp_repodata_json(tmp_path): file = tmp_path / "repodata.json" From bdc780528ed188244f254d0947b40c218c039760 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 16 Jun 2026 11:50:38 +0200 Subject: [PATCH 14/41] Test `exclude_newer{,_package}` further Signed-off-by: Julien Jerphanion --- .../src/solver/libsolv/test_database.cpp | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/libmamba/tests/src/solver/libsolv/test_database.cpp b/libmamba/tests/src/solver/libsolv/test_database.cpp index 274645635c..96af9117f9 100644 --- a/libmamba/tests/src/solver/libsolv/test_database.cpp +++ b/libmamba/tests/src/solver/libsolv/test_database.cpp @@ -402,6 +402,146 @@ namespace ); } + SECTION("exclude_newer date policies with package overrides") + { + auto tmp_dir = TemporaryDirectory(); + const auto repodata = tmp_dir.path() / "repodata.json"; + std::ofstream out_file(repodata.std_path()); + out_file << R"({ + "packages": { + "mamba-2.0-0.tar.bz2": { + "name": "mamba", + "version": "2.0", + "build": "0", + "build_number": 0, + "subdir": "linux-64", + "depends": [], + "timestamp": 1764547200 + }, + "mamba-2.8-0.tar.bz2": { + "name": "mamba", + "version": "2.8", + "build": "0", + "build_number": 0, + "subdir": "linux-64", + "depends": [], + "timestamp": 1768435200 + }, + "numpy-2.0-0.tar.bz2": { + "name": "numpy", + "version": "2.0", + "build": "0", + "build_number": 0, + "subdir": "linux-64", + "depends": [], + "timestamp": 1730000000 + } + }, + "packages.conda": {} + })"; + out_file.close(); + + const auto cutoff_2019 = resolve_exclude_newer_cutoff("2019-01-01", 0).value(); + const auto cutoff_2026_jan = resolve_exclude_newer_cutoff("2026-01-01", 0).value(); + + SECTION("global 2019 cutoff excludes mamba 2.0 and numpy 2.0") + { + auto db_filtered = libsolv::Database( + {}, + { matchspec_parser, /* exclude_newer_timestamp= */ cutoff_2019 } + ); + auto repo1 = db_filtered.add_repo_from_repodata_json( + repodata, + "https://conda.anaconda.org/conda-forge/linux-64", + "conda-forge", + libsolv::PipAsPythonDependency::No + ); + REQUIRE(repo1.has_value()); + + std::size_t mamba_count = 0; + db_filtered.for_each_package_matching( + specs::MatchSpec::parse("mamba").value(), + [&](const auto&) { ++mamba_count; } + ); + std::size_t numpy_count = 0; + db_filtered.for_each_package_matching( + specs::MatchSpec::parse("numpy").value(), + [&](const auto&) { ++numpy_count; } + ); + REQUIRE(mamba_count == 0); + REQUIRE(numpy_count == 0); + } + + SECTION("numpy opt-out allows numpy 2.0 with same global 2019 cutoff") + { + auto db_filtered = libsolv::Database( + {}, + { + matchspec_parser, + /* exclude_newer_timestamp= */ cutoff_2019, + /* exclude_newer_package= */ + ExcludeNewerPackageCutoffs{ + { "numpy", std::nullopt }, + }, + } + ); + auto repo1 = db_filtered.add_repo_from_repodata_json( + repodata, + "https://conda.anaconda.org/conda-forge/linux-64", + "conda-forge", + libsolv::PipAsPythonDependency::No + ); + REQUIRE(repo1.has_value()); + + std::size_t mamba_count = 0; + db_filtered.for_each_package_matching( + specs::MatchSpec::parse("mamba").value(), + [&](const auto&) { ++mamba_count; } + ); + std::size_t numpy_count = 0; + db_filtered.for_each_package_matching( + specs::MatchSpec::parse("numpy").value(), + [&](const auto& p) + { + ++numpy_count; + REQUIRE(p.version == "2.0"); + } + ); + REQUIRE(mamba_count == 0); + REQUIRE(numpy_count == 1); + } + + SECTION("mamba January 2026 package policy keeps 2.0 and excludes 2.8") + { + auto db_filtered = libsolv::Database( + {}, + { + matchspec_parser, + /* exclude_newer_timestamp= */ cutoff_2019, + /* exclude_newer_package= */ + ExcludeNewerPackageCutoffs{ + { "mamba", cutoff_2026_jan }, + }, + } + ); + auto repo1 = db_filtered.add_repo_from_repodata_json( + repodata, + "https://conda.anaconda.org/conda-forge/linux-64", + "conda-forge", + libsolv::PipAsPythonDependency::No + ); + REQUIRE(repo1.has_value()); + + std::vector mamba_versions; + db_filtered.for_each_package_matching( + specs::MatchSpec::parse("mamba").value(), + [&](const auto& p) { mamba_versions.push_back(p.version); } + ); + REQUIRE(mamba_versions.size() == 1); + REQUIRE(mamba_versions[0] == "2.0"); + } + } + SECTION("Add repo from repodata with extra pip") { const auto repodata = mambatests::test_data_dir From 815bc46bf0b6547b5e58435a664a0ae73249caf2 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Wed, 17 Jun 2026 11:15:31 +0200 Subject: [PATCH 15/41] Reorder files in sources lists Signed-off-by: Julien Jerphanion Co-authored-by: Hind Montassif --- libmamba/CMakeLists.txt | 4 ++-- libmamba/tests/CMakeLists.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/libmamba/CMakeLists.txt b/libmamba/CMakeLists.txt index bad321811e..4820e8c1af 100644 --- a/libmamba/CMakeLists.txt +++ b/libmamba/CMakeLists.txt @@ -227,8 +227,8 @@ set( ${LIBMAMBA_SOURCE_DIR}/core/env_lockfile_conda.cpp ${LIBMAMBA_SOURCE_DIR}/core/env_lockfile_mambajs.cpp ${LIBMAMBA_SOURCE_DIR}/core/environments_manager.cpp - ${LIBMAMBA_SOURCE_DIR}/core/exclude_newer.cpp ${LIBMAMBA_SOURCE_DIR}/core/error_handling.cpp + ${LIBMAMBA_SOURCE_DIR}/core/exclude_newer.cpp ${LIBMAMBA_SOURCE_DIR}/core/execution.cpp ${LIBMAMBA_SOURCE_DIR}/core/fsutil.cpp ${LIBMAMBA_SOURCE_DIR}/core/history.cpp @@ -384,8 +384,8 @@ set( ${LIBMAMBA_INCLUDE_DIR}/mamba/core/download_progress_bar.hpp ${LIBMAMBA_INCLUDE_DIR}/mamba/core/env_lockfile.hpp ${LIBMAMBA_INCLUDE_DIR}/mamba/core/environments_manager.hpp - ${LIBMAMBA_INCLUDE_DIR}/mamba/core/exclude_newer.hpp ${LIBMAMBA_INCLUDE_DIR}/mamba/core/error_handling.hpp + ${LIBMAMBA_INCLUDE_DIR}/mamba/core/exclude_newer.hpp ${LIBMAMBA_INCLUDE_DIR}/mamba/core/execution.hpp ${LIBMAMBA_INCLUDE_DIR}/mamba/core/fsutil.hpp ${LIBMAMBA_INCLUDE_DIR}/mamba/core/history.hpp diff --git a/libmamba/tests/CMakeLists.txt b/libmamba/tests/CMakeLists.txt index 69949867f2..661f505d15 100644 --- a/libmamba/tests/CMakeLists.txt +++ b/libmamba/tests/CMakeLists.txt @@ -95,8 +95,8 @@ set( src/core/test_cpp.cpp src/core/test_env_file_reading.cpp src/core/test_env_lockfile.cpp - src/core/test_exclude_newer.cpp src/core/test_environments_manager.cpp + src/core/test_exclude_newer.cpp src/core/test_execution.cpp src/core/test_filesystem.cpp src/core/test_history.cpp From bd81f34a0f96d3c30584feb67ffd57aabe25c4aa Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 30 Jun 2026 14:17:02 +0200 Subject: [PATCH 16/41] Remove leftover field Signed-off-by: Julien Jerphanion --- libmamba/include/mamba/core/exclude_newer.hpp | 4 ---- 1 file changed, 4 deletions(-) diff --git a/libmamba/include/mamba/core/exclude_newer.hpp b/libmamba/include/mamba/core/exclude_newer.hpp index f0aa3385e0..580b5b3a2e 100644 --- a/libmamba/include/mamba/core/exclude_newer.hpp +++ b/libmamba/include/mamba/core/exclude_newer.hpp @@ -21,8 +21,6 @@ namespace mamba { struct ExcludeNewerPackageHash { - using is_transparent = void; - [[nodiscard]] auto operator()(std::string_view value) const noexcept -> std::size_t { return std::hash{}(value); @@ -31,8 +29,6 @@ namespace mamba struct ExcludeNewerPackageEqual { - using is_transparent = void; - [[nodiscard]] auto operator()(std::string_view lhs, std::string_view rhs) const noexcept -> bool { From af68afdc2e1cea0830a32f338016ab37901ccd29 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 30 Jun 2026 14:57:17 +0200 Subject: [PATCH 17/41] Use elements of `std::chrono` and apply review comments Signed-off-by: Julien Jerphanion Co-authored-by: Johan Mabille --- libmamba/include/mamba/core/exclude_newer.hpp | 68 ++-- libmamba/src/core/exclude_newer.cpp | 314 +++++++++--------- .../tests/src/core/test_exclude_newer.cpp | 43 ++- 3 files changed, 242 insertions(+), 183 deletions(-) diff --git a/libmamba/include/mamba/core/exclude_newer.hpp b/libmamba/include/mamba/core/exclude_newer.hpp index 580b5b3a2e..bf8eea1dd9 100644 --- a/libmamba/include/mamba/core/exclude_newer.hpp +++ b/libmamba/include/mamba/core/exclude_newer.hpp @@ -8,7 +8,6 @@ #define MAMBA_CORE_EXCLUDE_NEWER_HPP #include -#include #include #include #include @@ -17,41 +16,26 @@ namespace mamba { - namespace detail - { - struct ExcludeNewerPackageHash - { - [[nodiscard]] auto operator()(std::string_view value) const noexcept -> std::size_t - { - return std::hash{}(value); - } - }; - - struct ExcludeNewerPackageEqual - { - [[nodiscard]] auto operator()(std::string_view lhs, std::string_view rhs) const noexcept - -> bool - { - return lhs == rhs; - } - }; - } // namespace detail - /** * Resolved per-package ``exclude_newer`` cutoffs. * + * Cutoffs are stored as Unix epoch seconds (``std::uint64_t``) for compatibility with + * conda repodata timestamps and ``Database::Settings``. Parsing uses + * ``std::chrono::sys_seconds`` internally; see ``resolve_exclude_newer_cutoff``. + * * When a package name is present: * - ``std::nullopt`` exempts the package from the global policy (``false`` in config) * - a timestamp value applies a package-specific cutoff * * Packages not listed fall back to the global cutoff. */ - using ExcludeNewerPackageCutoffs = std::unordered_map< - std::string, - std::optional, - detail::ExcludeNewerPackageHash, - detail::ExcludeNewerPackageEqual>; + using ExcludeNewerPackageCutoffs = std::unordered_map>; + /** + * Resolved and raw ``exclude_newer`` policy from configuration. + * + * Holds both unresolved config strings and resolved Unix-second cutoffs used by the solver. + */ struct ExcludeNewerPolicy { /** @@ -61,6 +45,13 @@ namespace mamba * https://github.com/conda/conda/issues/15759 */ std::string exclude_newer; + + /** + * Raw per-package ``exclude_newer`` overrides from configuration. + * + * Values are resolved to timestamps (or exemption) via + * ``resolve_exclude_newer_package_cutoffs``. + */ std::map exclude_newer_package; /** @@ -73,14 +64,26 @@ namespace mamba */ ExcludeNewerPackageCutoffs per_package = {}; + /** Return whether no ``exclude_newer`` configuration is set. */ [[nodiscard]] auto empty() const -> bool { return exclude_newer.empty() && exclude_newer_package.empty(); } + /** + * Return the effective cutoff for ``package_name``. + * + * Per-package entries take precedence over the global cutoff. A mapped ``std::nullopt`` + * means the package is exempt. + */ [[nodiscard]] auto cutoff_for(std::string_view package_name) const -> std::optional; + /** + * Return whether ``pkg_timestamp`` is newer than the effective cutoff for ``package_name``. + * + * Exempt packages (no cutoff) are never excluded. + */ [[nodiscard]] auto excludes(std::string_view package_name, std::uint64_t pkg_timestamp) const -> bool; }; @@ -88,6 +91,10 @@ namespace mamba /** * Resolve raw per-package ``exclude_newer`` configuration values. * + * @param exclude_newer_package Map of package name to config value (duration, date, or + * ``false``). + * @param now_seconds Reference time for relative durations, in Unix seconds. + * * @throws std::invalid_argument when a non-``false`` value cannot be parsed. */ [[nodiscard]] auto resolve_exclude_newer_package_cutoffs( @@ -99,12 +106,21 @@ namespace mamba * Resolve a global ``exclude_newer`` configuration value to an absolute Unix * timestamp cutoff in seconds. * + * The public API exposes ``std::uint64_t`` seconds for compatibility with repodata + * timestamps. Internally, date and datetime values are parsed with + * ``std::chrono::parse`` and converted at this boundary. Duration strings + * (``7d``, ``P7D``, plain seconds) use custom parsers because the standard library + * does not provide ISO 8601 duration parsing. + * * Matches conda's ``exclude_newer`` semantics: * - Durations (``7d``, ``P7D``, plain seconds) resolve to ``now - duration`` * - Date-only values (``YYYY-MM-DD``) resolve to the start of the next UTC day * - Datetimes resolve to the given instant (naive values are UTC) * - Zero durations (``0``, ``0d``, ``P0D``) resolve to ``now`` * + * @param value Raw configuration string. + * @param now_seconds Reference time for relative durations, in Unix seconds. + * * Returns ``std::nullopt`` when ``value`` is empty/whitespace-only. * * @throws std::invalid_argument when the value cannot be parsed. diff --git a/libmamba/src/core/exclude_newer.cpp b/libmamba/src/core/exclude_newer.cpp index 70f47335ab..c58aabd4bb 100644 --- a/libmamba/src/core/exclude_newer.cpp +++ b/libmamba/src/core/exclude_newer.cpp @@ -6,8 +6,9 @@ #include #include -#include +#include #include +#include #include #include @@ -18,6 +19,37 @@ namespace mamba { namespace { + // Disambiguate the char overload for use with strip_if/lstrip_if templates. + constexpr auto is_space = static_cast(util::is_space); + + using CutoffInstant = std::chrono::sys_seconds; + using SysTime = std::chrono::sys_time; + + /** Convert an internal UTC instant to Unix epoch seconds for the public API. */ + [[nodiscard]] auto to_unix_seconds(CutoffInstant instant) -> std::uint64_t + { + return static_cast(instant.time_since_epoch().count()); + } + + /** + * Parse ``value`` with ``std::chrono::parse`` using ``fmt``. + * + * Returns ``std::nullopt`` when parsing fails or trailing characters remain. + */ + template + [[nodiscard]] auto parse_chrono(std::string_view value, const char* fmt) -> std::optional + { + std::istringstream stream{ std::string(value) }; + T out{}; + stream >> std::chrono::parse(fmt, out); + if (stream.fail() || stream.peek() != std::istringstream::traits_type::eof()) + { + return std::nullopt; + } + return out; + } + + /** Case-insensitive equality for ASCII strings. */ [[nodiscard]] auto equals_ci(std::string_view value, std::string_view expected) -> bool { return value.size() == expected.size() @@ -29,6 +61,7 @@ namespace mamba ); } + /** Build a ``std::invalid_argument`` for an unparsable ``exclude_newer`` value. */ [[nodiscard]] auto invalid_exclude_newer(std::string_view value) -> std::invalid_argument { return std::invalid_argument( @@ -37,6 +70,11 @@ namespace mamba ); } + /** + * Parse a leading unsigned integer from ``value`` and advance past the consumed digits. + * + * Used by compact duration parsing (e.g. ``7d``). + */ [[nodiscard]] auto parse_uint_prefix(std::string_view& value) -> std::optional { if (value.empty() || !std::isdigit(static_cast(value.front()))) @@ -53,9 +91,10 @@ namespace mamba return number; } - [[nodiscard]] auto parse_fixed_uint(std::string_view value) -> std::optional + /** Parse ``value`` as an unsigned integer that must consume the entire string. */ + [[nodiscard]] auto parse_fixed_uint(std::string_view value) -> std::optional { - int number = 0; + std::uint64_t number = 0; const auto [ptr, ec] = std::from_chars(value.data(), value.data() + value.size(), number); if (ec != std::errc() || ptr != value.data() + value.size()) { @@ -64,55 +103,78 @@ namespace mamba return number; } - [[nodiscard]] auto parse_plain_seconds(std::string_view value) -> std::optional + /** Parse a plain integer duration in seconds (e.g. ``3600``). */ + [[nodiscard]] auto parse_plain_seconds(std::string_view value) + -> std::optional { - std::uint64_t seconds = 0; - const auto* begin = value.data(); - const auto* end = begin + value.size(); - const auto [ptr, ec] = std::from_chars(begin, end, seconds); - if (ec != std::errc() || ptr != end) + if (auto seconds = parse_fixed_uint(value)) { - return std::nullopt; + return std::chrono::seconds{ static_cast(*seconds) }; } - return seconds; + return std::nullopt; } + /** Parse a compact duration such as ``7d``, ``3d12h``, ``1w``, or ``30s``. */ [[nodiscard]] auto parse_compact_duration_seconds(std::string_view value) - -> std::optional + -> std::optional { auto remaining = value; - const auto amount = parse_uint_prefix(remaining); - if (!amount) - { - return std::nullopt; - } - while (!remaining.empty() && util::is_space(remaining.front())) + std::uint64_t total = 0; + bool has_component = false; + + while (!remaining.empty()) { + const auto amount = parse_uint_prefix(remaining); + if (!amount) + { + return std::nullopt; + } + remaining = util::lstrip_if(remaining, is_space); + if (remaining.empty()) + { + return std::nullopt; + } + + std::uint64_t multiplier = 0; + switch (std::tolower(static_cast(remaining.front()))) + { + case 'w': + multiplier = 604800; + break; + case 'd': + multiplier = 86400; + break; + case 'h': + multiplier = 3600; + break; + case 'm': + multiplier = 60; + break; + case 's': + multiplier = 1; + break; + default: + return std::nullopt; + } remaining.remove_prefix(1); + has_component = true; + total += *amount * multiplier; } - if (remaining.size() != 1) + + if (!has_component) { return std::nullopt; } - switch (std::tolower(static_cast(remaining.front()))) - { - case 'w': - return *amount * 604800; - case 'd': - return *amount * 86400; - case 'h': - return *amount * 3600; - case 'm': - return *amount * 60; - case 's': - return *amount; - default: - return std::nullopt; - } + return std::chrono::seconds{ static_cast(total) }; } + /** + * Parse an ISO 8601 duration such as ``P7D`` or ``P1DT12H``. + * + * @throws std::invalid_argument when the value starts with ``P`` but has no components. + */ [[nodiscard]] auto parse_iso8601_duration_seconds(std::string_view value) - -> std::optional + -> std::optional { if (value.empty() || std::tolower(static_cast(value.front())) != 'p') { @@ -152,7 +214,7 @@ namespace mamba } remaining.remove_prefix(digits + 1); has_component = true; - total += static_cast(*amount) * multiplier; + total += *amount * multiplier; return true; }; @@ -180,11 +242,16 @@ namespace mamba { throw invalid_exclude_newer(value); } - return total; + return std::chrono::seconds{ static_cast(total) }; } + /** + * Parse a duration string in any supported format. + * + * Tries plain seconds, ISO 8601, then compact notation, in that order. + */ [[nodiscard]] auto parse_duration_seconds(std::string_view value) - -> std::optional + -> std::optional { if (auto seconds = parse_plain_seconds(value)) { @@ -197,44 +264,33 @@ namespace mamba return parse_compact_duration_seconds(value); } - [[nodiscard]] auto timegm_utc(std::tm tm) -> std::time_t - { -#if defined(_WIN32) - return _mkgmtime(&tm); -#else - return timegm(&tm); -#endif - } - - [[nodiscard]] auto parse_date_only_next_utc_day(std::string_view value) - -> std::optional + /** + * Parse a date-only value (``YYYY-MM-DD``) to the start of the next UTC day. + * + * Matches conda's exclusive upper-bound semantics for date-only ``exclude_newer``. + */ + [[nodiscard]] auto parse_date_only(std::string_view value) -> std::optional { - if (value.size() != 10 || value[4] != '-' || value[7] != '-') + if (value.size() != 10) { return std::nullopt; } - const auto year = parse_fixed_uint(value.substr(0, 4)); - const auto month = parse_fixed_uint(value.substr(5, 2)); - const auto day = parse_fixed_uint(value.substr(8, 2)); - if (!year || !month || !day) + const auto day = parse_chrono(value, "%F"); + if (!day) { return std::nullopt; } - std::tm tm = {}; - tm.tm_year = *year - 1900; - tm.tm_mon = *month - 1; - tm.tm_mday = *day + 1; - tm.tm_hour = 0; - tm.tm_min = 0; - tm.tm_sec = 0; - tm.tm_isdst = 0; - return static_cast(timegm_utc(tm)); + return CutoffInstant{ *day + std::chrono::days{ 1 } }; } - [[nodiscard]] auto parse_datetime_timestamp(std::string_view value) - -> std::optional + /** + * Parse a datetime value to an absolute UTC instant. + * + * Supports ``%FT%T%Ez``, ``%FT%TZ``, and naive ``%FT%T`` forms. + */ + [[nodiscard]] auto parse_datetime(std::string_view value) -> std::optional { if (value.size() < 19 || value[4] != '-' || value[7] != '-' || value[10] != 'T' || value[13] != ':' || value[16] != ':') @@ -242,119 +298,77 @@ namespace mamba return std::nullopt; } - const auto year = parse_fixed_uint(value.substr(0, 4)); - const auto month = parse_fixed_uint(value.substr(5, 2)); - const auto day = parse_fixed_uint(value.substr(8, 2)); - const auto hour = parse_fixed_uint(value.substr(11, 2)); - const auto minute = parse_fixed_uint(value.substr(14, 2)); - const auto second = parse_fixed_uint(value.substr(17, 2)); - if (!year || !month || !day || !hour || !minute || !second) + if (auto instant = parse_chrono(value, "%FT%T%Ez")) { - return std::nullopt; + return CutoffInstant{ instant->time_since_epoch() }; } - - std::tm tm = {}; - tm.tm_year = *year - 1900; - tm.tm_mon = *month - 1; - tm.tm_mday = *day; - tm.tm_hour = *hour; - tm.tm_min = *minute; - tm.tm_sec = *second; - tm.tm_isdst = 0; - - auto timestamp = timegm_utc(tm); - auto suffix = value.substr(19); - - if (suffix.empty()) - { - // Naive datetimes are interpreted as UTC. - } - else if (suffix.size() == 1 - && std::tolower(static_cast(suffix.front())) == 'z') - { - // Explicit UTC. - } - else if (suffix.size() == 6 && (suffix.front() == '+' || suffix.front() == '-') - && suffix[3] == ':') - { - const auto offset_hours = parse_fixed_uint(suffix.substr(1, 2)); - const auto offset_minutes = parse_fixed_uint(suffix.substr(4, 2)); - if (!offset_hours || !offset_minutes) - { - return std::nullopt; - } - const auto sign = suffix.front() == '+' ? 1 : -1; - const auto offset_seconds = sign * (*offset_hours * 3600 + *offset_minutes * 60); - timestamp -= offset_seconds; - } - else + if (auto instant = parse_chrono(value, "%FT%TZ")) { - return std::nullopt; + return CutoffInstant{ instant->time_since_epoch() }; } - - if (timestamp < 0) + if (auto instant = parse_chrono(value, "%FT%T")) { - return std::nullopt; + return CutoffInstant{ instant->time_since_epoch() }; } - return static_cast(timestamp); + return std::nullopt; } - [[nodiscard]] auto - duration_cutoff(std::uint64_t duration_seconds, std::uint64_t now_seconds) -> std::uint64_t + /** Compute ``now - duration``, clamping to epoch zero when the duration exceeds ``now``. */ + [[nodiscard]] auto duration_cutoff(std::chrono::seconds duration, CutoffInstant now) + -> CutoffInstant { - if (duration_seconds > now_seconds) + if (duration > now.time_since_epoch()) { - return 0; + return CutoffInstant{}; } - return now_seconds - duration_seconds; + return now - duration; } - [[nodiscard]] auto - resolve_exclude_newer_cutoff_impl(std::string_view value, std::uint64_t now_seconds) - -> std::optional - { - value = util::strip_if(value, [](char c) { return util::is_space(c); }); - if (value.empty()) - { - return std::nullopt; - } + } // namespace - if (auto duration_seconds = parse_duration_seconds(value)) - { - return duration_cutoff(*duration_seconds, now_seconds); - } + /** Resolve a global ``exclude_newer`` value; see ``resolve_exclude_newer_cutoff`` in the + * header. */ + auto resolve_exclude_newer_cutoff(std::string_view value, std::uint64_t now_seconds) + -> std::optional + { + value = util::strip_if(value, is_space); + if (value.empty()) + { + return std::nullopt; + } - if (auto timestamp = parse_date_only_next_utc_day(value)) - { - return *timestamp; - } + const auto now = CutoffInstant{ std::chrono::seconds{ now_seconds } }; - if (auto timestamp = parse_datetime_timestamp(value)) - { - return *timestamp; - } + if (auto duration = parse_duration_seconds(value)) + { + return to_unix_seconds(duration_cutoff(*duration, now)); + } - throw invalid_exclude_newer(value); + if (auto instant = parse_date_only(value)) + { + return to_unix_seconds(*instant); } - } // namespace + if (auto instant = parse_datetime(value)) + { + return to_unix_seconds(*instant); + } - auto resolve_exclude_newer_cutoff(std::string_view value, std::uint64_t now_seconds) - -> std::optional - { - return resolve_exclude_newer_cutoff_impl(value, now_seconds); + throw invalid_exclude_newer(value); } + /** Return the per-package or global cutoff for ``package_name``. */ auto ExcludeNewerPolicy::cutoff_for(std::string_view package_name) const -> std::optional { - if (const auto it = per_package.find(package_name); it != per_package.end()) + if (const auto it = per_package.find(std::string(package_name)); it != per_package.end()) { return it->second; } return global; } + /** Return whether ``pkg_timestamp`` exceeds the effective cutoff for ``package_name``. */ auto ExcludeNewerPolicy::excludes(std::string_view package_name, std::uint64_t pkg_timestamp) const -> bool { @@ -365,6 +379,7 @@ namespace mamba return false; } + /** Resolve each entry in ``exclude_newer_package`` to a cutoff or exemption. */ auto resolve_exclude_newer_package_cutoffs( const std::map& exclude_newer_package, std::uint64_t now_seconds @@ -373,10 +388,7 @@ namespace mamba auto out = ExcludeNewerPackageCutoffs{}; for (const auto& [name, value] : exclude_newer_package) { - const auto trimmed = util::strip_if( - std::string_view{ value }, - [](char c) { return util::is_space(c); } - ); + const auto trimmed = util::strip_if(std::string_view{ value }, is_space); if (equals_ci(trimmed, "false")) { out.emplace(name, std::nullopt); diff --git a/libmamba/tests/src/core/test_exclude_newer.cpp b/libmamba/tests/src/core/test_exclude_newer.cpp index f81660465d..fe079c1abd 100644 --- a/libmamba/tests/src/core/test_exclude_newer.cpp +++ b/libmamba/tests/src/core/test_exclude_newer.cpp @@ -16,27 +16,52 @@ namespace { constexpr std::uint64_t now = 1'700'000'000; - SECTION("empty value is disabled") + SECTION("empty or whitespace-only values disable the policy") { REQUIRE(resolve_exclude_newer_cutoff("", now) == std::nullopt); REQUIRE(resolve_exclude_newer_cutoff(" ", now) == std::nullopt); } - SECTION("durations resolve relative to now") + SECTION("zero values use the current time as the cutoff") { - REQUIRE(resolve_exclude_newer_cutoff("7d", now) == now - 7 * 86400); - REQUIRE(resolve_exclude_newer_cutoff("P7D", now) == now - 7 * 86400); - REQUIRE(resolve_exclude_newer_cutoff("3600", now) == now - 3600); + REQUIRE(resolve_exclude_newer_cutoff("0", now) == now); REQUIRE(resolve_exclude_newer_cutoff("0d", now) == now); REQUIRE(resolve_exclude_newer_cutoff("P0D", now) == now); } + SECTION("plain integers are durations in seconds") + { + REQUIRE(resolve_exclude_newer_cutoff("3600", now) == now - 3600); + } + + SECTION("compact durations resolve relative to now") + { + REQUIRE(resolve_exclude_newer_cutoff("7d", now) == now - 7 * 86400); + REQUIRE(resolve_exclude_newer_cutoff("1w", now) == now - 604800); + REQUIRE(resolve_exclude_newer_cutoff("3d12h", now) == now - (3 * 86400 + 12 * 3600)); + } + + SECTION("ISO 8601 durations resolve relative to now") + { + REQUIRE(resolve_exclude_newer_cutoff("P7D", now) == now - 7 * 86400); + REQUIRE(resolve_exclude_newer_cutoff("PT24H", now) == now - 24 * 3600); + REQUIRE(resolve_exclude_newer_cutoff("P1DT12H", now) == now - (86400 + 12 * 3600)); + } + SECTION("date-only values use the start of the next UTC day") { REQUIRE(resolve_exclude_newer_cutoff("2026-04-01", now) == 1'775'088'000); } - SECTION("datetimes resolve to absolute instants") + SECTION("date-only values roll over at month and year boundaries") + { + REQUIRE(resolve_exclude_newer_cutoff("2026-01-31", now) == 1'769'904'000); // 2026-02-01 + REQUIRE(resolve_exclude_newer_cutoff("2025-02-28", now) == 1'740'787'200); // 2025-03-01 + REQUIRE(resolve_exclude_newer_cutoff("2024-02-29", now) == 1'709'251'200); // 2024-03-01 + REQUIRE(resolve_exclude_newer_cutoff("2025-12-31", now) == 1'767'225'600); // 2026-01-01 + } + + SECTION("RFC 3339 datetimes resolve to absolute UTC instants") { REQUIRE(resolve_exclude_newer_cutoff("2026-04-01T12:00:00", now) == 1'775'044'800); REQUIRE(resolve_exclude_newer_cutoff("2026-04-01T10:00:00Z", now) == 1'775'037'600); @@ -82,6 +107,12 @@ namespace }, }; + SECTION("unset policy is empty") + { + const ExcludeNewerPolicy unset{}; + REQUIRE(unset.empty()); + } + SECTION("unknown packages use the global cutoff") { REQUIRE(policy.cutoff_for("other-pkg") == global_cutoff); From 469745f9784394ab7f5a1022e0332cd130efc1aa Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 30 Jun 2026 16:35:57 +0200 Subject: [PATCH 18/41] Name and type `exclude_newer_*` parameters Signed-off-by: Julien Jerphanion --- libmamba/src/api/utils.cpp | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/libmamba/src/api/utils.cpp b/libmamba/src/api/utils.cpp index 80e74f3663..5f77c5f734 100644 --- a/libmamba/src/api/utils.cpp +++ b/libmamba/src/api/utils.cpp @@ -528,13 +528,24 @@ namespace mamba ) -> solver::libsolv::Database::Settings { const auto now = static_cast(std::time(nullptr)); + const auto matchspec_parser = experimental_matchspec_parsing + ? solver::libsolv::MatchSpecParser::Mamba + : solver::libsolv::MatchSpecParser::Libsolv; + const std::optional + exclude_newer_timestamp = exclude_newer_policy.exclude_newer.empty() + ? std::nullopt + : resolve_exclude_newer_cutoff( + exclude_newer_policy.exclude_newer, + now + ); + const ExcludeNewerPackageCutoffs exclude_newer_package = resolve_exclude_newer_package_cutoffs( + exclude_newer_policy.exclude_newer_package, + now + ); return { - experimental_matchspec_parsing ? solver::libsolv::MatchSpecParser::Mamba - : solver::libsolv::MatchSpecParser::Libsolv, - exclude_newer_policy.exclude_newer.empty() - ? std::nullopt - : resolve_exclude_newer_cutoff(exclude_newer_policy.exclude_newer, now), - resolve_exclude_newer_package_cutoffs(exclude_newer_policy.exclude_newer_package, now), + matchspec_parser, + exclude_newer_timestamp, + exclude_newer_package, }; } } // namespace From dde5a93d8b3f013cf5cd6f91399f7b8f6f4c0581 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 30 Jun 2026 17:00:05 +0200 Subject: [PATCH 19/41] Adapt and test ISO 8601 durations parsing Signed-off-by: Julien Jerphanion --- libmamba/include/mamba/core/exclude_newer.hpp | 14 ++ libmamba/src/core/exclude_newer.cpp | 145 ++++++++---------- .../tests/src/core/test_exclude_newer.cpp | 17 ++ 3 files changed, 98 insertions(+), 78 deletions(-) diff --git a/libmamba/include/mamba/core/exclude_newer.hpp b/libmamba/include/mamba/core/exclude_newer.hpp index bf8eea1dd9..2da5584771 100644 --- a/libmamba/include/mamba/core/exclude_newer.hpp +++ b/libmamba/include/mamba/core/exclude_newer.hpp @@ -7,6 +7,7 @@ #ifndef MAMBA_CORE_EXCLUDE_NEWER_HPP #define MAMBA_CORE_EXCLUDE_NEWER_HPP +#include #include #include #include @@ -128,6 +129,19 @@ namespace mamba [[nodiscard]] auto resolve_exclude_newer_cutoff(std::string_view value, std::uint64_t now_seconds) -> std::optional; + namespace detail + { + /** + * Parse an ISO 8601 duration (``P…Y…M…W…DT…H…M…S``) to seconds. + * + * Returns ``std::nullopt`` when ``value`` is not an ISO 8601 duration. + * + * @throws std::invalid_argument when the value starts with ``P`` but has no components. + */ + [[nodiscard]] auto parse_iso8601_duration_seconds(std::string_view value) + -> std::optional; + } + } // namespace mamba #endif diff --git a/libmamba/src/core/exclude_newer.cpp b/libmamba/src/core/exclude_newer.cpp index c58aabd4bb..f78dca8703 100644 --- a/libmamba/src/core/exclude_newer.cpp +++ b/libmamba/src/core/exclude_newer.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -168,83 +169,6 @@ namespace mamba return std::chrono::seconds{ static_cast(total) }; } - /** - * Parse an ISO 8601 duration such as ``P7D`` or ``P1DT12H``. - * - * @throws std::invalid_argument when the value starts with ``P`` but has no components. - */ - [[nodiscard]] auto parse_iso8601_duration_seconds(std::string_view value) - -> std::optional - { - if (value.empty() || std::tolower(static_cast(value.front())) != 'p') - { - return std::nullopt; - } - - auto remaining = value.substr(1); - std::uint64_t total = 0; - bool has_component = false; - - const auto consume_if_unit = [&](char unit, std::uint64_t multiplier) -> bool - { - std::size_t digits = 0; - while (digits < remaining.size() - && std::isdigit(static_cast(remaining[digits]))) - { - ++digits; - } - if (digits == 0) - { - return true; - } - if (remaining.size() < digits + 1) - { - return false; - } - if (std::tolower(static_cast(remaining[digits])) - != std::tolower(static_cast(unit))) - { - return true; - } - - const auto amount = parse_fixed_uint(remaining.substr(0, digits)); - if (!amount) - { - return false; - } - remaining.remove_prefix(digits + 1); - has_component = true; - total += *amount * multiplier; - return true; - }; - - if (!consume_if_unit('W', 604800) || !consume_if_unit('D', 86400)) - { - return std::nullopt; - } - - if (!remaining.empty() - && std::tolower(static_cast(remaining.front())) == 't') - { - remaining.remove_prefix(1); - if (!consume_if_unit('H', 3600) || !consume_if_unit('M', 60) - || !consume_if_unit('S', 1)) - { - return std::nullopt; - } - } - - if (!remaining.empty()) - { - return std::nullopt; - } - if (!has_component) - { - throw invalid_exclude_newer(value); - } - return std::chrono::seconds{ static_cast(total) }; - } - /** * Parse a duration string in any supported format. * @@ -257,7 +181,7 @@ namespace mamba { return seconds; } - if (auto seconds = parse_iso8601_duration_seconds(value)) + if (auto seconds = detail::parse_iso8601_duration_seconds(value)) { return seconds; } @@ -326,6 +250,71 @@ namespace mamba } // namespace + namespace detail + { + auto parse_iso8601_duration_seconds(std::string_view value) + -> std::optional + { + // P(n)Y(n)M(n)W(n)DT(n)H(n)M(n)S + static const std::regex iso8601_duration{ + R"(^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$)", + std::regex_constants::icase, + }; + + constexpr std::uint64_t seconds_per_minute = 60; + constexpr std::uint64_t seconds_per_hour = 3600; + constexpr std::uint64_t seconds_per_day = 86400; + constexpr std::uint64_t seconds_per_week = 604800; + constexpr std::uint64_t seconds_per_month = 30 * seconds_per_day; + constexpr std::uint64_t seconds_per_year = 365 * seconds_per_day; + + if (value.empty()) + { + return std::nullopt; + } + + std::match_results match; + if (!std::regex_match(value.begin(), value.end(), match, iso8601_duration)) + { + return std::nullopt; + } + + std::uint64_t total = 0; + bool has_component = false; + + const auto accumulate_component = [&](std::size_t group, std::uint64_t multiplier) -> bool + { + if (!match[group].matched) + { + return true; + } + const auto amount = parse_fixed_uint(match[group].str()); + if (!amount) + { + return false; + } + total += *amount * multiplier; + has_component = true; + return true; + }; + + if (!accumulate_component(1, seconds_per_year) + || !accumulate_component(2, seconds_per_month) + || !accumulate_component(3, seconds_per_week) + || !accumulate_component(4, seconds_per_day) + || !accumulate_component(5, seconds_per_hour) + || !accumulate_component(6, seconds_per_minute) || !accumulate_component(7, 1)) + { + return std::nullopt; + } + if (!has_component) + { + throw invalid_exclude_newer(value); + } + return std::chrono::seconds{ static_cast(total) }; + } + } // namespace detail + /** Resolve a global ``exclude_newer`` value; see ``resolve_exclude_newer_cutoff`` in the * header. */ auto resolve_exclude_newer_cutoff(std::string_view value, std::uint64_t now_seconds) diff --git a/libmamba/tests/src/core/test_exclude_newer.cpp b/libmamba/tests/src/core/test_exclude_newer.cpp index fe079c1abd..a31e2b0f07 100644 --- a/libmamba/tests/src/core/test_exclude_newer.cpp +++ b/libmamba/tests/src/core/test_exclude_newer.cpp @@ -46,6 +46,13 @@ namespace REQUIRE(resolve_exclude_newer_cutoff("P7D", now) == now - 7 * 86400); REQUIRE(resolve_exclude_newer_cutoff("PT24H", now) == now - 24 * 3600); REQUIRE(resolve_exclude_newer_cutoff("P1DT12H", now) == now - (86400 + 12 * 3600)); + REQUIRE(resolve_exclude_newer_cutoff("P1Y", now) == now - 365 * 86400); + REQUIRE(resolve_exclude_newer_cutoff("P6M", now) == now - 6 * 30 * 86400); + REQUIRE(resolve_exclude_newer_cutoff("PT1M", now) == now - 60); + REQUIRE( + resolve_exclude_newer_cutoff("P3Y6M4DT12H30M5S", now) + == now - (3 * 365 * 86400 + 6 * 30 * 86400 + 4 * 86400 + 12 * 3600 + 30 * 60 + 5) + ); } SECTION("date-only values use the start of the next UTC day") @@ -75,6 +82,16 @@ namespace } } + TEST_CASE("parse_iso8601_duration_seconds") + { + SECTION("malformed durations are rejected") + { + REQUIRE(detail::parse_iso8601_duration_seconds("P3Y6M4D12H30M5S") == std::nullopt); + REQUIRE(detail::parse_iso8601_duration_seconds("3Y6M4DT12H30M5S") == std::nullopt); + REQUIRE(detail::parse_iso8601_duration_seconds("12H30M5S") == std::nullopt); + } + } + TEST_CASE("resolve_exclude_newer_package_cutoffs") { constexpr std::uint64_t now = 1'700'000'000; From 4ad4d33a1a6d11ea11f4efbf76efaadbfb4ddfc6 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 30 Jun 2026 17:05:06 +0200 Subject: [PATCH 20/41] Adapt and test compact duration parsing Signed-off-by: Julien Jerphanion --- libmamba/include/mamba/core/exclude_newer.hpp | 9 ++ libmamba/src/core/exclude_newer.cpp | 140 ++++++++---------- .../tests/src/core/test_exclude_newer.cpp | 95 ++++++++++++ 3 files changed, 168 insertions(+), 76 deletions(-) diff --git a/libmamba/include/mamba/core/exclude_newer.hpp b/libmamba/include/mamba/core/exclude_newer.hpp index 2da5584771..acb4e59d0f 100644 --- a/libmamba/include/mamba/core/exclude_newer.hpp +++ b/libmamba/include/mamba/core/exclude_newer.hpp @@ -140,6 +140,15 @@ namespace mamba */ [[nodiscard]] auto parse_iso8601_duration_seconds(std::string_view value) -> std::optional; + + /** + * Parse a compact duration (``(n)y(n)M(n)w(n)d(n)h(n)m(n)s``, e.g. ``7d``, ``3d12h``) + * to seconds. + * + * Returns ``std::nullopt`` when ``value`` is not a compact duration. + */ + [[nodiscard]] auto parse_compact_duration_seconds(std::string_view value) + -> std::optional; } } // namespace mamba diff --git a/libmamba/src/core/exclude_newer.cpp b/libmamba/src/core/exclude_newer.cpp index f78dca8703..4004b2a641 100644 --- a/libmamba/src/core/exclude_newer.cpp +++ b/libmamba/src/core/exclude_newer.cpp @@ -71,27 +71,6 @@ namespace mamba ); } - /** - * Parse a leading unsigned integer from ``value`` and advance past the consumed digits. - * - * Used by compact duration parsing (e.g. ``7d``). - */ - [[nodiscard]] auto parse_uint_prefix(std::string_view& value) -> std::optional - { - if (value.empty() || !std::isdigit(static_cast(value.front()))) - { - return std::nullopt; - } - std::uint64_t number = 0; - const auto [ptr, ec] = std::from_chars(value.data(), value.data() + value.size(), number); - if (ec != std::errc()) - { - return std::nullopt; - } - value.remove_prefix(static_cast(ptr - value.data())); - return number; - } - /** Parse ``value`` as an unsigned integer that must consume the entire string. */ [[nodiscard]] auto parse_fixed_uint(std::string_view value) -> std::optional { @@ -115,60 +94,6 @@ namespace mamba return std::nullopt; } - /** Parse a compact duration such as ``7d``, ``3d12h``, ``1w``, or ``30s``. */ - [[nodiscard]] auto parse_compact_duration_seconds(std::string_view value) - -> std::optional - { - auto remaining = value; - std::uint64_t total = 0; - bool has_component = false; - - while (!remaining.empty()) - { - const auto amount = parse_uint_prefix(remaining); - if (!amount) - { - return std::nullopt; - } - remaining = util::lstrip_if(remaining, is_space); - if (remaining.empty()) - { - return std::nullopt; - } - - std::uint64_t multiplier = 0; - switch (std::tolower(static_cast(remaining.front()))) - { - case 'w': - multiplier = 604800; - break; - case 'd': - multiplier = 86400; - break; - case 'h': - multiplier = 3600; - break; - case 'm': - multiplier = 60; - break; - case 's': - multiplier = 1; - break; - default: - return std::nullopt; - } - remaining.remove_prefix(1); - has_component = true; - total += *amount * multiplier; - } - - if (!has_component) - { - return std::nullopt; - } - return std::chrono::seconds{ static_cast(total) }; - } - /** * Parse a duration string in any supported format. * @@ -185,7 +110,7 @@ namespace mamba { return seconds; } - return parse_compact_duration_seconds(value); + return detail::parse_compact_duration_seconds(value); } /** @@ -313,6 +238,69 @@ namespace mamba } return std::chrono::seconds{ static_cast(total) }; } + + auto parse_compact_duration_seconds(std::string_view value) + -> std::optional + { + // (n)y(n)M(n)w(n)d(n)h(n)m(n)s — lowercase units except M for months. + static const std::regex compact_duration{ R"(^(\d+[yMwdhms])+$)" }; + static const std::regex compact_segment{ R"((\d+)([yMwdhms]))" }; + + constexpr std::uint64_t seconds_per_minute = 60; + constexpr std::uint64_t seconds_per_hour = 3600; + constexpr std::uint64_t seconds_per_day = 86400; + constexpr std::uint64_t seconds_per_week = 604800; + constexpr std::uint64_t seconds_per_month = 30 * seconds_per_day; + constexpr std::uint64_t seconds_per_year = 365 * seconds_per_day; + + if (value.empty() || !std::regex_match(value.begin(), value.end(), compact_duration)) + { + return std::nullopt; + } + + std::uint64_t total = 0; + const std::string input(value); + const std::sregex_iterator end; + for (std::sregex_iterator it(input.begin(), input.end(), compact_segment); it != end; ++it) + { + const auto amount = parse_fixed_uint((*it)[1].str()); + if (!amount) + { + return std::nullopt; + } + + std::uint64_t multiplier = 0; + switch ((*it)[2].str().front()) + { + case 'y': + multiplier = seconds_per_year; + break; + case 'M': + multiplier = seconds_per_month; + break; + case 'w': + multiplier = seconds_per_week; + break; + case 'd': + multiplier = seconds_per_day; + break; + case 'h': + multiplier = seconds_per_hour; + break; + case 'm': + multiplier = seconds_per_minute; + break; + case 's': + multiplier = 1; + break; + default: + return std::nullopt; + } + total += *amount * multiplier; + } + + return std::chrono::seconds{ static_cast(total) }; + } } // namespace detail /** Resolve a global ``exclude_newer`` value; see ``resolve_exclude_newer_cutoff`` in the diff --git a/libmamba/tests/src/core/test_exclude_newer.cpp b/libmamba/tests/src/core/test_exclude_newer.cpp index a31e2b0f07..8947af435c 100644 --- a/libmamba/tests/src/core/test_exclude_newer.cpp +++ b/libmamba/tests/src/core/test_exclude_newer.cpp @@ -39,6 +39,12 @@ namespace REQUIRE(resolve_exclude_newer_cutoff("7d", now) == now - 7 * 86400); REQUIRE(resolve_exclude_newer_cutoff("1w", now) == now - 604800); REQUIRE(resolve_exclude_newer_cutoff("3d12h", now) == now - (3 * 86400 + 12 * 3600)); + REQUIRE(resolve_exclude_newer_cutoff("1y", now) == now - 365 * 86400); + REQUIRE(resolve_exclude_newer_cutoff("6M", now) == now - 6 * 30 * 86400); + REQUIRE( + resolve_exclude_newer_cutoff("1y6M7d", now) + == now - (365 * 86400 + 6 * 30 * 86400 + 7 * 86400) + ); } SECTION("ISO 8601 durations resolve relative to now") @@ -92,6 +98,95 @@ namespace } } + TEST_CASE("parse_compact_duration_seconds") + { + constexpr std::uint64_t y = 365 * 86400; + constexpr std::uint64_t mon = 30 * 86400; + constexpr std::uint64_t w = 604800; + constexpr std::uint64_t d = 86400; + constexpr std::uint64_t h = 3600; + constexpr std::uint64_t min = 60; + + const auto sec = [](std::int64_t n) { return std::chrono::seconds{ n }; }; + const auto parse = [](std::string_view value) + { return detail::parse_compact_duration_seconds(value); }; + + SECTION("each unit suffix is parsed on its own") + { + REQUIRE(parse("1y") == sec(static_cast(y))); + REQUIRE(parse("2M") == sec(static_cast(2 * mon))); + REQUIRE(parse("3w") == sec(static_cast(3 * w))); + REQUIRE(parse("4d") == sec(static_cast(4 * d))); + REQUIRE(parse("5h") == sec(static_cast(5 * h))); + REQUIRE(parse("6m") == sec(static_cast(6 * min))); + REQUIRE(parse("7s") == sec(7)); + } + + SECTION("zero amounts are valid") + { + REQUIRE(parse("0y") == sec(0)); + REQUIRE(parse("0M") == sec(0)); + REQUIRE(parse("0w") == sec(0)); + REQUIRE(parse("0d") == sec(0)); + REQUIRE(parse("0h") == sec(0)); + REQUIRE(parse("0m") == sec(0)); + REQUIRE(parse("0s") == sec(0)); + REQUIRE(parse("0y0M0w0d0h0m0s") == sec(0)); + } + + SECTION("multiple segments are summed") + { + REQUIRE(parse("3d12h") == sec(static_cast(3 * d + 12 * h))); + REQUIRE(parse("1w2d") == sec(static_cast(w + 2 * d))); + REQUIRE(parse("1y6M7d") == sec(static_cast(y + 6 * mon + 7 * d))); + REQUIRE( + parse("1y2M3w4d5h6m7s") + == sec(static_cast(y + 2 * mon + 3 * w + 4 * d + 5 * h + 6 * min + 7)) + ); + } + + SECTION("minutes and months are distinguished by case") + { + REQUIRE(parse("30m") == sec(30 * min)); + REQUIRE(parse("30M") == sec(static_cast(30 * mon))); + REQUIRE(parse("1m2M") == sec(static_cast(min + 2 * mon))); + } + + SECTION("unit letters are case-sensitive") + { + REQUIRE(parse("7D") == std::nullopt); + REQUIRE(parse("1Y") == std::nullopt); + REQUIRE(parse("1W") == std::nullopt); + REQUIRE(parse("12H") == std::nullopt); + REQUIRE(parse("30S") == std::nullopt); + REQUIRE(parse("6m") == sec(6 * min)); + REQUIRE(parse("6M") == sec(static_cast(6 * mon))); + } + + SECTION("incomplete or malformed compact durations are rejected") + { + REQUIRE(parse("") == std::nullopt); + REQUIRE(parse("7") == std::nullopt); + REQUIRE(parse("7 ") == std::nullopt); + REQUIRE(parse("d7") == std::nullopt); + REQUIRE(parse("7d12") == std::nullopt); + REQUIRE(parse("7d12x") == std::nullopt); + REQUIRE(parse("-1d") == std::nullopt); + REQUIRE(parse("1.5d") == std::nullopt); + REQUIRE(parse("1x") == std::nullopt); + REQUIRE(parse("not-a-duration") == std::nullopt); + } + + SECTION("values handled by other parsers are not compact durations") + { + REQUIRE(parse("3600") == std::nullopt); + REQUIRE(parse("0") == std::nullopt); + REQUIRE(parse("P7D") == std::nullopt); + REQUIRE(parse("PT1H") == std::nullopt); + REQUIRE(parse("2026-04-01") == std::nullopt); + } + } + TEST_CASE("resolve_exclude_newer_package_cutoffs") { constexpr std::uint64_t now = 1'700'000'000; From 1d82cd54d375515532c1db7f9633ce0b26607674 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 30 Jun 2026 17:08:18 +0200 Subject: [PATCH 21/41] Throw `mamba_error` when a `exclude_newer` string is unparseable Signed-off-by: Julien Jerphanion --- libmamba/include/mamba/core/exclude_newer.hpp | 14 +++--- libmamba/src/core/exclude_newer.cpp | 45 ++++++++++++++----- .../tests/src/core/test_exclude_newer.cpp | 13 ++++-- 3 files changed, 52 insertions(+), 20 deletions(-) diff --git a/libmamba/include/mamba/core/exclude_newer.hpp b/libmamba/include/mamba/core/exclude_newer.hpp index acb4e59d0f..97a069f89d 100644 --- a/libmamba/include/mamba/core/exclude_newer.hpp +++ b/libmamba/include/mamba/core/exclude_newer.hpp @@ -96,7 +96,7 @@ namespace mamba * ``false``). * @param now_seconds Reference time for relative durations, in Unix seconds. * - * @throws std::invalid_argument when a non-``false`` value cannot be parsed. + * @throws mamba_error when a non-``false`` value cannot be parsed. */ [[nodiscard]] auto resolve_exclude_newer_package_cutoffs( const std::map& exclude_newer_package, @@ -121,13 +121,17 @@ namespace mamba * * @param value Raw configuration string. * @param now_seconds Reference time for relative durations, in Unix seconds. + * @param package_name When resolving a per-package override, used in parse-failure warnings. * * Returns ``std::nullopt`` when ``value`` is empty/whitespace-only. * - * @throws std::invalid_argument when the value cannot be parsed. + * @throws mamba_error when the value cannot be parsed. */ - [[nodiscard]] auto resolve_exclude_newer_cutoff(std::string_view value, std::uint64_t now_seconds) - -> std::optional; + [[nodiscard]] auto resolve_exclude_newer_cutoff( + std::string_view value, + std::uint64_t now_seconds, + std::string_view package_name = {} + ) -> std::optional; namespace detail { @@ -136,7 +140,7 @@ namespace mamba * * Returns ``std::nullopt`` when ``value`` is not an ISO 8601 duration. * - * @throws std::invalid_argument when the value starts with ``P`` but has no components. + * @throws mamba_error when the value starts with ``P`` but has no components. */ [[nodiscard]] auto parse_iso8601_duration_seconds(std::string_view value) -> std::optional; diff --git a/libmamba/src/core/exclude_newer.cpp b/libmamba/src/core/exclude_newer.cpp index 4004b2a641..35874dbf20 100644 --- a/libmamba/src/core/exclude_newer.cpp +++ b/libmamba/src/core/exclude_newer.cpp @@ -13,6 +13,7 @@ #include #include +#include "mamba/core/error_handling.hpp" #include "mamba/core/exclude_newer.hpp" #include "mamba/util/string.hpp" @@ -62,13 +63,27 @@ namespace mamba ); } - /** Build a ``std::invalid_argument`` for an unparsable ``exclude_newer`` value. */ - [[nodiscard]] auto invalid_exclude_newer(std::string_view value) -> std::invalid_argument + /** Throw when an ``exclude_newer`` value could not be parsed. */ + [[noreturn]] void + throw_invalid_exclude_newer(std::string_view value, std::string_view package_name = {}) { - return std::invalid_argument( - "Invalid exclude_newer value '" + std::string(value) - + "'; use e.g. 7d, P7D, 2026-04-01, or 2026-04-01T12:00:00Z" - ); + constexpr auto duration_hint = "expected a compact duration (e.g. 7d, 3d12h, 1w, 1y, 6M), an ISO 8601 duration " + "(e.g. P7D, PT24H, P1DT12H), a plain integer in seconds (e.g. 3600), a date " + "(e.g. 2026-04-01), or a datetime (e.g. 2026-04-01T12:00:00Z)"; + + std::string message; + if (package_name.empty()) + { + message = "Could not parse exclude_newer value '" + std::string(value) + "'; " + + duration_hint; + } + else + { + message = "Could not parse exclude_newer_package value for package '" + + std::string(package_name) + "' ('" + std::string(value) + "'); " + + duration_hint + ", or false"; + } + throw mamba_error(std::move(message), mamba_error_code::incorrect_usage); } /** Parse ``value`` as an unsigned integer that must consume the entire string. */ @@ -234,7 +249,7 @@ namespace mamba } if (!has_component) { - throw invalid_exclude_newer(value); + throw_invalid_exclude_newer(value); } return std::chrono::seconds{ static_cast(total) }; } @@ -305,12 +320,20 @@ namespace mamba /** Resolve a global ``exclude_newer`` value; see ``resolve_exclude_newer_cutoff`` in the * header. */ - auto resolve_exclude_newer_cutoff(std::string_view value, std::uint64_t now_seconds) - -> std::optional + auto resolve_exclude_newer_cutoff( + std::string_view value, + std::uint64_t now_seconds, + std::string_view package_name + ) -> std::optional { + const auto raw = value; value = util::strip_if(value, is_space); if (value.empty()) { + if (!raw.empty()) + { + throw_invalid_exclude_newer(raw, package_name); + } return std::nullopt; } @@ -331,7 +354,7 @@ namespace mamba return to_unix_seconds(*instant); } - throw invalid_exclude_newer(value); + throw_invalid_exclude_newer(value, package_name); } /** Return the per-package or global cutoff for ``package_name``. */ @@ -372,7 +395,7 @@ namespace mamba } else { - out.emplace(name, resolve_exclude_newer_cutoff(trimmed, now_seconds)); + out.emplace(name, resolve_exclude_newer_cutoff(trimmed, now_seconds, name)); } } return out; diff --git a/libmamba/tests/src/core/test_exclude_newer.cpp b/libmamba/tests/src/core/test_exclude_newer.cpp index 8947af435c..c2624111eb 100644 --- a/libmamba/tests/src/core/test_exclude_newer.cpp +++ b/libmamba/tests/src/core/test_exclude_newer.cpp @@ -6,6 +6,7 @@ #include +#include "mamba/core/error_handling.hpp" #include "mamba/core/exclude_newer.hpp" using namespace mamba; @@ -16,10 +17,14 @@ namespace { constexpr std::uint64_t now = 1'700'000'000; - SECTION("empty or whitespace-only values disable the policy") + SECTION("empty values disable the policy") { REQUIRE(resolve_exclude_newer_cutoff("", now) == std::nullopt); - REQUIRE(resolve_exclude_newer_cutoff(" ", now) == std::nullopt); + } + + SECTION("whitespace-only values cannot be parsed") + { + REQUIRE_THROWS_AS(resolve_exclude_newer_cutoff(" ", now), mamba_error); } SECTION("zero values use the current time as the cutoff") @@ -83,8 +88,8 @@ namespace SECTION("invalid values throw") { - REQUIRE_THROWS_AS(resolve_exclude_newer_cutoff("not-a-duration", now), std::invalid_argument); - REQUIRE_THROWS_AS(resolve_exclude_newer_cutoff("P", now), std::invalid_argument); + REQUIRE_THROWS_AS(resolve_exclude_newer_cutoff("not-a-duration", now), mamba_error); + REQUIRE_THROWS_AS(resolve_exclude_newer_cutoff("P", now), mamba_error); } } From 3406c795a0f3e184cba797aa6220ab6dd6fe9668 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Wed, 1 Jul 2026 09:35:14 +0200 Subject: [PATCH 22/41] Fall back on `HowardHinnant/date` for macOS Signed-off-by: Julien Jerphanion --- .github/workflows/brew.yml | 1 + dev/environment-dev.yml | 1 + libmamba/CMakeLists.txt | 49 ++++++++++++++++ .../mamba/core/detail/chrono_parse.hpp | 46 +++++++++++++++ libmamba/include/mamba/core/exclude_newer.hpp | 6 +- libmamba/libmambaConfig.cmake.in | 4 ++ libmamba/src/core/exclude_newer.cpp | 28 ++-------- .../tests/src/core/test_exclude_newer.cpp | 56 +++++++++++++++++++ 8 files changed, 167 insertions(+), 24 deletions(-) create mode 100644 libmamba/include/mamba/core/detail/chrono_parse.hpp diff --git a/.github/workflows/brew.yml b/.github/workflows/brew.yml index 101578573f..3840346411 100644 --- a/.github/workflows/brew.yml +++ b/.github/workflows/brew.yml @@ -19,6 +19,7 @@ jobs: brew install --overwrite fmt libarchive libsolv lz4 openssl@3 reproc simdjson xz yaml-cpp zstd cli11 nlohmann-json spdlog tl-expected pkgconfig python msgpack + howard-hinnant-date - name: Configure to build mamba run: > diff --git a/dev/environment-dev.yml b/dev/environment-dev.yml index 3fe56f778b..6eb24e7bae 100644 --- a/dev/environment-dev.yml +++ b/dev/environment-dev.yml @@ -21,6 +21,7 @@ dependencies: - libmsgpack-c - nlohmann_json - reproc-cpp >=14.2.4.post0 + - sel(osx): howardhinnant_date - simdjson >=3.3.0 - spdlog >=1.16.0 - yaml-cpp >=0.8.0 diff --git a/libmamba/CMakeLists.txt b/libmamba/CMakeLists.txt index 4820e8c1af..6789f8dacb 100644 --- a/libmamba/CMakeLists.txt +++ b/libmamba/CMakeLists.txt @@ -9,6 +9,44 @@ cmake_policy(SET CMP0025 NEW) # Introduced in cmake 3.0 cmake_policy(SET CMP0077 NEW) # Introduced in cmake 3.13 project(libmamba) +include(CheckCXXSourceCompiles) + +# std::chrono::parse (P0355) is not yet available on all platforms (e.g. libc++ on macOS). +# https://github.com/llvm/llvm-project/issues/166051 +if(NOT DEFINED MAMBA_HAVE_STD_CHRONO_PARSE) + set(CMAKE_REQUIRED_FLAGS_BACKUP "${CMAKE_REQUIRED_FLAGS}") + if(MSVC) + list(APPEND CMAKE_REQUIRED_FLAGS "/std:c++20") + else() + list(APPEND CMAKE_REQUIRED_FLAGS "-std=c++20") + endif() + check_cxx_source_compiles( + " +#include +#include +int main() +{ + std::istringstream is{ \"2020-01-01\" }; + std::chrono::sys_days d{}; + is >> std::chrono::parse( \"%F\", d ); + return is.fail() ? 1 : 0; +} +" + MAMBA_HAVE_STD_CHRONO_PARSE + ) + set(CMAKE_REQUIRED_FLAGS "${CMAKE_REQUIRED_FLAGS_BACKUP}") +endif() + +if(MAMBA_HAVE_STD_CHRONO_PARSE) + message(STATUS "libmamba: using std::chrono::parse for date/datetime parsing") +else() + find_package(date CONFIG REQUIRED) + message( + STATUS + "libmamba: using Howard Hinnant date for date/datetime parsing (std::chrono::parse unavailable)" + ) +endif() + set(LIBMAMBA_INCLUDE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/include) set(LIBMAMBA_SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src) set(LIBMAMBA_DATA_DIR ${CMAKE_CURRENT_SOURCE_DIR}/data) @@ -738,6 +776,11 @@ macro(libmamba_create_target target_name linkage output_name) target_link_libraries(${target_name} PUBLIC Threads::Threads) endif() + if(NOT MAMBA_HAVE_STD_CHRONO_PARSE) + target_compile_definitions(${target_name} PUBLIC MAMBA_USE_HOWARD_HINNANT_DATE) + target_link_libraries(${target_name} PUBLIC date::date date::date-tz) + endif() + list(APPEND libmamba_targets ${target_name}) add_library(mamba::${target_name} ALIAS ${target_name}) endmacro() @@ -814,6 +857,12 @@ install( PATTERN "*.h" ) +if(NOT MAMBA_HAVE_STD_CHRONO_PARSE) + set(LIBMAMBA_USE_HOWARD_HINNANT_DATE ON) +else() + set(LIBMAMBA_USE_HOWARD_HINNANT_DATE OFF) +endif() + # Configure 'mambaConfig.cmake' for a build tree set(MAMBA_CONFIG_CODE "####### Expanded from \@MAMBA_CONFIG_CODE\@ #######\n") set( diff --git a/libmamba/include/mamba/core/detail/chrono_parse.hpp b/libmamba/include/mamba/core/detail/chrono_parse.hpp new file mode 100644 index 0000000000..2862d81860 --- /dev/null +++ b/libmamba/include/mamba/core/detail/chrono_parse.hpp @@ -0,0 +1,46 @@ +// 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. + +#pragma once + +#include +#include +#include +#include +#include + +#if defined(MAMBA_USE_HOWARD_HINNANT_DATE) +#include +#include +#endif + +namespace mamba::detail +{ + /** + * Parse ``value`` with ``std::chrono::parse`` or ``date::from_stream`` using ``fmt``. + * + * When ``MAMBA_USE_HOWARD_HINNANT_DATE`` is set (libc++ lacks P0355; + * https://github.com/llvm/llvm-project/issues/166051), ``date::from_stream`` is used. + * + * Returns ``std::nullopt`` when parsing fails or trailing characters remain. + */ + template + [[nodiscard]] auto parse_chrono(std::string_view value, const char* fmt) -> std::optional + { + std::istringstream stream{ std::string(value) }; + T out{}; +#if defined(MAMBA_USE_HOWARD_HINNANT_DATE) + date::from_stream(stream, fmt, out); +#else + stream >> std::chrono::parse(fmt, out); +#endif + if (stream.fail() || stream.peek() != std::istringstream::traits_type::eof()) + { + return std::nullopt; + } + return out; + } +} // namespace mamba::detail diff --git a/libmamba/include/mamba/core/exclude_newer.hpp b/libmamba/include/mamba/core/exclude_newer.hpp index 97a069f89d..93a94343a2 100644 --- a/libmamba/include/mamba/core/exclude_newer.hpp +++ b/libmamba/include/mamba/core/exclude_newer.hpp @@ -109,7 +109,9 @@ namespace mamba * * The public API exposes ``std::uint64_t`` seconds for compatibility with repodata * timestamps. Internally, date and datetime values are parsed with - * ``std::chrono::parse`` and converted at this boundary. Duration strings + * ``std::chrono::parse`` when the standard library provides it, otherwise with + * Howard Hinnant's ``date`` library until libc++ implements P0355 + * (https://github.com/llvm/llvm-project/issues/166051). Duration strings * (``7d``, ``P7D``, plain seconds) use custom parsers because the standard library * does not provide ISO 8601 duration parsing. * @@ -157,4 +159,6 @@ namespace mamba } // namespace mamba +#include "mamba/core/detail/chrono_parse.hpp" + #endif diff --git a/libmamba/libmambaConfig.cmake.in b/libmamba/libmambaConfig.cmake.in index 8c82060d6a..25702c3bda 100644 --- a/libmamba/libmambaConfig.cmake.in +++ b/libmamba/libmambaConfig.cmake.in @@ -28,6 +28,10 @@ find_dependency(nlohmann_json) find_dependency(yaml-cpp) find_dependency(reproc++) +if(@LIBMAMBA_USE_HOWARD_HINNANT_DATE@) + find_dependency(date) +endif() + if(NOT (TARGET libmamba-dyn OR TARGET libmamba-static)) include("${CMAKE_CURRENT_LIST_DIR}/@PROJECT_NAME@Targets.cmake") diff --git a/libmamba/src/core/exclude_newer.cpp b/libmamba/src/core/exclude_newer.cpp index 35874dbf20..8ba362cb81 100644 --- a/libmamba/src/core/exclude_newer.cpp +++ b/libmamba/src/core/exclude_newer.cpp @@ -9,10 +9,10 @@ #include #include #include -#include #include #include +#include "mamba/core/detail/chrono_parse.hpp" #include "mamba/core/error_handling.hpp" #include "mamba/core/exclude_newer.hpp" #include "mamba/util/string.hpp" @@ -33,24 +33,6 @@ namespace mamba return static_cast(instant.time_since_epoch().count()); } - /** - * Parse ``value`` with ``std::chrono::parse`` using ``fmt``. - * - * Returns ``std::nullopt`` when parsing fails or trailing characters remain. - */ - template - [[nodiscard]] auto parse_chrono(std::string_view value, const char* fmt) -> std::optional - { - std::istringstream stream{ std::string(value) }; - T out{}; - stream >> std::chrono::parse(fmt, out); - if (stream.fail() || stream.peek() != std::istringstream::traits_type::eof()) - { - return std::nullopt; - } - return out; - } - /** Case-insensitive equality for ASCII strings. */ [[nodiscard]] auto equals_ci(std::string_view value, std::string_view expected) -> bool { @@ -140,7 +122,7 @@ namespace mamba return std::nullopt; } - const auto day = parse_chrono(value, "%F"); + const auto day = detail::parse_chrono(value, "%F"); if (!day) { return std::nullopt; @@ -162,15 +144,15 @@ namespace mamba return std::nullopt; } - if (auto instant = parse_chrono(value, "%FT%T%Ez")) + if (auto instant = detail::parse_chrono(value, "%FT%T%Ez")) { return CutoffInstant{ instant->time_since_epoch() }; } - if (auto instant = parse_chrono(value, "%FT%TZ")) + if (auto instant = detail::parse_chrono(value, "%FT%TZ")) { return CutoffInstant{ instant->time_since_epoch() }; } - if (auto instant = parse_chrono(value, "%FT%T")) + if (auto instant = detail::parse_chrono(value, "%FT%T")) { return CutoffInstant{ instant->time_since_epoch() }; } diff --git a/libmamba/tests/src/core/test_exclude_newer.cpp b/libmamba/tests/src/core/test_exclude_newer.cpp index c2624111eb..7ca4fd7808 100644 --- a/libmamba/tests/src/core/test_exclude_newer.cpp +++ b/libmamba/tests/src/core/test_exclude_newer.cpp @@ -93,6 +93,62 @@ namespace } } + TEST_CASE("parse_chrono") + { + using SysDays = std::chrono::sys_days; + using SysTime = std::chrono::sys_time; + + const auto day_epoch = [](std::string_view value) -> std::optional + { + const auto parsed = detail::parse_chrono(value, "%F"); + if (!parsed) + { + return std::nullopt; + } + return parsed->time_since_epoch().count(); + }; + + const auto time_epoch = [](std::string_view value, + const char* fmt) -> std::optional + { + const auto parsed = detail::parse_chrono(value, fmt); + if (!parsed) + { + return std::nullopt; + } + return std::chrono::duration_cast(parsed->time_since_epoch()).count(); + }; + + SECTION("date-only values parse with %F") + { + REQUIRE(day_epoch("2026-04-01") == 20'544); + REQUIRE(day_epoch("2026-01-31") == 20'484); + REQUIRE(day_epoch("2024-02-29") == 19'782); + } + + SECTION("RFC 3339 datetimes parse to UTC instants") + { + REQUIRE(time_epoch("2026-04-01T12:00:00", "%FT%T") == 1'775'044'800); + REQUIRE(time_epoch("2026-04-01T10:00:00Z", "%FT%TZ") == 1'775'037'600); + REQUIRE(time_epoch("2026-04-01T12:00:00+02:00", "%FT%T%Ez") == 1'775'037'600); + } + + SECTION("invalid values are rejected") + { + REQUIRE(day_epoch("") == std::nullopt); + REQUIRE(day_epoch("2026/04/01") == std::nullopt); + REQUIRE(day_epoch("not-a-date") == std::nullopt); + REQUIRE(day_epoch("2026-04-01T12:00:00") == std::nullopt); + REQUIRE(day_epoch("2026-04-01 extra") == std::nullopt); + + REQUIRE(time_epoch("", "%FT%T") == std::nullopt); + REQUIRE(time_epoch("2026-04-01", "%FT%T") == std::nullopt); + REQUIRE(time_epoch("2026-04-01T12:00:00", "%FT%TZ") == std::nullopt); + REQUIRE(time_epoch("2026-04-01T12:00:00extra", "%FT%T") == std::nullopt); + REQUIRE(time_epoch("2026-04-01T12:00:00+0200", "%FT%T%Ez") == std::nullopt); + } + } + TEST_CASE("parse_iso8601_duration_seconds") { SECTION("malformed durations are rejected") From e89a730490d8d01b0546bca424bb42d9f9991267 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Wed, 1 Jul 2026 11:29:21 +0200 Subject: [PATCH 23/41] Add `howardhinnant_date` as a host dep for macOS micromamba builds Signed-off-by: Julien Jerphanion --- .github/workflows/static_build.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/static_build.yml b/.github/workflows/static_build.yml index 9a872c71cf..c0b718eb7f 100644 --- a/.github/workflows/static_build.yml +++ b/.github/workflows/static_build.yml @@ -85,6 +85,23 @@ jobs: ) ) PY + - name: Add howardhinnant_date host dependency for osx + if: ${{ matrix.platform == 'osx' }} + run: | + cd micromamba-feedstock/ + python3 - <<'PY' + from pathlib import Path + meta = Path("recipe/meta.yaml") + lines = meta.read_text().splitlines() + if any("howardhinnant_date" in line for line in lines): + raise SystemExit(0) + out = [] + for line in lines: + out.append(line) + if line.strip().startswith("- lz4-c-static"): + out.append(" - howardhinnant_date") + meta.write_text("\n".join(out) + "\n") + PY - name: Checkout mamba branch uses: actions/checkout@v7 with: From 71f2d5fb26c9d707e49c0d86edbeb2e6904cfe80 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Wed, 1 Jul 2026 11:30:30 +0200 Subject: [PATCH 24/41] Require `msvcp140_atomic_wait.dll` at runtime for micromamba on Windows Signed-off-by: Julien Jerphanion --- dev/micromamba_windows_allowed_dlls.tsv | 1 + 1 file changed, 1 insertion(+) diff --git a/dev/micromamba_windows_allowed_dlls.tsv b/dev/micromamba_windows_allowed_dlls.tsv index 70b6076a66..8679789346 100644 --- a/dev/micromamba_windows_allowed_dlls.tsv +++ b/dev/micromamba_windows_allowed_dlls.tsv @@ -26,5 +26,6 @@ api-ms-win-crt-string-l1-1-0.dll UCRT (ucrt) UCRT forwarder: C string and memory api-ms-win-crt-time-l1-1-0.dll UCRT (ucrt) UCRT forwarder: time and date (time, localtime, strftime, …). api-ms-win-crt-utility-l1-1-0.dll UCRT (ucrt) UCRT forwarder: utility routines (qsort, bsearch, system, …). MSVCP140.dll MSVC runtime (vc14_runtime) Microsoft C++ standard library runtime (/MD builds). +msvcp140_atomic_wait.dll MSVC runtime (vc14_runtime) C++20 std::atomic wait/notify from the MSVC STL; required at runtime by std::chrono::parse (exclude_newer date/datetime parsing). VCRUNTIME140.dll MSVC runtime (vc14_runtime) Microsoft C runtime helpers (exceptions, EH scaffolding). VCRUNTIME140_1.dll MSVC runtime (vc14_runtime) Additional MSVC C++ exception-handling support on x64. From 5e7df6271df74a70830aa85a2f1db0ef626d575a Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Mon, 20 Jul 2026 10:37:42 +0200 Subject: [PATCH 25/41] Use `howardhinnant_date-static` for macOS micromamba builds Signed-off-by: Julien Jerphanion --- .github/workflows/static_build.yml | 6 +++--- dev/environment-micromamba-static.yml | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/static_build.yml b/.github/workflows/static_build.yml index c0b718eb7f..31cf9addf4 100644 --- a/.github/workflows/static_build.yml +++ b/.github/workflows/static_build.yml @@ -85,7 +85,7 @@ jobs: ) ) PY - - name: Add howardhinnant_date host dependency for osx + - name: Add howardhinnant_date-static host dependency for osx if: ${{ matrix.platform == 'osx' }} run: | cd micromamba-feedstock/ @@ -93,13 +93,13 @@ jobs: from pathlib import Path meta = Path("recipe/meta.yaml") lines = meta.read_text().splitlines() - if any("howardhinnant_date" in line for line in lines): + if any("howardhinnant_date-static" in line for line in lines): raise SystemExit(0) out = [] for line in lines: out.append(line) if line.strip().startswith("- lz4-c-static"): - out.append(" - howardhinnant_date") + out.append(" - howardhinnant_date-static") meta.write_text("\n".join(out) + "\n") PY - name: Checkout mamba branch diff --git a/dev/environment-micromamba-static.yml b/dev/environment-micromamba-static.yml index 703333834d..ca4690e18b 100644 --- a/dev/environment-micromamba-static.yml +++ b/dev/environment-micromamba-static.yml @@ -38,6 +38,7 @@ dependencies: - zlib - libnghttp2-static - lz4-c-static + - sel(osx): howardhinnant_date-static # libmamba test dependencies - catch2 # micromamba dependencies From 7d312bd09654c1f0640eef5408b6bb6c6e20376c Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 21 Jul 2026 11:58:58 +0200 Subject: [PATCH 26/41] Add reference and remark about ISO 8601 duration format Signed-off-by: Julien Jerphanion --- libmamba/src/core/exclude_newer.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/libmamba/src/core/exclude_newer.cpp b/libmamba/src/core/exclude_newer.cpp index 8ba362cb81..7bd9b466ea 100644 --- a/libmamba/src/core/exclude_newer.cpp +++ b/libmamba/src/core/exclude_newer.cpp @@ -177,7 +177,12 @@ namespace mamba auto parse_iso8601_duration_seconds(std::string_view value) -> std::optional { - // P(n)Y(n)M(n)W(n)DT(n)H(n)M(n)S + // For a definition of the ISO 8601 duration format, see: + // https://docs.digi.com/resources/documentation/digidocs/90001488-13/reference/r_iso_8601_duration_format.htm + // but mind the missing "(n)W" segment for weeks! Weeks are supported here folloiwing: + // + // P(n)Y(n)M(n)W(n)DT(n)H(n)M(n)S + // static const std::regex iso8601_duration{ R"(^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$)", std::regex_constants::icase, From aaf90d090c3b5459ffc75f662856654f76a2bf5f Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 21 Jul 2026 11:59:34 +0200 Subject: [PATCH 27/41] Document specification of `exclude_newer_package` Signed-off-by: Julien Jerphanion --- libmamba/src/api/configuration.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libmamba/src/api/configuration.cpp b/libmamba/src/api/configuration.cpp index f3b8daaf96..0e4a206520 100644 --- a/libmamba/src/api/configuration.cpp +++ b/libmamba/src/api/configuration.cpp @@ -1740,7 +1740,9 @@ namespace mamba .long_description(unindent(R"( Maps package names to durations, dates, timestamps, or false to exempt a package from the global exclude_newer policy. Package-specific values - take precedence over the global cutoff.)"))); + take precedence over the global cutoff. This must + be expressed as a JSON dictionary with package names as keys (e.g. + '{"numpy": "false", "pandas": "7d"}'.)"))); insert(Configurable("order_solver_request", &m_context.solver_flags.order_request) .group("Solver") From 5cae3429cbe96a92f35e1b5038f0ab782827318f Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 21 Jul 2026 13:49:21 +0200 Subject: [PATCH 28/41] Do not include user-provided values in `ExcludeNewerPolicy` Signed-off-by: Julien Jerphanion --- libmamba/include/mamba/core/context.hpp | 16 ++++++- libmamba/include/mamba/core/exclude_newer.hpp | 43 ++++++++++--------- .../include/mamba/solver/libsolv/database.hpp | 13 +++--- libmamba/src/api/configuration.cpp | 4 +- libmamba/src/api/install.cpp | 6 ++- libmamba/src/api/remove.cpp | 3 +- libmamba/src/api/repoquery.cpp | 3 +- libmamba/src/api/utils.cpp | 28 +++++------- libmamba/src/api/utils.hpp | 5 ++- libmamba/src/core/exclude_newer.cpp | 14 ++++++ libmamba/src/core/package_database_loader.cpp | 2 +- libmamba/src/solver/libsolv/database.cpp | 17 +------- .../tests/src/core/test_exclude_newer.cpp | 2 - .../src/solver/libsolv/test_database.cpp | 40 +++++++++-------- libmambapy/bindings/solver_libsolv.cpp | 2 +- 15 files changed, 106 insertions(+), 92 deletions(-) diff --git a/libmamba/include/mamba/core/context.hpp b/libmamba/include/mamba/core/context.hpp index 2dea3ccbfd..6b107911dc 100644 --- a/libmamba/include/mamba/core/context.hpp +++ b/libmamba/include/mamba/core/context.hpp @@ -13,7 +13,6 @@ #include #include "mamba/core/context_params.hpp" -#include "mamba/core/exclude_newer.hpp" #include "mamba/core/logging.hpp" #include "mamba/core/palette.hpp" #include "mamba/core/subdir_parameters.hpp" @@ -131,7 +130,20 @@ namespace mamba // solver options solver::Request::Flags solver_flags = {}; - ExcludeNewerPolicy exclude_newer_policy; + + /** + * Exclude packages published more recently than this duration or date. + * + * See Configurable ``exclude_newer``; resolved into ``ExcludeNewerPolicy`` at solve time. + */ + std::string exclude_newer; + + /** + * Per-package overrides for ``exclude_newer`` (JSON dictionary on CLI / env vars). + * + * See Configurable ``exclude_newer_package``. + */ + std::map exclude_newer_package; // add start menu shortcuts on Windows (not implemented on Linux / macOS) bool shortcuts = true; diff --git a/libmamba/include/mamba/core/exclude_newer.hpp b/libmamba/include/mamba/core/exclude_newer.hpp index 93a94343a2..0a07f61622 100644 --- a/libmamba/include/mamba/core/exclude_newer.hpp +++ b/libmamba/include/mamba/core/exclude_newer.hpp @@ -33,28 +33,16 @@ namespace mamba using ExcludeNewerPackageCutoffs = std::unordered_map>; /** - * Resolved and raw ``exclude_newer`` policy from configuration. + * Resolved ``exclude_newer`` policy used by the solver / database. * - * Holds both unresolved config strings and resolved Unix-second cutoffs used by the solver. + * Holds Unix-second cutoffs only. Raw configuration strings live on ``Context`` and are + * resolved via ``resolve_exclude_newer_policy``. + * + * Background and cross-ecosystem tracking: + * https://github.com/conda/conda/issues/15759 */ struct ExcludeNewerPolicy { - /** - * Raw ``exclude_newer`` configuration values from CLI or configuration file. - * - * Background and cross-ecosystem tracking: - * https://github.com/conda/conda/issues/15759 - */ - std::string exclude_newer; - - /** - * Raw per-package ``exclude_newer`` overrides from configuration. - * - * Values are resolved to timestamps (or exemption) via - * ``resolve_exclude_newer_package_cutoffs``. - */ - std::map exclude_newer_package; - /** * Resolved global cutoff timestamp in seconds. */ @@ -65,10 +53,10 @@ namespace mamba */ ExcludeNewerPackageCutoffs per_package = {}; - /** Return whether no ``exclude_newer`` configuration is set. */ + /** Return whether no cutoff is configured. */ [[nodiscard]] auto empty() const -> bool { - return exclude_newer.empty() && exclude_newer_package.empty(); + return !global.has_value() && per_package.empty(); } /** @@ -103,6 +91,21 @@ namespace mamba std::uint64_t now_seconds ) -> ExcludeNewerPackageCutoffs; + /** + * Resolve raw ``exclude_newer`` configuration into a policy for the database. + * + * @param exclude_newer Global cutoff configuration string. + * @param exclude_newer_package Per-package overrides. + * @param now_seconds Reference time for relative durations, in Unix seconds. + * + * @throws mamba_error when a value cannot be parsed. + */ + [[nodiscard]] auto resolve_exclude_newer_policy( + std::string_view exclude_newer, + const std::map& exclude_newer_package, + std::uint64_t now_seconds + ) -> ExcludeNewerPolicy; + /** * Resolve a global ``exclude_newer`` configuration value to an absolute Unix * timestamp cutoff in seconds. diff --git a/libmamba/include/mamba/solver/libsolv/database.hpp b/libmamba/include/mamba/solver/libsolv/database.hpp index 3556843bc5..03d6d4fa8a 100644 --- a/libmamba/include/mamba/solver/libsolv/database.hpp +++ b/libmamba/include/mamba/solver/libsolv/database.hpp @@ -67,8 +67,8 @@ namespace mamba::solver::libsolv { MatchSpecParser matchspec_parser = MatchSpecParser::Libsolv; /** - * When set, packages with a policy timestamp newer than this Unix epoch cutoff - * (seconds) are excluded during repodata loading. + * When set, packages with a policy timestamp newer than the effective cutoff + * (Unix epoch seconds) are excluded during repodata loading. * * For repodata JSON, `indexed_timestamp` is preferred over `timestamp` when both * are present, matching conda's `--exclude-newer` semantics. @@ -76,13 +76,10 @@ namespace mamba::solver::libsolv * Per-package overrides take precedence over the global cutoff. A package mapped * to ``std::nullopt`` is exempt from the global policy (``false`` in config). * - * The `.solv` cache path is not filtered; invalidate the cache when these values - * change. + * The `.solv` cache path is not filtered; invalidate the cache when this policy + * changes. */ - std::optional exclude_newer_timestamp = std::nullopt; - ExcludeNewerPackageCutoffs exclude_newer_package = {}; - - [[nodiscard]] auto exclude_newer_policy() const -> ExcludeNewerPolicy; + ExcludeNewerPolicy exclude_newer_policy = {}; }; using logger_type = std::function; diff --git a/libmamba/src/api/configuration.cpp b/libmamba/src/api/configuration.cpp index 0e4a206520..0cdccc0c3e 100644 --- a/libmamba/src/api/configuration.cpp +++ b/libmamba/src/api/configuration.cpp @@ -1719,7 +1719,7 @@ namespace mamba .set_env_var_names() .description("Allow downgrade when installing packages. Default is false.")); - insert(Configurable("exclude_newer", &m_context.exclude_newer_policy.exclude_newer) + insert(Configurable("exclude_newer", &m_context.exclude_newer) .group("Solver") .set_rc_configurable() .set_env_var_names({ "CONDA_EXCLUDE_NEWER", "MAMBA_EXCLUDE_NEWER" }) @@ -1732,7 +1732,7 @@ namespace mamba treated as durations in seconds. Supply 0 for no delay, using the current time as the cutoff.)"))); - insert(Configurable("exclude_newer_package", &m_context.exclude_newer_policy.exclude_newer_package) + insert(Configurable("exclude_newer_package", &m_context.exclude_newer_package) .group("Solver") .set_rc_configurable() .set_env_var_names({ "CONDA_EXCLUDE_NEWER_PACKAGE", "MAMBA_EXCLUDE_NEWER_PACKAGE" }) diff --git a/libmamba/src/api/install.cpp b/libmamba/src/api/install.cpp index 9f54d3f437..7fe35180b3 100644 --- a/libmamba/src/api/install.cpp +++ b/libmamba/src/api/install.cpp @@ -743,7 +743,8 @@ namespace mamba auto database = make_solver_database( channel_context, ctx.experimental_matchspec_parsing, - ctx.exclude_newer_policy + ctx.exclude_newer, + ctx.exclude_newer_package ); init_channels(ctx, channel_context); @@ -1239,7 +1240,8 @@ namespace mamba auto db = make_solver_database( channel_context, ctx.experimental_matchspec_parsing, - ctx.exclude_newer_policy + ctx.exclude_newer, + ctx.exclude_newer_package ); add_logger_to_database(db); diff --git a/libmamba/src/api/remove.cpp b/libmamba/src/api/remove.cpp index fa23bbdcf9..1d5bf34e79 100644 --- a/libmamba/src/api/remove.cpp +++ b/libmamba/src/api/remove.cpp @@ -129,7 +129,8 @@ namespace mamba auto database = make_solver_database( channel_context, ctx.experimental_matchspec_parsing, - ctx.exclude_newer_policy + ctx.exclude_newer, + ctx.exclude_newer_package ); auto prefix_data = load_prefix_data_and_installed(ctx, channel_context, database); diff --git a/libmamba/src/api/repoquery.cpp b/libmamba/src/api/repoquery.cpp index fa184e484c..a8a3ef4821 100644 --- a/libmamba/src/api/repoquery.cpp +++ b/libmamba/src/api/repoquery.cpp @@ -60,7 +60,8 @@ namespace mamba auto db = make_solver_database( channel_context, ctx.experimental_matchspec_parsing, - ctx.exclude_newer_policy + ctx.exclude_newer, + ctx.exclude_newer_package ); add_logger_to_database(db); diff --git a/libmamba/src/api/utils.cpp b/libmamba/src/api/utils.cpp index 5f77c5f734..b039dd7d76 100644 --- a/libmamba/src/api/utils.cpp +++ b/libmamba/src/api/utils.cpp @@ -524,28 +524,17 @@ namespace mamba { [[nodiscard]] auto make_database_settings( bool experimental_matchspec_parsing, - const ExcludeNewerPolicy& exclude_newer_policy + std::string_view exclude_newer, + const std::map& exclude_newer_package ) -> solver::libsolv::Database::Settings { const auto now = static_cast(std::time(nullptr)); const auto matchspec_parser = experimental_matchspec_parsing ? solver::libsolv::MatchSpecParser::Mamba : solver::libsolv::MatchSpecParser::Libsolv; - const std::optional - exclude_newer_timestamp = exclude_newer_policy.exclude_newer.empty() - ? std::nullopt - : resolve_exclude_newer_cutoff( - exclude_newer_policy.exclude_newer, - now - ); - const ExcludeNewerPackageCutoffs exclude_newer_package = resolve_exclude_newer_package_cutoffs( - exclude_newer_policy.exclude_newer_package, - now - ); return { matchspec_parser, - exclude_newer_timestamp, - exclude_newer_package, + resolve_exclude_newer_policy(exclude_newer, exclude_newer_package, now), }; } } // namespace @@ -553,12 +542,13 @@ namespace mamba solver::libsolv::Database make_solver_database( ChannelContext& channel_context, bool experimental_matchspec_parsing, - const ExcludeNewerPolicy& exclude_newer_policy + std::string_view exclude_newer, + const std::map& exclude_newer_package ) { solver::libsolv::Database db{ channel_context.params(), - make_database_settings(experimental_matchspec_parsing, exclude_newer_policy), + make_database_settings(experimental_matchspec_parsing, exclude_newer, exclude_newer_package), }; add_logger_to_database(db); return db; @@ -608,7 +598,8 @@ namespace mamba { populate_context_channels_from_specs(raw_specs, ctx); - if (!ctx.exclude_newer_policy.empty() && !ctx.mamba_repodata_parsing) + if ((!ctx.exclude_newer.empty() || !ctx.exclude_newer_package.empty()) + && !ctx.mamba_repodata_parsing) { LOG_WARNING << "exclude_newer requires the Mamba repodata parser; packages loaded from " "the libsolv parser will not be filtered"; @@ -617,7 +608,8 @@ namespace mamba auto db = make_solver_database( channel_context, ctx.experimental_matchspec_parsing, - ctx.exclude_newer_policy + ctx.exclude_newer, + ctx.exclude_newer_package ); MultiPackageCache package_caches(ctx.pkgs_dirs, ctx.validation_params); diff --git a/libmamba/src/api/utils.hpp b/libmamba/src/api/utils.hpp index f1c90fc017..6e5e19a9f3 100644 --- a/libmamba/src/api/utils.hpp +++ b/libmamba/src/api/utils.hpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -26,7 +27,6 @@ namespace mamba class ChannelContext; class Configuration; class Context; - struct ExcludeNewerPolicy; class MTransaction; class PrefixData; class MultiPackageCache; @@ -128,7 +128,8 @@ namespace mamba solver::libsolv::Database make_solver_database( ChannelContext& channel_context, bool experimental_matchspec_parsing, - const ExcludeNewerPolicy& exclude_newer_policy + std::string_view exclude_newer = {}, + const std::map& exclude_newer_package = {} ); /** diff --git a/libmamba/src/core/exclude_newer.cpp b/libmamba/src/core/exclude_newer.cpp index 7bd9b466ea..d065a1b167 100644 --- a/libmamba/src/core/exclude_newer.cpp +++ b/libmamba/src/core/exclude_newer.cpp @@ -388,4 +388,18 @@ namespace mamba return out; } + auto resolve_exclude_newer_policy( + std::string_view exclude_newer, + const std::map& exclude_newer_package, + std::uint64_t now_seconds + ) -> ExcludeNewerPolicy + { + return { + /* .global= */ exclude_newer.empty() + ? std::nullopt + : resolve_exclude_newer_cutoff(exclude_newer, now_seconds), + /* .per_package= */ resolve_exclude_newer_package_cutoffs(exclude_newer_package, now_seconds), + }; + } + } // namespace mamba diff --git a/libmamba/src/core/package_database_loader.cpp b/libmamba/src/core/package_database_loader.cpp index 7bf7b7d439..cedc59cc21 100644 --- a/libmamba/src/core/package_database_loader.cpp +++ b/libmamba/src/core/package_database_loader.cpp @@ -90,7 +90,7 @@ namespace mamba : solver::libsolv::RepodataParser::Libsolv; // Solv files are too slow on Windows. They also bypass exclude_newer filtering. - if (!util::on_win && ctx.exclude_newer_policy.empty()) + if (!util::on_win && ctx.exclude_newer.empty() && ctx.exclude_newer_package.empty()) { auto maybe_repo = subdir.valid_libsolv_cache_path().and_then( [&](fs::u8path&& solv_file) diff --git a/libmamba/src/solver/libsolv/database.cpp b/libmamba/src/solver/libsolv/database.cpp index 11fc752261..4855a084a1 100644 --- a/libmamba/src/solver/libsolv/database.cpp +++ b/libmamba/src/solver/libsolv/database.cpp @@ -99,16 +99,6 @@ namespace mamba::solver::libsolv return m_data->settings; } - auto Database::Settings::exclude_newer_policy() const -> ExcludeNewerPolicy - { - return { - /* .exclude_newer= */ "", - /* .exclude_newer_package= */ {}, - /* .global= */ exclude_newer_timestamp, - /* .per_package= */ exclude_newer_package, - }; - } - namespace { auto libsolv_to_log_level(int type) -> LogLevel @@ -194,7 +184,7 @@ namespace mamba::solver::libsolv package_types, settings().matchspec_parser, verify_artifacts, - settings().exclude_newer_policy() + settings().exclude_newer_policy ); } @@ -272,10 +262,7 @@ namespace mamba::solver::libsolv void Database::add_repo_from_packages_impl_loop(const RepoInfo& repo, const specs::PackageInfo& pkg) { - if (settings().exclude_newer_policy().excludes( - pkg.name, - normalize_conda_timestamp(pkg.timestamp) - )) + if (settings().exclude_newer_policy.excludes(pkg.name, normalize_conda_timestamp(pkg.timestamp))) { return; } diff --git a/libmamba/tests/src/core/test_exclude_newer.cpp b/libmamba/tests/src/core/test_exclude_newer.cpp index 7ca4fd7808..a3ea59cc36 100644 --- a/libmamba/tests/src/core/test_exclude_newer.cpp +++ b/libmamba/tests/src/core/test_exclude_newer.cpp @@ -270,8 +270,6 @@ namespace constexpr std::uint64_t global_cutoff = 2000; const ExcludeNewerPolicy policy{ - /* .exclude_newer= */ "", - /* .exclude_newer_package= */ {}, /* .global= */ global_cutoff, /* .per_package= */ { diff --git a/libmamba/tests/src/solver/libsolv/test_database.cpp b/libmamba/tests/src/solver/libsolv/test_database.cpp index 96af9117f9..1c6b5a0b0e 100644 --- a/libmamba/tests/src/solver/libsolv/test_database.cpp +++ b/libmamba/tests/src/solver/libsolv/test_database.cpp @@ -197,7 +197,7 @@ namespace const std::uint64_t cutoff = 2000; auto db_filtered = libsolv::Database( {}, - { matchspec_parser, /* exclude_newer_timestamp= */ cutoff } + { matchspec_parser, ExcludeNewerPolicy{ /* .global= */ cutoff } } ); auto old_pkg = specs::PackageInfo(); @@ -225,7 +225,7 @@ namespace const std::uint64_t cutoff = 2000000000; auto db_filtered = libsolv::Database( {}, - { matchspec_parser, /* exclude_newer_timestamp= */ cutoff } + { matchspec_parser, ExcludeNewerPolicy{ /* .global= */ cutoff } } ); auto ms_pkg = specs::PackageInfo(); @@ -272,7 +272,7 @@ namespace / "repodata/conda-forge-numpy-linux-64.json"; auto db_filtered = libsolv::Database( {}, - { matchspec_parser, /* exclude_newer_timestamp= */ std::uint64_t(1700000000) } + { matchspec_parser, ExcludeNewerPolicy{ /* .global= */ std::uint64_t(1700000000) } } ); auto repo1 = db_filtered.add_repo_from_repodata_json( repodata, @@ -329,10 +329,12 @@ namespace {}, { matchspec_parser, - /* exclude_newer_timestamp= */ std::uint64_t(2000), - /* exclude_newer_package= */ - ExcludeNewerPackageCutoffs{ - { "exempt-pkg", std::nullopt }, + ExcludeNewerPolicy{ + /* .global= */ std::uint64_t(2000), + /* .per_package= */ + ExcludeNewerPackageCutoffs{ + { "exempt-pkg", std::nullopt }, + }, }, } ); @@ -385,7 +387,7 @@ namespace auto db_filtered = libsolv::Database( {}, - { matchspec_parser, /* exclude_newer_timestamp= */ std::uint64_t(2000) } + { matchspec_parser, ExcludeNewerPolicy{ /* .global= */ std::uint64_t(2000) } } ); auto repo1 = db_filtered.add_repo_from_repodata_json( repodata, @@ -448,7 +450,7 @@ namespace { auto db_filtered = libsolv::Database( {}, - { matchspec_parser, /* exclude_newer_timestamp= */ cutoff_2019 } + { matchspec_parser, ExcludeNewerPolicy{ /* .global= */ cutoff_2019 } } ); auto repo1 = db_filtered.add_repo_from_repodata_json( repodata, @@ -478,10 +480,12 @@ namespace {}, { matchspec_parser, - /* exclude_newer_timestamp= */ cutoff_2019, - /* exclude_newer_package= */ - ExcludeNewerPackageCutoffs{ - { "numpy", std::nullopt }, + ExcludeNewerPolicy{ + /* .global= */ cutoff_2019, + /* .per_package= */ + ExcludeNewerPackageCutoffs{ + { "numpy", std::nullopt }, + }, }, } ); @@ -517,10 +521,12 @@ namespace {}, { matchspec_parser, - /* exclude_newer_timestamp= */ cutoff_2019, - /* exclude_newer_package= */ - ExcludeNewerPackageCutoffs{ - { "mamba", cutoff_2026_jan }, + ExcludeNewerPolicy{ + /* .global= */ cutoff_2019, + /* .per_package= */ + ExcludeNewerPackageCutoffs{ + { "mamba", cutoff_2026_jan }, + }, }, } ); diff --git a/libmambapy/bindings/solver_libsolv.cpp b/libmambapy/bindings/solver_libsolv.cpp index f1f8a01ed1..6e6a72409a 100644 --- a/libmambapy/bindings/solver_libsolv.cpp +++ b/libmambapy/bindings/solver_libsolv.cpp @@ -151,7 +151,7 @@ namespace mambapy channel_params, Database::Settings{ matchspec_parser, - exclude_newer_timestamp, + ExcludeNewerPolicy{ /* .global= */ exclude_newer_timestamp }, } ); } From 6c6e8acafc42c845ea7e9c12f371d1c690755ee6 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 21 Jul 2026 13:58:19 +0200 Subject: [PATCH 29/41] Make `exclude_newer_package` part of common options Signed-off-by: Julien Jerphanion --- micromamba/src/common_options.cpp | 8 ++++++++ micromamba/tests/test_install.py | 15 +++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/micromamba/src/common_options.cpp b/micromamba/src/common_options.cpp index 665ff320ed..6498da4b9a 100644 --- a/micromamba/src/common_options.cpp +++ b/micromamba/src/common_options.cpp @@ -404,6 +404,14 @@ init_install_options(CLI::App* subcom, Configuration& config) exclude_newer.description() ); + auto& exclude_newer_package = config.at("exclude_newer_package"); + subcom->add_option_function( + "--exclude-newer-package", + [&exclude_newer_package](const std::string& value) + { exclude_newer_package.set_cli_yaml_value(value); }, + exclude_newer_package.description() + ); + auto& allow_softlinks = config.at("allow_softlinks"); subcom->add_flag( "--allow-softlinks,!--no-allow-softlinks", diff --git a/micromamba/tests/test_install.py b/micromamba/tests/test_install.py index 2e9b1a4711..eb3eb8657c 100644 --- a/micromamba/tests/test_install.py +++ b/micromamba/tests/test_install.py @@ -48,6 +48,21 @@ def teardown_method(cls): if Path(TestInstall.prefix).exists(): helpers.rmtree(TestInstall.prefix) + def test_exclude_newer_cli(self): + res = helpers.install( + "-p", + TestInstall.prefix, + "xtensor", + "--exclude-newer", + "7d", + "--exclude-newer-package", + "{numpy: 'false', pandas: '7d'}", + "--print-config-only", + ) + assert res["exclude_newer"] == "7d" + # `--print-config-only` returns YAML; the string "false" is loaded as a bool. + assert res["exclude_newer_package"] == {"numpy": False, "pandas": "7d"} + @classmethod def config_tests(cls, res, root_prefix=root_prefix, target_prefix=prefix): assert res["root_prefix"] == root_prefix From e635a8822c3ffdd52adcfae665615da8c23f24d5 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 21 Jul 2026 14:01:35 +0200 Subject: [PATCH 30/41] Document `howardhinnant_date` dependency Signed-off-by: Julien Jerphanion --- dev/environment-dev.yml | 3 ++- dev/environment-micromamba-static.yml | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/dev/environment-dev.yml b/dev/environment-dev.yml index 6eb24e7bae..1bb985081c 100644 --- a/dev/environment-dev.yml +++ b/dev/environment-dev.yml @@ -21,11 +21,12 @@ dependencies: - libmsgpack-c - nlohmann_json - reproc-cpp >=14.2.4.post0 - - sel(osx): howardhinnant_date - simdjson >=3.3.0 - spdlog >=1.16.0 - yaml-cpp >=0.8.0 - sel(win): winreg + # Replacement of `std::chrono::parse` (P0355R7) missing in libc++, see: https://github.com/llvm/llvm-project/issues/166051 + - sel(osx): howardhinnant_date # libmamba test dependencies - catch2 # micromamba dependencies diff --git a/dev/environment-micromamba-static.yml b/dev/environment-micromamba-static.yml index ca4690e18b..cf2ef670c1 100644 --- a/dev/environment-micromamba-static.yml +++ b/dev/environment-micromamba-static.yml @@ -38,6 +38,7 @@ dependencies: - zlib - libnghttp2-static - lz4-c-static + # Replacement of `std::chrono::parse` (P0355R7) missing in libc++, see: https://github.com/llvm/llvm-project/issues/166051 - sel(osx): howardhinnant_date-static # libmamba test dependencies - catch2 From 951d03eb8d4b1bd2eedf46e95e6e1108866d6f36 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 21 Jul 2026 14:11:18 +0200 Subject: [PATCH 31/41] Move comment to `ExcludeNewerPolicy`'s definition Signed-off-by: Julien Jerphanion --- libmamba/include/mamba/core/exclude_newer.hpp | 7 +++++++ libmamba/include/mamba/solver/libsolv/database.hpp | 13 ------------- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/libmamba/include/mamba/core/exclude_newer.hpp b/libmamba/include/mamba/core/exclude_newer.hpp index 0a07f61622..d97694c6cb 100644 --- a/libmamba/include/mamba/core/exclude_newer.hpp +++ b/libmamba/include/mamba/core/exclude_newer.hpp @@ -38,6 +38,13 @@ namespace mamba * Holds Unix-second cutoffs only. Raw configuration strings live on ``Context`` and are * resolved via ``resolve_exclude_newer_policy``. * + * When set, packages with a policy timestamp newer than the effective cutoff are excluded + * during repodata loading. For repodata JSON, ``indexed_timestamp`` is preferred over + * ``timestamp`` when both are present, matching conda's ``--exclude-newer`` semantics. + * Per-package overrides take precedence over the global cutoff; a package mapped to + * ``std::nullopt`` is exempt (``false`` in config). The ``.solv`` cache path is not + * filtered; invalidate the cache when this policy changes. + * * Background and cross-ecosystem tracking: * https://github.com/conda/conda/issues/15759 */ diff --git a/libmamba/include/mamba/solver/libsolv/database.hpp b/libmamba/include/mamba/solver/libsolv/database.hpp index 03d6d4fa8a..d25e79b943 100644 --- a/libmamba/include/mamba/solver/libsolv/database.hpp +++ b/libmamba/include/mamba/solver/libsolv/database.hpp @@ -66,19 +66,6 @@ namespace mamba::solver::libsolv struct Settings { MatchSpecParser matchspec_parser = MatchSpecParser::Libsolv; - /** - * When set, packages with a policy timestamp newer than the effective cutoff - * (Unix epoch seconds) are excluded during repodata loading. - * - * For repodata JSON, `indexed_timestamp` is preferred over `timestamp` when both - * are present, matching conda's `--exclude-newer` semantics. - * - * Per-package overrides take precedence over the global cutoff. A package mapped - * to ``std::nullopt`` is exempt from the global policy (``false`` in config). - * - * The `.solv` cache path is not filtered; invalidate the cache when this policy - * changes. - */ ExcludeNewerPolicy exclude_newer_policy = {}; }; From 7612748ef15a054041efc3b38e8a1fc518236b6c Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Thu, 23 Jul 2026 16:05:43 +0200 Subject: [PATCH 32/41] Add TODO to drop Howard Hinnant date when chrono::parse is ubiquitous Signed-off-by: Julien Jerphanion Co-authored-by: Klaim --- libmamba/include/mamba/core/detail/chrono_parse.hpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libmamba/include/mamba/core/detail/chrono_parse.hpp b/libmamba/include/mamba/core/detail/chrono_parse.hpp index 2862d81860..cb307a55c6 100644 --- a/libmamba/include/mamba/core/detail/chrono_parse.hpp +++ b/libmamba/include/mamba/core/detail/chrono_parse.hpp @@ -25,6 +25,9 @@ namespace mamba::detail * When ``MAMBA_USE_HOWARD_HINNANT_DATE`` is set (libc++ lacks P0355; * https://github.com/llvm/llvm-project/issues/166051), ``date::from_stream`` is used. * + * TODO: Drop the Howard Hinnant ``date`` fallback once ``std::chrono::parse`` is + * available on all supported platforms (notably macOS/libc++). + * * Returns ``std::nullopt`` when parsing fails or trailing characters remain. */ template From 7ea5be75f7393780086eda015172e6b80a2bf285 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Thu, 23 Jul 2026 16:06:03 +0200 Subject: [PATCH 33/41] Store exclude_newer_package as a vector of pairs Signed-off-by: Julien Jerphanion Co-authored-by: Klaim --- libmamba/include/mamba/api/configuration.hpp | 14 +++++++++ .../include/mamba/api/configuration_impl.hpp | 31 +++++++++++++++++++ libmamba/include/mamba/core/context.hpp | 3 +- libmamba/include/mamba/core/exclude_newer.hpp | 5 ++- libmamba/src/api/utils.cpp | 4 +-- libmamba/src/api/utils.hpp | 2 +- libmamba/src/core/exclude_newer.cpp | 4 +-- 7 files changed, 54 insertions(+), 9 deletions(-) diff --git a/libmamba/include/mamba/api/configuration.hpp b/libmamba/include/mamba/api/configuration.hpp index 861fdf9c77..1981d46962 100644 --- a/libmamba/include/mamba/api/configuration.hpp +++ b/libmamba/include/mamba/api/configuration.hpp @@ -633,6 +633,20 @@ namespace mamba node[name] = values; } + template <> + inline void ConfigurableImpl>>::dump_json( + nlohmann::json& node, + const std::string& name + ) const + { + nlohmann::json object = nlohmann::json::object(); + for (const auto& [key, value] : m_value) + { + object[key] = value; + } + node[name] = object; + } + template void ConfigurableImpl::set_rc_value(const T& value, const std::string& source) { diff --git a/libmamba/include/mamba/api/configuration_impl.hpp b/libmamba/include/mamba/api/configuration_impl.hpp index cf28980f42..b731fcbabc 100644 --- a/libmamba/include/mamba/api/configuration_impl.hpp +++ b/libmamba/include/mamba/api/configuration_impl.hpp @@ -3,6 +3,7 @@ #include #include +#include #include #include @@ -199,6 +200,36 @@ namespace YAML } }; + template <> + struct convert>> + { + static Node encode(const std::vector>& rhs) + { + Node node; + node.SetStyle(YAML::EmitterStyle::Block); + for (const auto& [key, value] : rhs) + { + node[key] = value; + } + return node; + } + + static bool decode(const Node& node, std::vector>& rhs) + { + if (!node.IsMap()) + { + return false; + } + rhs.clear(); + rhs.reserve(node.size()); + for (const auto& entry : node) + { + rhs.emplace_back(entry.first.as(), entry.second.Scalar()); + } + return true; + } + }; + template <> struct convert { diff --git a/libmamba/include/mamba/core/context.hpp b/libmamba/include/mamba/core/context.hpp index 6b107911dc..0db99ea914 100644 --- a/libmamba/include/mamba/core/context.hpp +++ b/libmamba/include/mamba/core/context.hpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include "mamba/core/context_params.hpp" @@ -143,7 +144,7 @@ namespace mamba * * See Configurable ``exclude_newer_package``. */ - std::map exclude_newer_package; + std::vector> exclude_newer_package; // add start menu shortcuts on Windows (not implemented on Linux / macOS) bool shortcuts = true; diff --git a/libmamba/include/mamba/core/exclude_newer.hpp b/libmamba/include/mamba/core/exclude_newer.hpp index d97694c6cb..e9670dd88b 100644 --- a/libmamba/include/mamba/core/exclude_newer.hpp +++ b/libmamba/include/mamba/core/exclude_newer.hpp @@ -9,7 +9,6 @@ #include #include -#include #include #include #include @@ -94,7 +93,7 @@ namespace mamba * @throws mamba_error when a non-``false`` value cannot be parsed. */ [[nodiscard]] auto resolve_exclude_newer_package_cutoffs( - const std::map& exclude_newer_package, + const std::vector>& exclude_newer_package, std::uint64_t now_seconds ) -> ExcludeNewerPackageCutoffs; @@ -109,7 +108,7 @@ namespace mamba */ [[nodiscard]] auto resolve_exclude_newer_policy( std::string_view exclude_newer, - const std::map& exclude_newer_package, + const std::vector>& exclude_newer_package, std::uint64_t now_seconds ) -> ExcludeNewerPolicy; diff --git a/libmamba/src/api/utils.cpp b/libmamba/src/api/utils.cpp index b039dd7d76..4c1d13d525 100644 --- a/libmamba/src/api/utils.cpp +++ b/libmamba/src/api/utils.cpp @@ -525,7 +525,7 @@ namespace mamba [[nodiscard]] auto make_database_settings( bool experimental_matchspec_parsing, std::string_view exclude_newer, - const std::map& exclude_newer_package + const std::vector>& exclude_newer_package ) -> solver::libsolv::Database::Settings { const auto now = static_cast(std::time(nullptr)); @@ -543,7 +543,7 @@ namespace mamba ChannelContext& channel_context, bool experimental_matchspec_parsing, std::string_view exclude_newer, - const std::map& exclude_newer_package + const std::vector>& exclude_newer_package ) { solver::libsolv::Database db{ diff --git a/libmamba/src/api/utils.hpp b/libmamba/src/api/utils.hpp index 6e5e19a9f3..ad74687ca7 100644 --- a/libmamba/src/api/utils.hpp +++ b/libmamba/src/api/utils.hpp @@ -129,7 +129,7 @@ namespace mamba ChannelContext& channel_context, bool experimental_matchspec_parsing, std::string_view exclude_newer = {}, - const std::map& exclude_newer_package = {} + const std::vector>& exclude_newer_package = {} ); /** diff --git a/libmamba/src/core/exclude_newer.cpp b/libmamba/src/core/exclude_newer.cpp index d065a1b167..27349fbb7a 100644 --- a/libmamba/src/core/exclude_newer.cpp +++ b/libmamba/src/core/exclude_newer.cpp @@ -368,7 +368,7 @@ namespace mamba /** Resolve each entry in ``exclude_newer_package`` to a cutoff or exemption. */ auto resolve_exclude_newer_package_cutoffs( - const std::map& exclude_newer_package, + const std::vector>& exclude_newer_package, std::uint64_t now_seconds ) -> ExcludeNewerPackageCutoffs { @@ -390,7 +390,7 @@ namespace mamba auto resolve_exclude_newer_policy( std::string_view exclude_newer, - const std::map& exclude_newer_package, + const std::vector>& exclude_newer_package, std::uint64_t now_seconds ) -> ExcludeNewerPolicy { From 67f185b0ba27654dacd0a70fa49c30345ebd9a0a Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Thu, 23 Jul 2026 16:06:17 +0200 Subject: [PATCH 34/41] Use Markdown single backticks in exclude_newer docs Signed-off-by: Julien Jerphanion Co-authored-by: Klaim --- libmamba/include/mamba/core/exclude_newer.hpp | 68 ++++++++++--------- 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/libmamba/include/mamba/core/exclude_newer.hpp b/libmamba/include/mamba/core/exclude_newer.hpp index e9670dd88b..e97c769367 100644 --- a/libmamba/include/mamba/core/exclude_newer.hpp +++ b/libmamba/include/mamba/core/exclude_newer.hpp @@ -13,18 +13,20 @@ #include #include #include +#include +#include namespace mamba { /** - * Resolved per-package ``exclude_newer`` cutoffs. + * Resolved per-package `exclude_newer` cutoffs. * - * Cutoffs are stored as Unix epoch seconds (``std::uint64_t``) for compatibility with - * conda repodata timestamps and ``Database::Settings``. Parsing uses - * ``std::chrono::sys_seconds`` internally; see ``resolve_exclude_newer_cutoff``. + * Cutoffs are stored as Unix epoch seconds (`std::uint64_t`) for compatibility with + * conda repodata timestamps and `Database::Settings`. Parsing uses + * `std::chrono::sys_seconds` internally; see `resolve_exclude_newer_cutoff`. * * When a package name is present: - * - ``std::nullopt`` exempts the package from the global policy (``false`` in config) + * - `std::nullopt` exempts the package from the global policy (`false` in config) * - a timestamp value applies a package-specific cutoff * * Packages not listed fall back to the global cutoff. @@ -32,16 +34,16 @@ namespace mamba using ExcludeNewerPackageCutoffs = std::unordered_map>; /** - * Resolved ``exclude_newer`` policy used by the solver / database. + * Resolved `exclude_newer` policy used by the solver / database. * - * Holds Unix-second cutoffs only. Raw configuration strings live on ``Context`` and are - * resolved via ``resolve_exclude_newer_policy``. + * Holds Unix-second cutoffs only. Raw configuration strings live on `Context` and are + * resolved via `resolve_exclude_newer_policy`. * * When set, packages with a policy timestamp newer than the effective cutoff are excluded - * during repodata loading. For repodata JSON, ``indexed_timestamp`` is preferred over - * ``timestamp`` when both are present, matching conda's ``--exclude-newer`` semantics. + * during repodata loading. For repodata JSON, `indexed_timestamp` is preferred over + * `timestamp` when both are present, matching conda's `--exclude-newer` semantics. * Per-package overrides take precedence over the global cutoff; a package mapped to - * ``std::nullopt`` is exempt (``false`` in config). The ``.solv`` cache path is not + * `std::nullopt` is exempt (`false` in config). The `.solv` cache path is not * filtered; invalidate the cache when this policy changes. * * Background and cross-ecosystem tracking: @@ -66,16 +68,16 @@ namespace mamba } /** - * Return the effective cutoff for ``package_name``. + * Return the effective cutoff for `package_name`. * - * Per-package entries take precedence over the global cutoff. A mapped ``std::nullopt`` + * Per-package entries take precedence over the global cutoff. A mapped `std::nullopt` * means the package is exempt. */ [[nodiscard]] auto cutoff_for(std::string_view package_name) const -> std::optional; /** - * Return whether ``pkg_timestamp`` is newer than the effective cutoff for ``package_name``. + * Return whether `pkg_timestamp` is newer than the effective cutoff for `package_name`. * * Exempt packages (no cutoff) are never excluded. */ @@ -84,13 +86,13 @@ namespace mamba }; /** - * Resolve raw per-package ``exclude_newer`` configuration values. + * Resolve raw per-package `exclude_newer` configuration values. * * @param exclude_newer_package Map of package name to config value (duration, date, or - * ``false``). + * `false`). * @param now_seconds Reference time for relative durations, in Unix seconds. * - * @throws mamba_error when a non-``false`` value cannot be parsed. + * @throws mamba_error when a non-`false` value cannot be parsed. */ [[nodiscard]] auto resolve_exclude_newer_package_cutoffs( const std::vector>& exclude_newer_package, @@ -98,7 +100,7 @@ namespace mamba ) -> ExcludeNewerPackageCutoffs; /** - * Resolve raw ``exclude_newer`` configuration into a policy for the database. + * Resolve raw `exclude_newer` configuration into a policy for the database. * * @param exclude_newer Global cutoff configuration string. * @param exclude_newer_package Per-package overrides. @@ -113,28 +115,28 @@ namespace mamba ) -> ExcludeNewerPolicy; /** - * Resolve a global ``exclude_newer`` configuration value to an absolute Unix + * Resolve a global `exclude_newer` configuration value to an absolute Unix * timestamp cutoff in seconds. * - * The public API exposes ``std::uint64_t`` seconds for compatibility with repodata + * The public API exposes `std::uint64_t` seconds for compatibility with repodata * timestamps. Internally, date and datetime values are parsed with - * ``std::chrono::parse`` when the standard library provides it, otherwise with - * Howard Hinnant's ``date`` library until libc++ implements P0355 + * `std::chrono::parse` when the standard library provides it, otherwise with + * Howard Hinnant's `date` library until libc++ implements P0355 * (https://github.com/llvm/llvm-project/issues/166051). Duration strings - * (``7d``, ``P7D``, plain seconds) use custom parsers because the standard library + * (`7d`, `P7D`, plain seconds) use custom parsers because the standard library * does not provide ISO 8601 duration parsing. * - * Matches conda's ``exclude_newer`` semantics: - * - Durations (``7d``, ``P7D``, plain seconds) resolve to ``now - duration`` - * - Date-only values (``YYYY-MM-DD``) resolve to the start of the next UTC day + * Matches conda's `exclude_newer` semantics: + * - Durations (`7d`, `P7D`, plain seconds) resolve to `now - duration` + * - Date-only values (`YYYY-MM-DD`) resolve to the start of the next UTC day * - Datetimes resolve to the given instant (naive values are UTC) - * - Zero durations (``0``, ``0d``, ``P0D``) resolve to ``now`` + * - Zero durations (`0`, `0d`, `P0D`) resolve to `now` * * @param value Raw configuration string. * @param now_seconds Reference time for relative durations, in Unix seconds. * @param package_name When resolving a per-package override, used in parse-failure warnings. * - * Returns ``std::nullopt`` when ``value`` is empty/whitespace-only. + * Returns `std::nullopt` when `value` is empty/whitespace-only. * * @throws mamba_error when the value cannot be parsed. */ @@ -147,20 +149,20 @@ namespace mamba namespace detail { /** - * Parse an ISO 8601 duration (``P…Y…M…W…DT…H…M…S``) to seconds. + * Parse an ISO 8601 duration (`P…Y…M…W…DT…H…M…S`) to seconds. * - * Returns ``std::nullopt`` when ``value`` is not an ISO 8601 duration. + * Returns `std::nullopt` when `value` is not an ISO 8601 duration. * - * @throws mamba_error when the value starts with ``P`` but has no components. + * @throws mamba_error when the value starts with `P` but has no components. */ [[nodiscard]] auto parse_iso8601_duration_seconds(std::string_view value) -> std::optional; /** - * Parse a compact duration (``(n)y(n)M(n)w(n)d(n)h(n)m(n)s``, e.g. ``7d``, ``3d12h``) + * Parse a compact duration (`(n)y(n)M(n)w(n)d(n)h(n)m(n)s`, e.g. `7d`, `3d12h`) * to seconds. * - * Returns ``std::nullopt`` when ``value`` is not a compact duration. + * Returns `std::nullopt` when `value` is not a compact duration. */ [[nodiscard]] auto parse_compact_duration_seconds(std::string_view value) -> std::optional; From b36d3ca2018f35c3810de5a08a9c591f5874f946 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Thu, 23 Jul 2026 16:06:24 +0200 Subject: [PATCH 35/41] Drop implementation details from resolve_exclude_newer_cutoff docs Signed-off-by: Julien Jerphanion Co-authored-by: Klaim --- libmamba/include/mamba/core/exclude_newer.hpp | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/libmamba/include/mamba/core/exclude_newer.hpp b/libmamba/include/mamba/core/exclude_newer.hpp index e97c769367..0a310a3637 100644 --- a/libmamba/include/mamba/core/exclude_newer.hpp +++ b/libmamba/include/mamba/core/exclude_newer.hpp @@ -88,7 +88,7 @@ namespace mamba /** * Resolve raw per-package `exclude_newer` configuration values. * - * @param exclude_newer_package Map of package name to config value (duration, date, or + * @param exclude_newer_package Per-package name/value pairs (duration, date, or * `false`). * @param now_seconds Reference time for relative durations, in Unix seconds. * @@ -118,19 +118,10 @@ namespace mamba * Resolve a global `exclude_newer` configuration value to an absolute Unix * timestamp cutoff in seconds. * - * The public API exposes `std::uint64_t` seconds for compatibility with repodata - * timestamps. Internally, date and datetime values are parsed with - * `std::chrono::parse` when the standard library provides it, otherwise with - * Howard Hinnant's `date` library until libc++ implements P0355 - * (https://github.com/llvm/llvm-project/issues/166051). Duration strings - * (`7d`, `P7D`, plain seconds) use custom parsers because the standard library - * does not provide ISO 8601 duration parsing. - * - * Matches conda's `exclude_newer` semantics: - * - Durations (`7d`, `P7D`, plain seconds) resolve to `now - duration` - * - Date-only values (`YYYY-MM-DD`) resolve to the start of the next UTC day - * - Datetimes resolve to the given instant (naive values are UTC) - * - Zero durations (`0`, `0d`, `P0D`) resolve to `now` + * Durations (`7d`, `P7D`, plain seconds) resolve relative to `now_seconds`. + * Date-only values (`YYYY-MM-DD`) resolve to the start of the next UTC day. + * Datetimes resolve to the given instant (naive values are UTC). Zero durations + * (`0`, `0d`, `P0D`) resolve to `now_seconds`. * * @param value Raw configuration string. * @param now_seconds Reference time for relative durations, in Unix seconds. From b7eb3fe1dcb4ec135739257d895b5d702a5d717f Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Thu, 23 Jul 2026 16:06:39 +0200 Subject: [PATCH 36/41] Use chrono for exclude_newer reference time Signed-off-by: Julien Jerphanion Co-authored-by: Klaim --- libmamba/src/api/utils.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/libmamba/src/api/utils.cpp b/libmamba/src/api/utils.cpp index 4c1d13d525..edf9132836 100644 --- a/libmamba/src/api/utils.cpp +++ b/libmamba/src/api/utils.cpp @@ -528,7 +528,12 @@ namespace mamba const std::vector>& exclude_newer_package ) -> solver::libsolv::Database::Settings { - const auto now = static_cast(std::time(nullptr)); + const auto now = static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch() + ) + .count() + ); const auto matchspec_parser = experimental_matchspec_parsing ? solver::libsolv::MatchSpecParser::Mamba : solver::libsolv::MatchSpecParser::Libsolv; From 2bbb36edc950a131ac51ab570cc0dd00de826e1a Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Thu, 23 Jul 2026 16:06:45 +0200 Subject: [PATCH 37/41] Use fmt for exclude_newer parse error messages Signed-off-by: Julien Jerphanion Co-authored-by: Klaim --- libmamba/src/core/exclude_newer.cpp | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/libmamba/src/core/exclude_newer.cpp b/libmamba/src/core/exclude_newer.cpp index 27349fbb7a..d7f623ea7f 100644 --- a/libmamba/src/core/exclude_newer.cpp +++ b/libmamba/src/core/exclude_newer.cpp @@ -12,6 +12,8 @@ #include #include +#include + #include "mamba/core/detail/chrono_parse.hpp" #include "mamba/core/error_handling.hpp" #include "mamba/core/exclude_newer.hpp" @@ -53,19 +55,22 @@ namespace mamba "(e.g. P7D, PT24H, P1DT12H), a plain integer in seconds (e.g. 3600), a date " "(e.g. 2026-04-01), or a datetime (e.g. 2026-04-01T12:00:00Z)"; - std::string message; if (package_name.empty()) { - message = "Could not parse exclude_newer value '" + std::string(value) + "'; " - + duration_hint; - } - else - { - message = "Could not parse exclude_newer_package value for package '" - + std::string(package_name) + "' ('" + std::string(value) + "'); " - + duration_hint + ", or false"; + throw mamba_error( + fmt::format("Could not parse exclude_newer value '{}'; {}", value, duration_hint), + mamba_error_code::incorrect_usage + ); } - throw mamba_error(std::move(message), mamba_error_code::incorrect_usage); + throw mamba_error( + fmt::format( + "Could not parse exclude_newer_package value for package '{}' ('{}'); {}, or false", + package_name, + value, + duration_hint + ), + mamba_error_code::incorrect_usage + ); } /** Parse ``value`` as an unsigned integer that must consume the entire string. */ From 59094e09e6b40114b3cdaca79c65edd061f5ad62 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Thu, 23 Jul 2026 16:07:13 +0200 Subject: [PATCH 38/41] Use chrono duration units for exclude_newer parsing Signed-off-by: Julien Jerphanion Co-authored-by: Klaim --- libmamba/src/core/exclude_newer.cpp | 29 ++++---- .../tests/src/core/test_exclude_newer.cpp | 68 +++++++++++++------ 2 files changed, 63 insertions(+), 34 deletions(-) diff --git a/libmamba/src/core/exclude_newer.cpp b/libmamba/src/core/exclude_newer.cpp index d7f623ea7f..3085bf364c 100644 --- a/libmamba/src/core/exclude_newer.cpp +++ b/libmamba/src/core/exclude_newer.cpp @@ -175,6 +175,21 @@ namespace mamba return now - duration; } + template + [[nodiscard]] constexpr std::uint64_t unit_seconds(Duration unit) + { + return static_cast( + std::chrono::duration_cast(unit).count() + ); + } + + constexpr std::uint64_t seconds_per_year = unit_seconds(std::chrono::years{ 1 }); + constexpr std::uint64_t seconds_per_month = unit_seconds(std::chrono::months{ 1 }); + constexpr std::uint64_t seconds_per_week = unit_seconds(std::chrono::weeks{ 1 }); + constexpr std::uint64_t seconds_per_day = unit_seconds(std::chrono::days{ 1 }); + constexpr std::uint64_t seconds_per_hour = unit_seconds(std::chrono::hours{ 1 }); + constexpr std::uint64_t seconds_per_minute = unit_seconds(std::chrono::minutes{ 1 }); + } // namespace namespace detail @@ -193,13 +208,6 @@ namespace mamba std::regex_constants::icase, }; - constexpr std::uint64_t seconds_per_minute = 60; - constexpr std::uint64_t seconds_per_hour = 3600; - constexpr std::uint64_t seconds_per_day = 86400; - constexpr std::uint64_t seconds_per_week = 604800; - constexpr std::uint64_t seconds_per_month = 30 * seconds_per_day; - constexpr std::uint64_t seconds_per_year = 365 * seconds_per_day; - if (value.empty()) { return std::nullopt; @@ -253,13 +261,6 @@ namespace mamba static const std::regex compact_duration{ R"(^(\d+[yMwdhms])+$)" }; static const std::regex compact_segment{ R"((\d+)([yMwdhms]))" }; - constexpr std::uint64_t seconds_per_minute = 60; - constexpr std::uint64_t seconds_per_hour = 3600; - constexpr std::uint64_t seconds_per_day = 86400; - constexpr std::uint64_t seconds_per_week = 604800; - constexpr std::uint64_t seconds_per_month = 30 * seconds_per_day; - constexpr std::uint64_t seconds_per_year = 365 * seconds_per_day; - if (value.empty() || !std::regex_match(value.begin(), value.end(), compact_duration)) { return std::nullopt; diff --git a/libmamba/tests/src/core/test_exclude_newer.cpp b/libmamba/tests/src/core/test_exclude_newer.cpp index a3ea59cc36..6c955499d2 100644 --- a/libmamba/tests/src/core/test_exclude_newer.cpp +++ b/libmamba/tests/src/core/test_exclude_newer.cpp @@ -4,6 +4,8 @@ // // The full license is in the file LICENSE, distributed with this software. +#include + #include #include "mamba/core/error_handling.hpp" @@ -11,6 +13,24 @@ using namespace mamba; +namespace +{ + template + [[nodiscard]] constexpr std::uint64_t unit_seconds(Duration unit) + { + return static_cast( + std::chrono::duration_cast(unit).count() + ); + } + + constexpr std::uint64_t seconds_per_year = unit_seconds(std::chrono::years{ 1 }); + constexpr std::uint64_t seconds_per_month = unit_seconds(std::chrono::months{ 1 }); + constexpr std::uint64_t seconds_per_week = unit_seconds(std::chrono::weeks{ 1 }); + constexpr std::uint64_t seconds_per_day = unit_seconds(std::chrono::days{ 1 }); + constexpr std::uint64_t seconds_per_hour = unit_seconds(std::chrono::hours{ 1 }); + constexpr std::uint64_t seconds_per_minute = unit_seconds(std::chrono::minutes{ 1 }); +} + namespace { TEST_CASE("resolve_exclude_newer_cutoff") @@ -41,28 +61,36 @@ namespace SECTION("compact durations resolve relative to now") { - REQUIRE(resolve_exclude_newer_cutoff("7d", now) == now - 7 * 86400); - REQUIRE(resolve_exclude_newer_cutoff("1w", now) == now - 604800); - REQUIRE(resolve_exclude_newer_cutoff("3d12h", now) == now - (3 * 86400 + 12 * 3600)); - REQUIRE(resolve_exclude_newer_cutoff("1y", now) == now - 365 * 86400); - REQUIRE(resolve_exclude_newer_cutoff("6M", now) == now - 6 * 30 * 86400); + REQUIRE(resolve_exclude_newer_cutoff("7d", now) == now - 7 * seconds_per_day); + REQUIRE(resolve_exclude_newer_cutoff("1w", now) == now - seconds_per_week); + REQUIRE( + resolve_exclude_newer_cutoff("3d12h", now) + == now - (3 * seconds_per_day + 12 * seconds_per_hour) + ); + REQUIRE(resolve_exclude_newer_cutoff("1y", now) == now - seconds_per_year); + REQUIRE(resolve_exclude_newer_cutoff("6M", now) == now - 6 * seconds_per_month); REQUIRE( resolve_exclude_newer_cutoff("1y6M7d", now) - == now - (365 * 86400 + 6 * 30 * 86400 + 7 * 86400) + == now - (seconds_per_year + 6 * seconds_per_month + 7 * seconds_per_day) ); } SECTION("ISO 8601 durations resolve relative to now") { - REQUIRE(resolve_exclude_newer_cutoff("P7D", now) == now - 7 * 86400); - REQUIRE(resolve_exclude_newer_cutoff("PT24H", now) == now - 24 * 3600); - REQUIRE(resolve_exclude_newer_cutoff("P1DT12H", now) == now - (86400 + 12 * 3600)); - REQUIRE(resolve_exclude_newer_cutoff("P1Y", now) == now - 365 * 86400); - REQUIRE(resolve_exclude_newer_cutoff("P6M", now) == now - 6 * 30 * 86400); - REQUIRE(resolve_exclude_newer_cutoff("PT1M", now) == now - 60); + REQUIRE(resolve_exclude_newer_cutoff("P7D", now) == now - 7 * seconds_per_day); + REQUIRE(resolve_exclude_newer_cutoff("PT24H", now) == now - 24 * seconds_per_hour); + REQUIRE( + resolve_exclude_newer_cutoff("P1DT12H", now) + == now - (seconds_per_day + 12 * seconds_per_hour) + ); + REQUIRE(resolve_exclude_newer_cutoff("P1Y", now) == now - seconds_per_year); + REQUIRE(resolve_exclude_newer_cutoff("P6M", now) == now - 6 * seconds_per_month); + REQUIRE(resolve_exclude_newer_cutoff("PT1M", now) == now - seconds_per_minute); REQUIRE( resolve_exclude_newer_cutoff("P3Y6M4DT12H30M5S", now) - == now - (3 * 365 * 86400 + 6 * 30 * 86400 + 4 * 86400 + 12 * 3600 + 30 * 60 + 5) + == now + - (3 * seconds_per_year + 6 * seconds_per_month + 4 * seconds_per_day + + 12 * seconds_per_hour + 30 * seconds_per_minute + 5) ); } @@ -161,12 +189,12 @@ namespace TEST_CASE("parse_compact_duration_seconds") { - constexpr std::uint64_t y = 365 * 86400; - constexpr std::uint64_t mon = 30 * 86400; - constexpr std::uint64_t w = 604800; - constexpr std::uint64_t d = 86400; - constexpr std::uint64_t h = 3600; - constexpr std::uint64_t min = 60; + constexpr std::uint64_t y = seconds_per_year; + constexpr std::uint64_t mon = seconds_per_month; + constexpr std::uint64_t w = seconds_per_week; + constexpr std::uint64_t d = seconds_per_day; + constexpr std::uint64_t h = seconds_per_hour; + constexpr std::uint64_t min = seconds_per_minute; const auto sec = [](std::int64_t n) { return std::chrono::seconds{ n }; }; const auto parse = [](std::string_view value) @@ -261,7 +289,7 @@ namespace SECTION("package-specific durations override global semantics at resolve time") { const auto cutoffs = resolve_exclude_newer_package_cutoffs({ { "pandas", "7d" } }, now); - REQUIRE(cutoffs.at("pandas") == now - 7 * 86400); + REQUIRE(cutoffs.at("pandas") == now - 7 * seconds_per_day); } } From e7645797a0ae449bec081b398df2e1aa22d51df1 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Thu, 23 Jul 2026 16:07:24 +0200 Subject: [PATCH 39/41] Accumulate chrono durations in compact exclude_newer parser Signed-off-by: Julien Jerphanion Co-authored-by: Klaim --- libmamba/src/core/exclude_newer.cpp | 30 ++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/libmamba/src/core/exclude_newer.cpp b/libmamba/src/core/exclude_newer.cpp index 3085bf364c..debb94ee49 100644 --- a/libmamba/src/core/exclude_newer.cpp +++ b/libmamba/src/core/exclude_newer.cpp @@ -266,7 +266,7 @@ namespace mamba return std::nullopt; } - std::uint64_t total = 0; + std::chrono::seconds total{ 0 }; const std::string input(value); const std::sregex_iterator end; for (std::sregex_iterator it(input.begin(), input.end(), compact_segment); it != end; ++it) @@ -277,37 +277,45 @@ namespace mamba return std::nullopt; } - std::uint64_t multiplier = 0; switch ((*it)[2].str().front()) { case 'y': - multiplier = seconds_per_year; + total += std::chrono::duration_cast(std::chrono::years{ + static_cast(*amount) }); break; case 'M': - multiplier = seconds_per_month; + total += std::chrono::duration_cast( + std::chrono::months{ static_cast(*amount) } + ); break; case 'w': - multiplier = seconds_per_week; + total += std::chrono::duration_cast(std::chrono::weeks{ + static_cast(*amount) }); break; case 'd': - multiplier = seconds_per_day; + total += std::chrono::duration_cast(std::chrono::days{ + static_cast(*amount) }); break; case 'h': - multiplier = seconds_per_hour; + total += std::chrono::duration_cast(std::chrono::hours{ + static_cast(*amount) }); break; case 'm': - multiplier = seconds_per_minute; + total += std::chrono::duration_cast( + std::chrono::minutes{ static_cast(*amount) } + ); break; case 's': - multiplier = 1; + total += std::chrono::seconds{ + static_cast(*amount) + }; break; default: return std::nullopt; } - total += *amount * multiplier; } - return std::chrono::seconds{ static_cast(total) }; + return total; } } // namespace detail From 4424176712ec7f80e8eda2f89ee227ec63b648c2 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Fri, 24 Jul 2026 10:00:49 +0200 Subject: [PATCH 40/41] Adapt CMAKE_REQUIRED_FLAGS Signed-off-by: Julien Jerphanion --- libmamba/CMakeLists.txt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/libmamba/CMakeLists.txt b/libmamba/CMakeLists.txt index 6789f8dacb..30b6e7931c 100644 --- a/libmamba/CMakeLists.txt +++ b/libmamba/CMakeLists.txt @@ -14,11 +14,12 @@ include(CheckCXXSourceCompiles) # std::chrono::parse (P0355) is not yet available on all platforms (e.g. libc++ on macOS). # https://github.com/llvm/llvm-project/issues/166051 if(NOT DEFINED MAMBA_HAVE_STD_CHRONO_PARSE) + # CMAKE_REQUIRED_FLAGS is a string (not a CMake list); list(APPEND) would inject ';'. set(CMAKE_REQUIRED_FLAGS_BACKUP "${CMAKE_REQUIRED_FLAGS}") if(MSVC) - list(APPEND CMAKE_REQUIRED_FLAGS "/std:c++20") + set(CMAKE_REQUIRED_FLAGS "${CMAKE_REQUIRED_FLAGS} /std:c++20") else() - list(APPEND CMAKE_REQUIRED_FLAGS "-std=c++20") + set(CMAKE_REQUIRED_FLAGS "${CMAKE_REQUIRED_FLAGS} -std=c++20") endif() check_cxx_source_compiles( " From 1e56c01a7b906ad5bd9fe18abe9edcdc67264edd Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Fri, 24 Jul 2026 10:01:42 +0200 Subject: [PATCH 41/41] Fix coercion of `exclude_newer_package` Signed-off-by: Julien Jerphanion --- .../include/mamba/api/configuration_impl.hpp | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/libmamba/include/mamba/api/configuration_impl.hpp b/libmamba/include/mamba/api/configuration_impl.hpp index b731fcbabc..4bded56b1f 100644 --- a/libmamba/include/mamba/api/configuration_impl.hpp +++ b/libmamba/include/mamba/api/configuration_impl.hpp @@ -171,6 +171,45 @@ namespace mamba { return true; } + + // Map-shaped config (e.g. exclude_newer_package): stored as vector of pairs for + // duplicate-key / order control, but configured as a YAML/JSON object — not a sequence. + // Must not use Source>::deserialize, which wraps the value in `[...]`. + template <> + struct Source>> + { + using value_type = std::vector>; + + static std::vector default_value(const value_type& /* init */) + { + return { "default" }; + } + + static void merge( + const std::map& values, + const std::vector& sources, + value_type& value, + std::vector& source + ) + { + source = sources; + value = values.at(sources.front()); + } + + static value_type deserialize(const std::string& value) + { + if (value.empty()) + { + return {}; + } + return YAML::Load(value).as(); + } + + static bool is_sequence() + { + return false; + } + }; } } @@ -205,7 +244,8 @@ namespace YAML { static Node encode(const std::vector>& rhs) { - Node node; + // Empty default Node is Null; print-config / YAML consumers need an empty Map. + Node node(NodeType::Map); node.SetStyle(YAML::EmitterStyle::Block); for (const auto& [key, value] : rhs) { @@ -224,7 +264,7 @@ namespace YAML rhs.reserve(node.size()); for (const auto& entry : node) { - rhs.emplace_back(entry.first.as(), entry.second.Scalar()); + rhs.emplace_back(entry.first.as(), entry.second.as()); } return true; }