From 32645dd02b75549a659f0a2186325945c67f885e Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Fri, 10 Apr 2026 14:27:06 +0200 Subject: [PATCH 01/27] Add exclude_newer_timestamp to Database::Settings When set, packages with a timestamp newer than the cutoff are excluded during repodata loading. This covers both ingestion paths: - JSON (mamba_read_json -> set_repo_solvables_impl): after parsing a solvable, check its timestamp and remove it if newer than the cutoff - PackageInfo (add_repo_from_packages_impl_loop): skip the package before adding a solvable The .solv cache path is not filtered; callers (e.g. conda-libmamba-solver) are expected to invalidate the cache when exclude_newer changes. Also moves MAX_CONDA_TIMESTAMP to helpers.hpp so both helpers.cpp and database.cpp can use it without duplication. Python bindings expose the new parameter as an optional exclude_newer_timestamp keyword argument on Database.__init__(). This enables conda's --exclude-newer feature to work with the libmamba solver backend. See conda/conda#15759 for full tracking. --- .../include/mamba/solver/libsolv/database.hpp | 2 + libmamba/src/solver/libsolv/database.cpp | 13 ++++- libmamba/src/solver/libsolv/helpers.cpp | 50 ++++++++++++------- libmamba/src/solver/libsolv/helpers.hpp | 8 ++- libmambapy/bindings/solver_libsolv.cpp | 9 +++- 5 files changed, 61 insertions(+), 21 deletions(-) diff --git a/libmamba/include/mamba/solver/libsolv/database.hpp b/libmamba/include/mamba/solver/libsolv/database.hpp index 968f02444c..3c06b18f7e 100644 --- a/libmamba/include/mamba/solver/libsolv/database.hpp +++ b/libmamba/include/mamba/solver/libsolv/database.hpp @@ -8,6 +8,7 @@ #define MAMBA_SOLVER_LIBSOLV_DATABASE_HPP #include +#include #include #include #include @@ -64,6 +65,7 @@ namespace mamba::solver::libsolv struct Settings { MatchSpecParser matchspec_parser = MatchSpecParser::Libsolv; + std::optional exclude_newer_timestamp = std::nullopt; }; using logger_type = std::function; diff --git a/libmamba/src/solver/libsolv/database.cpp b/libmamba/src/solver/libsolv/database.cpp index 3cd6e0b3f4..5d27fe7651 100644 --- a/libmamba/src/solver/libsolv/database.cpp +++ b/libmamba/src/solver/libsolv/database.cpp @@ -183,7 +183,8 @@ namespace mamba::solver::libsolv channel_id, package_types, settings().matchspec_parser, - verify_artifacts + verify_artifacts, + settings().exclude_newer_timestamp ); } @@ -261,6 +262,16 @@ 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) + { + const auto ts = (pkg.timestamp > MAX_CONDA_TIMESTAMP) + ? (pkg.timestamp / 1000) + : pkg.timestamp; + if (ts > *cutoff) + { + return; + } + } auto s_repo = solv::ObjRepoView(*repo.m_ptr); auto [id, solv] = s_repo.add_solvable(); set_solvable(pool(), solv, pkg, settings().matchspec_parser); diff --git a/libmamba/src/solver/libsolv/helpers.cpp b/libmamba/src/solver/libsolv/helpers.cpp index 5289e24401..4a9277e0c1 100644 --- a/libmamba/src/solver/libsolv/helpers.cpp +++ b/libmamba/src/solver/libsolv/helpers.cpp @@ -40,10 +40,6 @@ namespace mamba::solver::libsolv { - // Beyond this value, the timestamp would be in milliseconds and therefore should be converted - // to seconds. - inline constexpr auto MAX_CONDA_TIMESTAMP = 253402300799ULL; - void set_solvable( solv::ObjPool& pool, solv::ObjSolvableView solv, @@ -443,7 +439,8 @@ namespace mamba::solver::libsolv const std::optional& signatures, Filter&& filter, OnParsed&& on_parsed, - MatchSpecParser parser + MatchSpecParser parser, + std::optional exclude_newer_timestamp = std::nullopt ) { auto packages_as_object = packages.get_object(); @@ -466,7 +463,15 @@ namespace mamba::solver::libsolv ); if (parsed) { - on_parsed(filename); + if (exclude_newer_timestamp + && solv.timestamp() > static_cast(*exclude_newer_timestamp)) + { + repo.remove_solvable(id, /* reuse_id= */ true); + } + else + { + on_parsed(filename); + } } else { @@ -486,7 +491,8 @@ namespace mamba::solver::libsolv const std::string& default_subdir, JSONObject& packages, const std::optional& signatures, - MatchSpecParser parser + MatchSpecParser parser, + std::optional exclude_newer_timestamp = std::nullopt ) { return set_repo_solvables_impl( @@ -499,7 +505,8 @@ namespace mamba::solver::libsolv signatures, /* filter= */ [](const auto&) { return true; }, /* on_parsed= */ [](const auto&) {}, - parser + parser, + exclude_newer_timestamp ); } @@ -512,7 +519,8 @@ namespace mamba::solver::libsolv const std::string& default_subdir, JSONObject& packages, const std::optional& signatures, - MatchSpecParser parser + MatchSpecParser parser, + std::optional exclude_newer_timestamp = std::nullopt ) -> util::flat_set { auto filenames = util::flat_set(); @@ -528,7 +536,8 @@ namespace mamba::solver::libsolv /* on_parsed= */ [&](const auto& fn) { filenames.insert(std::string(specs::strip_archive_extension(fn))); }, - parser + parser, + exclude_newer_timestamp ); // Sort only once return filenames; @@ -544,7 +553,8 @@ namespace mamba::solver::libsolv JSONObject& packages, const std::optional& signatures, const SortedStringRange& added, - MatchSpecParser parser + MatchSpecParser parser, + std::optional exclude_newer_timestamp = std::nullopt ) { return set_repo_solvables_impl( @@ -558,7 +568,8 @@ namespace mamba::solver::libsolv /* filter= */ [&](const auto& fn) { return !added.contains(specs::strip_archive_extension(fn)); }, /* on_parsed= */ [&](const auto&) {}, - parser + parser, + exclude_newer_timestamp ); } } @@ -623,7 +634,8 @@ namespace mamba::solver::libsolv const std::string& channel_id, PackageTypes package_types, MatchSpecParser ms_parser, - bool verify_artifacts + bool verify_artifacts, + std::optional exclude_newer_timestamp ) -> expected_t { LOG_INFO << "Reading repodata.json file " << filename << " for repo " << repo.name() @@ -739,7 +751,8 @@ namespace mamba::solver::libsolv default_subdir, pkgs, json_signatures, - ms_parser + ms_parser, + exclude_newer_timestamp ); } if (auto pkgs = repodata_doc["packages"]; !pkgs.error()) @@ -753,7 +766,8 @@ namespace mamba::solver::libsolv pkgs, json_signatures, added, - ms_parser + ms_parser, + exclude_newer_timestamp ); } } @@ -770,7 +784,8 @@ namespace mamba::solver::libsolv default_subdir, pkgs, json_signatures, - ms_parser + ms_parser, + exclude_newer_timestamp ); } @@ -785,7 +800,8 @@ namespace mamba::solver::libsolv default_subdir, pkgs, json_signatures, - ms_parser + ms_parser, + exclude_newer_timestamp ); } } diff --git a/libmamba/src/solver/libsolv/helpers.hpp b/libmamba/src/solver/libsolv/helpers.hpp index 2054625d18..d79835e1e1 100644 --- a/libmamba/src/solver/libsolv/helpers.hpp +++ b/libmamba/src/solver/libsolv/helpers.hpp @@ -7,6 +7,7 @@ #ifndef MAMBA_SOLVER_LIBSOLV_HELPERS #define MAMBA_SOLVER_LIBSOLV_HELPERS +#include #include #include #include @@ -37,6 +38,10 @@ namespace mamba::fs namespace mamba::solver::libsolv { + // Beyond this value, the timestamp would be in milliseconds and therefore should be + // converted to seconds. + inline constexpr std::uint64_t MAX_CONDA_TIMESTAMP = 253402300799ULL; + void set_solvable( solv::ObjPool& pool, solv::ObjSolvableView solv, @@ -62,7 +67,8 @@ namespace mamba::solver::libsolv const std::string& channel_id, PackageTypes types, MatchSpecParser parser, - bool verify_artifacts + bool verify_artifacts, + std::optional exclude_newer_timestamp = std::nullopt ) -> expected_t; [[nodiscard]] auto read_solv( diff --git a/libmambapy/bindings/solver_libsolv.cpp b/libmambapy/bindings/solver_libsolv.cpp index 50f1c68d93..f1f8a01ed1 100644 --- a/libmambapy/bindings/solver_libsolv.cpp +++ b/libmambapy/bindings/solver_libsolv.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include "mamba/solver/libsolv/database.hpp" #include "mamba/solver/libsolv/parameters.hpp" @@ -142,18 +143,22 @@ namespace mambapy py::class_(m, "Database") .def( py::init( - [](specs::ChannelResolveParams channel_params, MatchSpecParser matchspec_parser) + [](specs::ChannelResolveParams channel_params, + MatchSpecParser matchspec_parser, + std::optional exclude_newer_timestamp) { return Database( channel_params, Database::Settings{ matchspec_parser, + exclude_newer_timestamp, } ); } ), py::arg("channel_params"), - py::arg("matchspec_parser") = MatchSpecParser::Libsolv + py::arg("matchspec_parser") = MatchSpecParser::Libsolv, + py::arg("exclude_newer_timestamp") = py::none() ) .def("set_logger", &Database::set_logger, py::call_guard()) .def( From f446a4e2c0ed7f162ef330aaf3baa971ca41a652 Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Fri, 10 Apr 2026 14:35:40 +0200 Subject: [PATCH 02/27] Add tests for exclude_newer_timestamp filtering - C++ tests (test_database.cpp): verify add_repo_from_packages filters packages by timestamp, normalizes millisecond timestamps, and filters packages loaded from repodata JSON - Python tests (test_solver_libsolv.py): verify the libmambapy binding accepts exclude_newer_timestamp, filters packages from both the packages API and repodata JSON, and that None keeps all packages --- .../src/solver/libsolv/test_database.cpp | 65 ++++++++++++++++ libmambapy/tests/test_solver_libsolv.py | 76 +++++++++++++++++++ 2 files changed, 141 insertions(+) diff --git a/libmamba/tests/src/solver/libsolv/test_database.cpp b/libmamba/tests/src/solver/libsolv/test_database.cpp index 062e07109d..1ed18b03a5 100644 --- a/libmamba/tests/src/solver/libsolv/test_database.cpp +++ b/libmamba/tests/src/solver/libsolv/test_database.cpp @@ -5,6 +5,7 @@ // The full license is in the file LICENSE, distributed with this software. #include +#include #include #include @@ -189,6 +190,51 @@ namespace } } + SECTION("exclude_newer_timestamp filters packages from add_repo_from_packages") + { + const std::uint64_t cutoff = 2000; + auto db_filtered = libsolv::Database( + {}, + { matchspec_parser, /* exclude_newer_timestamp= */ cutoff } + ); + + auto old_pkg = specs::PackageInfo(); + old_pkg.name = "old-pkg"; + old_pkg.version = "1.0"; + old_pkg.timestamp = 1000; + + auto new_pkg = specs::PackageInfo(); + new_pkg.name = "new-pkg"; + new_pkg.version = "1.0"; + new_pkg.timestamp = 3000; + + auto pkgs = std::array{ old_pkg, new_pkg }; + auto repo1 = db_filtered.add_repo_from_packages(pkgs, "repo1"); + REQUIRE(repo1.package_count() == 1); + + db_filtered.for_each_package_in_repo( + repo1, + [](const auto& p) { REQUIRE(p.name == "old-pkg"); } + ); + } + + SECTION("exclude_newer_timestamp normalizes millisecond timestamps") + { + const std::uint64_t cutoff = 2000; + auto db_filtered = libsolv::Database( + {}, + { matchspec_parser, /* exclude_newer_timestamp= */ cutoff } + ); + + auto ms_pkg = specs::PackageInfo(); + ms_pkg.name = "ms-pkg"; + ms_pkg.version = "1.0"; + ms_pkg.timestamp = 1500000; // 1500 seconds in ms + + auto repo1 = db_filtered.add_repo_from_packages(std::array{ ms_pkg }, "repo1"); + REQUIRE(repo1.package_count() == 1); + } + SECTION("Add repo from repodata with no extra pip") { const auto repodata = mambatests::test_data_dir @@ -218,6 +264,25 @@ namespace REQUIRE(found_python); } + SECTION("exclude_newer_timestamp filters packages from repodata JSON") + { + const auto repodata = mambatests::test_data_dir + / "repodata/conda-forge-numpy-linux-64.json"; + auto db_filtered = libsolv::Database( + {}, + { matchspec_parser, /* exclude_newer_timestamp= */ std::uint64_t(1700000000) } + ); + 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() < 33); + REQUIRE(repo1->package_count() > 0); + } + SECTION("Add repo from repodata with extra pip") { const auto repodata = mambatests::test_data_dir diff --git a/libmambapy/tests/test_solver_libsolv.py b/libmambapy/tests/test_solver_libsolv.py index f7e65dcf1a..0fc05ff5a8 100644 --- a/libmambapy/tests/test_solver_libsolv.py +++ b/libmambapy/tests/test_solver_libsolv.py @@ -182,6 +182,82 @@ def test_Database_RepoInfo_from_packages(add_pip_as_python_dependency, matchspec assert db.installed_repo() is None +def test_Database_exclude_newer_timestamp_filters_packages(): + cutoff = 2000 + db = libsolv.Database( + libmambapy.specs.ChannelResolveParams(), + exclude_newer_timestamp=cutoff, + ) + + old_pkg = libmambapy.specs.PackageInfo(name="old-pkg") + old_pkg.version = "1.0" + old_pkg.timestamp = 1000 + + new_pkg = libmambapy.specs.PackageInfo(name="new-pkg") + new_pkg.version = "1.0" + new_pkg.timestamp = 3000 + + repo = db.add_repo_from_packages([old_pkg, new_pkg], name="test") + assert repo.package_count() == 1 + + pkgs = db.packages_in_repo(repo) + assert len(pkgs) == 1 + assert pkgs[0].name == "old-pkg" + + +def test_Database_exclude_newer_timestamp_none_keeps_all(): + db = libsolv.Database( + libmambapy.specs.ChannelResolveParams(), + exclude_newer_timestamp=None, + ) + + pkg = libmambapy.specs.PackageInfo(name="pkg") + pkg.version = "1.0" + pkg.timestamp = 9999 + + repo = db.add_repo_from_packages([pkg], name="test") + assert repo.package_count() == 1 + + +def test_Database_exclude_newer_timestamp_repodata(tmp_path): + repodata_file = tmp_path / "repodata.json" + with open(repodata_file, "w+") as f: + json.dump( + { + "packages": { + "old-pkg-1.0-bld.tar.bz2": { + "name": "old-pkg", + "version": "1.0", + "build": "bld", + "build_number": 0, + "timestamp": 1000, + }, + "new-pkg-1.0-bld.tar.bz2": { + "name": "new-pkg", + "version": "1.0", + "build": "bld", + "build_number": 0, + "timestamp": 3000, + }, + }, + "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 + + @pytest.fixture def tmp_repodata_json(tmp_path): file = tmp_path / "repodata.json" From 94020acd061595993f435f8e7bebf0158d574525 Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Fri, 10 Apr 2026 14:55:08 +0200 Subject: [PATCH 03/27] Fix exclude_newer filtering for repodata JSON path The repodata JSON filter was reading solv.timestamp() to check against the cutoff, but libsolv attributes require internalize() before they can be read back. Since internalize() runs after all packages are loaded, the timestamp was always 0 at filter time. Fix by passing the parsed timestamp out of set_solvable via an out parameter and using that directly in the filter comparison. Also fix the millisecond normalization test to use a timestamp that actually triggers the normalization path (> MAX_CONDA_TIMESTAMP), and add subdir/depends fields to the Python repodata test fixture to match real repodata structure. --- libmamba/src/solver/libsolv/helpers.cpp | 19 ++++++++++++------- .../src/solver/libsolv/test_database.cpp | 4 ++-- libmambapy/tests/test_solver_libsolv.py | 4 ++++ 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/libmamba/src/solver/libsolv/helpers.cpp b/libmamba/src/solver/libsolv/helpers.cpp index 4a9277e0c1..ae17f99ffe 100644 --- a/libmamba/src/solver/libsolv/helpers.cpp +++ b/libmamba/src/solver/libsolv/helpers.cpp @@ -211,16 +211,15 @@ namespace mamba::solver::libsolv template [[nodiscard]] auto set_solvable( solv::ObjPool& pool, - // const std::string& repo_url_str, const specs::CondaURL& repo_url, const std::string& channel_id, solv::ObjSolvableView solv, - const std::string& filename, JSONObject&& pkg, const std::optional& signatures, const std::string& default_subdir, - MatchSpecParser parser + MatchSpecParser parser, + std::uint64_t* out_timestamp = nullptr ) -> bool { // Not available from RepoDataPackage @@ -335,7 +334,12 @@ namespace mamba::solver::libsolv if (auto timestamp = pkg["timestamp"]; !timestamp.error()) { const auto time = timestamp.get_uint64().value_unsafe(); - solv.set_timestamp((time > MAX_CONDA_TIMESTAMP) ? (time / 1000) : time); + const auto normalized = (time > MAX_CONDA_TIMESTAMP) ? (time / 1000) : time; + solv.set_timestamp(normalized); + if (out_timestamp) + { + *out_timestamp = normalized; + } } if (auto depends = pkg["depends"].get_array(); !depends.error()) @@ -450,6 +454,7 @@ namespace mamba::solver::libsolv if (filter(filename)) { auto [id, solv] = repo.add_solvable(); + std::uint64_t pkg_timestamp = 0; const bool parsed = set_solvable( pool, repo_url, @@ -459,12 +464,12 @@ namespace mamba::solver::libsolv pkg_field.value(), signatures, default_subdir, - parser + parser, + &pkg_timestamp ); if (parsed) { - if (exclude_newer_timestamp - && solv.timestamp() > static_cast(*exclude_newer_timestamp)) + if (exclude_newer_timestamp && pkg_timestamp > *exclude_newer_timestamp) { repo.remove_solvable(id, /* reuse_id= */ true); } diff --git a/libmamba/tests/src/solver/libsolv/test_database.cpp b/libmamba/tests/src/solver/libsolv/test_database.cpp index 1ed18b03a5..ae45ae86dc 100644 --- a/libmamba/tests/src/solver/libsolv/test_database.cpp +++ b/libmamba/tests/src/solver/libsolv/test_database.cpp @@ -220,7 +220,7 @@ namespace SECTION("exclude_newer_timestamp normalizes millisecond timestamps") { - const std::uint64_t cutoff = 2000; + const std::uint64_t cutoff = 2000000000; auto db_filtered = libsolv::Database( {}, { matchspec_parser, /* exclude_newer_timestamp= */ cutoff } @@ -229,7 +229,7 @@ namespace auto ms_pkg = specs::PackageInfo(); ms_pkg.name = "ms-pkg"; ms_pkg.version = "1.0"; - ms_pkg.timestamp = 1500000; // 1500 seconds in ms + ms_pkg.timestamp = 1500000000000; // 1.5e12 ms → 1.5e9 seconds, below cutoff auto repo1 = db_filtered.add_repo_from_packages(std::array{ ms_pkg }, "repo1"); REQUIRE(repo1.package_count() == 1); diff --git a/libmambapy/tests/test_solver_libsolv.py b/libmambapy/tests/test_solver_libsolv.py index 0fc05ff5a8..744850b539 100644 --- a/libmambapy/tests/test_solver_libsolv.py +++ b/libmambapy/tests/test_solver_libsolv.py @@ -230,6 +230,8 @@ def test_Database_exclude_newer_timestamp_repodata(tmp_path): "version": "1.0", "build": "bld", "build_number": 0, + "subdir": "linux-64", + "depends": [], "timestamp": 1000, }, "new-pkg-1.0-bld.tar.bz2": { @@ -237,6 +239,8 @@ def test_Database_exclude_newer_timestamp_repodata(tmp_path): "version": "1.0", "build": "bld", "build_number": 0, + "subdir": "linux-64", + "depends": [], "timestamp": 3000, }, }, From 891ee175867b9854c88bff975f67d844d330a20b Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Fri, 10 Apr 2026 15:02:52 +0200 Subject: [PATCH 04/27] 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/database.cpp | 5 ++--- libmamba/src/solver/libsolv/helpers.cpp | 3 +-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/libmamba/src/solver/libsolv/database.cpp b/libmamba/src/solver/libsolv/database.cpp index 5d27fe7651..e2bde71fa7 100644 --- a/libmamba/src/solver/libsolv/database.cpp +++ b/libmamba/src/solver/libsolv/database.cpp @@ -264,9 +264,8 @@ namespace mamba::solver::libsolv { if (const auto cutoff = settings().exclude_newer_timestamp) { - const auto ts = (pkg.timestamp > MAX_CONDA_TIMESTAMP) - ? (pkg.timestamp / 1000) - : pkg.timestamp; + const auto ts = (pkg.timestamp > MAX_CONDA_TIMESTAMP) ? (pkg.timestamp / 1000) + : pkg.timestamp; if (ts > *cutoff) { return; diff --git a/libmamba/src/solver/libsolv/helpers.cpp b/libmamba/src/solver/libsolv/helpers.cpp index ae17f99ffe..11f5c7f0a4 100644 --- a/libmamba/src/solver/libsolv/helpers.cpp +++ b/libmamba/src/solver/libsolv/helpers.cpp @@ -1524,8 +1524,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 96d2ca73766cf7d5b483aae9ecea9ffee4e0b6e4 Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Mon, 8 Jun 2026 14:09:51 +0200 Subject: [PATCH 05/27] Address exclude-newer review feedback --- libmamba/src/solver/libsolv/database.cpp | 4 +- libmamba/src/solver/libsolv/helpers.cpp | 30 ++++++--- libmamba/src/solver/libsolv/helpers.hpp | 5 ++ .../src/solver/libsolv/test_database.cpp | 62 +++++++++++++++++++ libmambapy/tests/test_solver_libsolv.py | 12 ++-- 5 files changed, 96 insertions(+), 17 deletions(-) diff --git a/libmamba/src/solver/libsolv/database.cpp b/libmamba/src/solver/libsolv/database.cpp index e2bde71fa7..9822a0e7ef 100644 --- a/libmamba/src/solver/libsolv/database.cpp +++ b/libmamba/src/solver/libsolv/database.cpp @@ -264,9 +264,7 @@ namespace mamba::solver::libsolv { if (const auto cutoff = settings().exclude_newer_timestamp) { - const auto ts = (pkg.timestamp > MAX_CONDA_TIMESTAMP) ? (pkg.timestamp / 1000) - : pkg.timestamp; - if (ts > *cutoff) + if (normalize_conda_timestamp(pkg.timestamp) > *cutoff) { return; } diff --git a/libmamba/src/solver/libsolv/helpers.cpp b/libmamba/src/solver/libsolv/helpers.cpp index 11f5c7f0a4..f5b16d94e3 100644 --- a/libmamba/src/solver/libsolv/helpers.cpp +++ b/libmamba/src/solver/libsolv/helpers.cpp @@ -68,9 +68,7 @@ namespace mamba::solver::libsolv // TODO conda timestamp are not Unix timestamp. // Libsolv normalize them this way, we need to do the same here otherwise the current // package may get arbitrary priority. - solv.set_timestamp( - (pkg.timestamp > MAX_CONDA_TIMESTAMP) ? (pkg.timestamp / 1000) : pkg.timestamp - ); + solv.set_timestamp(normalize_conda_timestamp(pkg.timestamp)); solv.set_md5(pkg.md5); solv.set_sha256(pkg.sha256); solv.set_python_site_packages_path(pkg.python_site_packages_path); @@ -331,15 +329,26 @@ namespace mamba::solver::libsolv // TODO conda timestamp are not Unix timestamp. // Libsolv normalize them this way, we need to do the same here otherwise the current // package may get arbitrary priority. + std::optional policy_timestamp; + if (auto indexed_timestamp = pkg["indexed_timestamp"]; !indexed_timestamp.error()) + { + policy_timestamp = normalize_conda_timestamp( + indexed_timestamp.get_uint64().value_unsafe() + ); + } + if (auto timestamp = pkg["timestamp"]; !timestamp.error()) { - const auto time = timestamp.get_uint64().value_unsafe(); - const auto normalized = (time > MAX_CONDA_TIMESTAMP) ? (time / 1000) : time; + const auto normalized = normalize_conda_timestamp( + timestamp.get_uint64().value_unsafe() + ); solv.set_timestamp(normalized); - if (out_timestamp) - { - *out_timestamp = normalized; - } + policy_timestamp = policy_timestamp.value_or(normalized); + } + + if (out_timestamp && policy_timestamp) + { + *out_timestamp = *policy_timestamp; } if (auto depends = pkg["depends"].get_array(); !depends.error()) @@ -1524,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) { diff --git a/libmamba/src/solver/libsolv/helpers.hpp b/libmamba/src/solver/libsolv/helpers.hpp index d79835e1e1..878c688d4a 100644 --- a/libmamba/src/solver/libsolv/helpers.hpp +++ b/libmamba/src/solver/libsolv/helpers.hpp @@ -42,6 +42,11 @@ namespace mamba::solver::libsolv // converted to seconds. inline constexpr std::uint64_t MAX_CONDA_TIMESTAMP = 253402300799ULL; + [[nodiscard]] constexpr auto normalize_conda_timestamp(std::uint64_t timestamp) -> std::uint64_t + { + return (timestamp > MAX_CONDA_TIMESTAMP) ? (timestamp / 1000) : timestamp; + } + void set_solvable( solv::ObjPool& pool, solv::ObjSolvableView solv, diff --git a/libmamba/tests/src/solver/libsolv/test_database.cpp b/libmamba/tests/src/solver/libsolv/test_database.cpp index ae45ae86dc..0a52d2a7dc 100644 --- a/libmamba/tests/src/solver/libsolv/test_database.cpp +++ b/libmamba/tests/src/solver/libsolv/test_database.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -281,6 +282,67 @@ namespace REQUIRE(repo1.has_value()); REQUIRE(repo1->package_count() < 33); REQUIRE(repo1->package_count() > 0); + + auto db_unfiltered = libsolv::Database({}, { matchspec_parser }); + auto unfiltered_repo = db_unfiltered.add_repo_from_repodata_json( + repodata, + "https://conda.anaconda.org/conda-forge/linux-64", + "conda-forge", + libsolv::PipAsPythonDependency::No + ); + REQUIRE(unfiltered_repo.has_value()); + REQUIRE(unfiltered_repo->package_count() > repo1->package_count()); + } + + SECTION("exclude_newer_timestamp prefers indexed_timestamp from repodata JSON") + { + auto tmp_dir = TemporaryDirectory(); + const auto repodata = tmp_dir.path() / "repodata.json"; + std::ofstream out_file(repodata.std_path()); + out_file << R"({ + "packages": { + "excluded-pkg-1.0-bld.tar.bz2": { + "name": "excluded-pkg", + "version": "1.0", + "build": "bld", + "build_number": 0, + "subdir": "linux-64", + "depends": [], + "timestamp": 1000, + "indexed_timestamp": 3000 + }, + "included-pkg-1.0-bld.tar.bz2": { + "name": "included-pkg", + "version": "1.0", + "build": "bld", + "build_number": 0, + "subdir": "linux-64", + "depends": [], + "timestamp": 3000, + "indexed_timestamp": 1000 + } + }, + "packages.conda": {} + })"; + out_file.close(); + + auto db_filtered = libsolv::Database( + {}, + { matchspec_parser, /* exclude_newer_timestamp= */ std::uint64_t(2000) } + ); + 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 == "included-pkg"); } + ); } SECTION("Add repo from repodata with extra pip") diff --git a/libmambapy/tests/test_solver_libsolv.py b/libmambapy/tests/test_solver_libsolv.py index 744850b539..8c006d2edf 100644 --- a/libmambapy/tests/test_solver_libsolv.py +++ b/libmambapy/tests/test_solver_libsolv.py @@ -225,23 +225,25 @@ def test_Database_exclude_newer_timestamp_repodata(tmp_path): json.dump( { "packages": { - "old-pkg-1.0-bld.tar.bz2": { - "name": "old-pkg", + "excluded-pkg-1.0-bld.tar.bz2": { + "name": "excluded-pkg", "version": "1.0", "build": "bld", "build_number": 0, "subdir": "linux-64", "depends": [], "timestamp": 1000, + "indexed_timestamp": 3000, }, - "new-pkg-1.0-bld.tar.bz2": { - "name": "new-pkg", + "included-pkg-1.0-bld.tar.bz2": { + "name": "included-pkg", "version": "1.0", "build": "bld", "build_number": 0, "subdir": "linux-64", "depends": [], "timestamp": 3000, + "indexed_timestamp": 1000, }, }, "packages.conda": {}, @@ -260,6 +262,8 @@ def test_Database_exclude_newer_timestamp_repodata(tmp_path): ) assert repo is not None assert repo.package_count() == 1 + pkgs = db.packages_in_repo(repo) + assert pkgs[0].name == "included-pkg" @pytest.fixture From de2695636bc81b251f59c132ffd084d7c5bb7f6e Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Wed, 10 Jun 2026 11:07:48 +0200 Subject: [PATCH 06/27] feat: Wire global exclude_newer config and CLI to solver Signed-off-by: Julien Jerphanion Co-authored-by: Cursor --- 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 c7f1d0f6a1..ec618d04ff 100644 --- a/libmamba/CMakeLists.txt +++ b/libmamba/CMakeLists.txt @@ -226,6 +226,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 3c06b18f7e..7d282a59f0 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 305e1debb3144791e548744f0120415465d97330 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Wed, 10 Jun 2026 15:52:44 +0200 Subject: [PATCH 07/27] 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 e7e139978e454fee2722984c542e3ce4fb846cf6 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Wed, 10 Jun 2026 15:57:47 +0200 Subject: [PATCH 08/27] 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 06d68d4251e8c24e8118f851a5e3c8885a135cfa Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Wed, 10 Jun 2026 16:09:32 +0200 Subject: [PATCH 09/27] 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 7d282a59f0..1d3803e587 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 9822a0e7ef..8b00c034cf 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 878c688d4a..1bfbf13d71 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" @@ -73,7 +74,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 3ab8182798e44dcab9f6c2e68e4bd86f074bf85f Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Wed, 10 Jun 2026 17:26:53 +0200 Subject: [PATCH 10/27] 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 33bb5292b75fae272fc18eeb475a2d7546891cdd Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 16 Jun 2026 11:13:41 +0200 Subject: [PATCH 11/27] 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 1d3803e587..060b9903be 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 8b00c034cf..1e3db96b42 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 1bfbf13d71..6a125e5dd5 100644 --- a/libmamba/src/solver/libsolv/helpers.hpp +++ b/libmamba/src/solver/libsolv/helpers.hpp @@ -74,7 +74,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 42598702402e91c5a23c7fdcb24873a5c37f34cf Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 16 Jun 2026 11:21:03 +0200 Subject: [PATCH 12/27] 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 50aa3f7442f10691bfbf39216f59f1b3607ea57e Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 16 Jun 2026 11:28:10 +0200 Subject: [PATCH 13/27] 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 ec618d04ff..e0d0860ce3 100644 --- a/libmamba/CMakeLists.txt +++ b/libmamba/CMakeLists.txt @@ -382,6 +382,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 72f48f4f120aa6673122e21994f3516e1a4edff3 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 16 Jun 2026 11:31:29 +0200 Subject: [PATCH 14/27] 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 52c137f1e8331b40721c6e148f6cb5254cdf35c6 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 16 Jun 2026 11:38:43 +0200 Subject: [PATCH 15/27] 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 c1a8f6fbcec1c0e7ddba62cfd5428328e7ba98a0 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 16 Jun 2026 11:42:16 +0200 Subject: [PATCH 16/27] 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 d6c8cc7c78726fea6b289ccc8cadbf2dde2a10f2 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 16 Jun 2026 11:50:38 +0200 Subject: [PATCH 17/27] 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 97ac2a5b2237f88316a9dae4c5cea8d3db4cae06 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Wed, 17 Jun 2026 11:15:31 +0200 Subject: [PATCH 18/27] 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 e0d0860ce3..4d6c977867 100644 --- a/libmamba/CMakeLists.txt +++ b/libmamba/CMakeLists.txt @@ -226,8 +226,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 @@ -382,8 +382,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 a87288f3f8b2ab93d18c48223982b4397c916bb7 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 30 Jun 2026 14:17:02 +0200 Subject: [PATCH 19/27] 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 be7de64c07b1ae15a0d9c0d8e499465c640d83e4 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 30 Jun 2026 14:57:17 +0200 Subject: [PATCH 20/27] 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 558322d6884e91c1f8691314a7481053cd2bc57d Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 30 Jun 2026 16:35:57 +0200 Subject: [PATCH 21/27] 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 95755715d79a2caf17a5ce905f5b34159f4aeff4 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 30 Jun 2026 17:00:05 +0200 Subject: [PATCH 22/27] 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 2157ec5ed6de9992dd30f630ff91f0e173352914 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 30 Jun 2026 17:05:06 +0200 Subject: [PATCH 23/27] 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 82268191058fe7c3530747610ccef0e1b2b2649a Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Tue, 30 Jun 2026 17:08:18 +0200 Subject: [PATCH 24/27] 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 ec7daf551b14c544ca6cd5ec3a9ec91883c4e798 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Wed, 1 Jul 2026 09:35:14 +0200 Subject: [PATCH 25/27] 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 4d6c977867..a27503e459 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) @@ -722,6 +760,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() @@ -798,6 +841,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 e92cb1ff408d6841857429486ac6cd859df64c73 Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Wed, 1 Jul 2026 11:29:21 +0200 Subject: [PATCH 26/27] 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 851cf0d38f..186e9f5ac3 100644 --- a/.github/workflows/static_build.yml +++ b/.github/workflows/static_build.yml @@ -70,6 +70,23 @@ jobs: run: | cd micromamba-feedstock/ sed -i '' '/conda_forge_output_validation/d' conda-forge.yml + - 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 e197fc43d945f67da7c73af7564976577d49a05a Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Wed, 1 Jul 2026 11:30:30 +0200 Subject: [PATCH 27/27] 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.