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/.github/workflows/static_build.yml b/.github/workflows/static_build.yml index 9a872c71cf..31cf9addf4 100644 --- a/.github/workflows/static_build.yml +++ b/.github/workflows/static_build.yml @@ -85,6 +85,23 @@ jobs: ) ) PY + - name: Add howardhinnant_date-static 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-static" in line for line in lines): + raise SystemExit(0) + out = [] + for line in lines: + out.append(line) + if line.strip().startswith("- lz4-c-static"): + out.append(" - howardhinnant_date-static") + meta.write_text("\n".join(out) + "\n") + PY - name: Checkout mamba branch uses: actions/checkout@v7 with: diff --git a/dev/environment-dev.yml b/dev/environment-dev.yml index 3fe56f778b..1bb985081c 100644 --- a/dev/environment-dev.yml +++ b/dev/environment-dev.yml @@ -25,6 +25,8 @@ dependencies: - spdlog >=1.16.0 - yaml-cpp >=0.8.0 - sel(win): winreg + # Replacement of `std::chrono::parse` (P0355R7) missing in libc++, see: https://github.com/llvm/llvm-project/issues/166051 + - sel(osx): howardhinnant_date # libmamba test dependencies - catch2 # micromamba dependencies diff --git a/dev/environment-micromamba-static.yml b/dev/environment-micromamba-static.yml index 703333834d..cf2ef670c1 100644 --- a/dev/environment-micromamba-static.yml +++ b/dev/environment-micromamba-static.yml @@ -38,6 +38,8 @@ dependencies: - zlib - libnghttp2-static - lz4-c-static + # Replacement of `std::chrono::parse` (P0355R7) missing in libc++, see: https://github.com/llvm/llvm-project/issues/166051 + - sel(osx): howardhinnant_date-static # libmamba test dependencies - catch2 # micromamba dependencies 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. diff --git a/libmamba/CMakeLists.txt b/libmamba/CMakeLists.txt index 2f10878112..30b6e7931c 100644 --- a/libmamba/CMakeLists.txt +++ b/libmamba/CMakeLists.txt @@ -9,6 +9,45 @@ 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) + # CMAKE_REQUIRED_FLAGS is a string (not a CMake list); list(APPEND) would inject ';'. + set(CMAKE_REQUIRED_FLAGS_BACKUP "${CMAKE_REQUIRED_FLAGS}") + if(MSVC) + set(CMAKE_REQUIRED_FLAGS "${CMAKE_REQUIRED_FLAGS} /std:c++20") + else() + set(CMAKE_REQUIRED_FLAGS "${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) @@ -228,6 +267,7 @@ set( ${LIBMAMBA_SOURCE_DIR}/core/env_lockfile_mambajs.cpp ${LIBMAMBA_SOURCE_DIR}/core/environments_manager.cpp ${LIBMAMBA_SOURCE_DIR}/core/error_handling.cpp + ${LIBMAMBA_SOURCE_DIR}/core/exclude_newer.cpp ${LIBMAMBA_SOURCE_DIR}/core/execution.cpp ${LIBMAMBA_SOURCE_DIR}/core/fsutil.cpp ${LIBMAMBA_SOURCE_DIR}/core/history.cpp @@ -384,6 +424,7 @@ set( ${LIBMAMBA_INCLUDE_DIR}/mamba/core/env_lockfile.hpp ${LIBMAMBA_INCLUDE_DIR}/mamba/core/environments_manager.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 @@ -736,6 +777,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() @@ -812,6 +858,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/api/configuration.hpp b/libmamba/include/mamba/api/configuration.hpp index 861fdf9c77..1981d46962 100644 --- a/libmamba/include/mamba/api/configuration.hpp +++ b/libmamba/include/mamba/api/configuration.hpp @@ -633,6 +633,20 @@ namespace mamba node[name] = values; } + template <> + inline void ConfigurableImpl>>::dump_json( + nlohmann::json& node, + const std::string& name + ) const + { + nlohmann::json object = nlohmann::json::object(); + for (const auto& [key, value] : m_value) + { + object[key] = value; + } + node[name] = object; + } + template void ConfigurableImpl::set_rc_value(const T& value, const std::string& source) { diff --git a/libmamba/include/mamba/api/configuration_impl.hpp b/libmamba/include/mamba/api/configuration_impl.hpp index cf28980f42..4bded56b1f 100644 --- a/libmamba/include/mamba/api/configuration_impl.hpp +++ b/libmamba/include/mamba/api/configuration_impl.hpp @@ -3,6 +3,7 @@ #include #include +#include #include #include @@ -170,6 +171,45 @@ namespace mamba { return true; } + + // Map-shaped config (e.g. exclude_newer_package): stored as vector of pairs for + // duplicate-key / order control, but configured as a YAML/JSON object — not a sequence. + // Must not use Source>::deserialize, which wraps the value in `[...]`. + template <> + struct Source>> + { + using value_type = std::vector>; + + static std::vector default_value(const value_type& /* init */) + { + return { "default" }; + } + + static void merge( + const std::map& values, + const std::vector& sources, + value_type& value, + std::vector& source + ) + { + source = sources; + value = values.at(sources.front()); + } + + static value_type deserialize(const std::string& value) + { + if (value.empty()) + { + return {}; + } + return YAML::Load(value).as(); + } + + static bool is_sequence() + { + return false; + } + }; } } @@ -199,6 +239,37 @@ namespace YAML } }; + template <> + struct convert>> + { + static Node encode(const std::vector>& rhs) + { + // Empty default Node is Null; print-config / YAML consumers need an empty Map. + Node node(NodeType::Map); + node.SetStyle(YAML::EmitterStyle::Block); + for (const auto& [key, value] : rhs) + { + node[key] = value; + } + return node; + } + + static bool decode(const Node& node, std::vector>& rhs) + { + if (!node.IsMap()) + { + return false; + } + rhs.clear(); + rhs.reserve(node.size()); + for (const auto& entry : node) + { + rhs.emplace_back(entry.first.as(), entry.second.as()); + } + return true; + } + }; + template <> struct convert { diff --git a/libmamba/include/mamba/core/context.hpp b/libmamba/include/mamba/core/context.hpp index 71160c2e1f..0db99ea914 100644 --- a/libmamba/include/mamba/core/context.hpp +++ b/libmamba/include/mamba/core/context.hpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include "mamba/core/context_params.hpp" @@ -131,6 +132,20 @@ namespace mamba // solver options solver::Request::Flags solver_flags = {}; + /** + * Exclude packages published more recently than this duration or date. + * + * See Configurable ``exclude_newer``; resolved into ``ExcludeNewerPolicy`` at solve time. + */ + std::string exclude_newer; + + /** + * Per-package overrides for ``exclude_newer`` (JSON dictionary on CLI / env vars). + * + * See Configurable ``exclude_newer_package``. + */ + std::vector> exclude_newer_package; + // add start menu shortcuts on Windows (not implemented on Linux / macOS) bool shortcuts = true; diff --git a/libmamba/include/mamba/core/detail/chrono_parse.hpp b/libmamba/include/mamba/core/detail/chrono_parse.hpp new file mode 100644 index 0000000000..cb307a55c6 --- /dev/null +++ b/libmamba/include/mamba/core/detail/chrono_parse.hpp @@ -0,0 +1,49 @@ +// 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. + * + * TODO: Drop the Howard Hinnant ``date`` fallback once ``std::chrono::parse`` is + * available on all supported platforms (notably macOS/libc++). + * + * Returns ``std::nullopt`` when parsing fails or trailing characters remain. + */ + template + [[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 new file mode 100644 index 0000000000..0a310a3637 --- /dev/null +++ b/libmamba/include/mamba/core/exclude_newer.hpp @@ -0,0 +1,166 @@ +// 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 +#include +#include +#include +#include +#include + +namespace mamba +{ + /** + * 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>; + + /** + * Resolved `exclude_newer` policy used by the solver / database. + * + * Holds Unix-second cutoffs only. Raw configuration strings live on `Context` and are + * resolved via `resolve_exclude_newer_policy`. + * + * When set, packages with a policy timestamp newer than the effective cutoff are excluded + * during repodata loading. For repodata JSON, `indexed_timestamp` is preferred over + * `timestamp` when both are present, matching conda's `--exclude-newer` semantics. + * Per-package overrides take precedence over the global cutoff; a package mapped to + * `std::nullopt` is exempt (`false` in config). The `.solv` cache path is not + * filtered; invalidate the cache when this policy changes. + * + * Background and cross-ecosystem tracking: + * https://github.com/conda/conda/issues/15759 + */ + struct ExcludeNewerPolicy + { + /** + * Resolved global cutoff timestamp in seconds. + */ + std::optional global = std::nullopt; + + /** + * Resolved per-package timestamp cutoffs. + */ + ExcludeNewerPackageCutoffs per_package = {}; + + /** Return whether no cutoff is configured. */ + [[nodiscard]] auto empty() const -> bool + { + return !global.has_value() && per_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; + }; + + /** + * Resolve raw per-package `exclude_newer` configuration values. + * + * @param exclude_newer_package Per-package name/value pairs (duration, date, or + * `false`). + * @param now_seconds Reference time for relative durations, in Unix seconds. + * + * @throws mamba_error when a non-`false` value cannot be parsed. + */ + [[nodiscard]] auto resolve_exclude_newer_package_cutoffs( + const std::vector>& exclude_newer_package, + std::uint64_t now_seconds + ) -> ExcludeNewerPackageCutoffs; + + /** + * Resolve raw `exclude_newer` configuration into a policy for the database. + * + * @param exclude_newer Global cutoff configuration string. + * @param exclude_newer_package Per-package overrides. + * @param now_seconds Reference time for relative durations, in Unix seconds. + * + * @throws mamba_error when a value cannot be parsed. + */ + [[nodiscard]] auto resolve_exclude_newer_policy( + std::string_view exclude_newer, + const std::vector>& exclude_newer_package, + std::uint64_t now_seconds + ) -> ExcludeNewerPolicy; + + /** + * Resolve a global `exclude_newer` configuration value to an absolute Unix + * timestamp cutoff in seconds. + * + * Durations (`7d`, `P7D`, plain seconds) resolve relative to `now_seconds`. + * Date-only values (`YYYY-MM-DD`) resolve to the start of the next UTC day. + * Datetimes resolve to the given instant (naive values are UTC). Zero durations + * (`0`, `0d`, `P0D`) resolve to `now_seconds`. + * + * @param value Raw configuration string. + * @param now_seconds Reference time for relative durations, in Unix seconds. + * @param package_name When resolving a per-package override, used in parse-failure warnings. + * + * Returns `std::nullopt` when `value` is empty/whitespace-only. + * + * @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::string_view package_name = {} + ) -> 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 mamba_error when the value starts with `P` but has no components. + */ + [[nodiscard]] auto parse_iso8601_duration_seconds(std::string_view value) + -> std::optional; + + /** + * Parse a compact duration (`(n)y(n)M(n)w(n)d(n)h(n)m(n)s`, e.g. `7d`, `3d12h`) + * 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 + +#include "mamba/core/detail/chrono_parse.hpp" + +#endif diff --git a/libmamba/include/mamba/solver/libsolv/database.hpp b/libmamba/include/mamba/solver/libsolv/database.hpp index 41b828a07e..d25e79b943 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" @@ -65,7 +66,7 @@ namespace mamba::solver::libsolv struct Settings { MatchSpecParser matchspec_parser = MatchSpecParser::Libsolv; - std::optional exclude_newer_timestamp = std::nullopt; + ExcludeNewerPolicy exclude_newer_policy = {}; }; using logger_type = std::function; 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/api/configuration.cpp b/libmamba/src/api/configuration.cpp index f1e2ae3251..0cdccc0c3e 100644 --- a/libmamba/src/api/configuration.cpp +++ b/libmamba/src/api/configuration.cpp @@ -1719,6 +1719,31 @@ 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. Package-specific values + take precedence over the global cutoff. This must + be expressed as a JSON dictionary with package names as keys (e.g. + '{"numpy": "false", "pandas": "7d"}'.)"))); + insert(Configurable("order_solver_request", &m_context.solver_flags.order_request) .group("Solver") .set_rc_configurable() diff --git a/libmamba/src/api/install.cpp b/libmamba/src/api/install.cpp index 6b145ebe1f..7fe35180b3 100644 --- a/libmamba/src/api/install.cpp +++ b/libmamba/src/api/install.cpp @@ -740,7 +740,12 @@ namespace mamba bool remove_prefix_on_failure ) { - auto database = make_solver_database(ctx.experimental_matchspec_parsing, channel_context); + auto database = make_solver_database( + channel_context, + ctx.experimental_matchspec_parsing, + ctx.exclude_newer, + ctx.exclude_newer_package + ); init_channels(ctx, channel_context); // Some use cases provide a list of explicit specs, but an empty @@ -1232,7 +1237,12 @@ namespace mamba MultiPackageCache package_caches{ ctx.pkgs_dirs, ctx.validation_params }; - solver::libsolv::Database db{ channel_context.params() }; + auto db = make_solver_database( + channel_context, + ctx.experimental_matchspec_parsing, + ctx.exclude_newer, + ctx.exclude_newer_package + ); 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..1d5bf34e79 100644 --- a/libmamba/src/api/remove.cpp +++ b/libmamba/src/api/remove.cpp @@ -126,7 +126,12 @@ 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( + channel_context, + ctx.experimental_matchspec_parsing, + ctx.exclude_newer, + ctx.exclude_newer_package + ); 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..a8a3ef4821 100644 --- a/libmamba/src/api/repoquery.cpp +++ b/libmamba/src/api/repoquery.cpp @@ -57,13 +57,12 @@ 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( + channel_context, + ctx.experimental_matchspec_parsing, + ctx.exclude_newer, + ctx.exclude_newer_package + ); 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..edf9132836 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,15 +520,40 @@ namespace mamba return outcome; } - solver::libsolv::Database - make_solver_database(bool experimental_matchspec_parsing, ChannelContext& channel_context) + namespace + { + [[nodiscard]] auto make_database_settings( + bool experimental_matchspec_parsing, + std::string_view exclude_newer, + const std::vector>& exclude_newer_package + ) -> solver::libsolv::Database::Settings + { + const auto now = static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch() + ) + .count() + ); + const auto matchspec_parser = experimental_matchspec_parsing + ? solver::libsolv::MatchSpecParser::Mamba + : solver::libsolv::MatchSpecParser::Libsolv; + return { + matchspec_parser, + resolve_exclude_newer_policy(exclude_newer, exclude_newer_package, now), + }; + } + } // namespace + + solver::libsolv::Database make_solver_database( + ChannelContext& channel_context, + bool experimental_matchspec_parsing, + std::string_view exclude_newer, + const std::vector>& exclude_newer_package + ) { solver::libsolv::Database db{ channel_context.params(), - { - experimental_matchspec_parsing ? solver::libsolv::MatchSpecParser::Mamba - : solver::libsolv::MatchSpecParser::Libsolv, - }, + make_database_settings(experimental_matchspec_parsing, exclude_newer, exclude_newer_package), }; add_logger_to_database(db); return db; @@ -575,7 +602,20 @@ 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.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"; + } + + auto db = make_solver_database( + channel_context, + ctx.experimental_matchspec_parsing, + ctx.exclude_newer, + ctx.exclude_newer_package + ); 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..ad74687ca7 100644 --- a/libmamba/src/api/utils.hpp +++ b/libmamba/src/api/utils.hpp @@ -8,9 +8,11 @@ #define MAMBA_UTILS_HPP #include +#include #include #include #include +#include #include #include @@ -123,8 +125,12 @@ 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); + solver::libsolv::Database make_solver_database( + ChannelContext& channel_context, + bool experimental_matchspec_parsing, + std::string_view exclude_newer = {}, + const std::vector>& exclude_newer_package = {} + ); /** * 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..debb94ee49 --- /dev/null +++ b/libmamba/src/core/exclude_newer.cpp @@ -0,0 +1,419 @@ +// 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 + +#include "mamba/core/detail/chrono_parse.hpp" +#include "mamba/core/error_handling.hpp" +#include "mamba/core/exclude_newer.hpp" +#include "mamba/util/string.hpp" + +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()); + } + + /** Case-insensitive equality for ASCII strings. */ + [[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); } + ); + } + + /** 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 = {}) + { + 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)"; + + if (package_name.empty()) + { + throw mamba_error( + fmt::format("Could not parse exclude_newer value '{}'; {}", value, duration_hint), + mamba_error_code::incorrect_usage + ); + } + throw mamba_error( + fmt::format( + "Could not parse exclude_newer_package value for package '{}' ('{}'); {}, or false", + package_name, + value, + duration_hint + ), + mamba_error_code::incorrect_usage + ); + } + + /** Parse ``value`` as an unsigned integer that must consume the entire string. */ + [[nodiscard]] auto parse_fixed_uint(std::string_view value) -> std::optional + { + 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()) + { + return std::nullopt; + } + return number; + } + + /** Parse a plain integer duration in seconds (e.g. ``3600``). */ + [[nodiscard]] auto parse_plain_seconds(std::string_view value) + -> std::optional + { + if (auto seconds = parse_fixed_uint(value)) + { + return std::chrono::seconds{ static_cast(*seconds) }; + } + return std::nullopt; + } + + /** + * 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 + { + if (auto seconds = parse_plain_seconds(value)) + { + return seconds; + } + if (auto seconds = detail::parse_iso8601_duration_seconds(value)) + { + return seconds; + } + return detail::parse_compact_duration_seconds(value); + } + + /** + * 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) + { + return std::nullopt; + } + + const auto day = detail::parse_chrono(value, "%F"); + if (!day) + { + return std::nullopt; + } + + return CutoffInstant{ *day + std::chrono::days{ 1 } }; + } + + /** + * 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] != ':') + { + return std::nullopt; + } + + if (auto instant = detail::parse_chrono(value, "%FT%T%Ez")) + { + return CutoffInstant{ instant->time_since_epoch() }; + } + if (auto instant = detail::parse_chrono(value, "%FT%TZ")) + { + return CutoffInstant{ instant->time_since_epoch() }; + } + if (auto instant = detail::parse_chrono(value, "%FT%T")) + { + return CutoffInstant{ instant->time_since_epoch() }; + } + return std::nullopt; + } + + /** 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 > now.time_since_epoch()) + { + return CutoffInstant{}; + } + return now - duration; + } + + template + [[nodiscard]] constexpr std::uint64_t unit_seconds(Duration unit) + { + return static_cast( + std::chrono::duration_cast(unit).count() + ); + } + + constexpr std::uint64_t seconds_per_year = unit_seconds(std::chrono::years{ 1 }); + constexpr std::uint64_t seconds_per_month = unit_seconds(std::chrono::months{ 1 }); + constexpr std::uint64_t seconds_per_week = unit_seconds(std::chrono::weeks{ 1 }); + constexpr std::uint64_t seconds_per_day = unit_seconds(std::chrono::days{ 1 }); + constexpr std::uint64_t seconds_per_hour = unit_seconds(std::chrono::hours{ 1 }); + constexpr std::uint64_t seconds_per_minute = unit_seconds(std::chrono::minutes{ 1 }); + + } // namespace + + namespace detail + { + auto parse_iso8601_duration_seconds(std::string_view value) + -> std::optional + { + // For a definition of the ISO 8601 duration format, see: + // https://docs.digi.com/resources/documentation/digidocs/90001488-13/reference/r_iso_8601_duration_format.htm + // but mind the missing "(n)W" segment for weeks! Weeks are supported here folloiwing: + // + // P(n)Y(n)M(n)W(n)DT(n)H(n)M(n)S + // + static const std::regex iso8601_duration{ + R"(^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$)", + std::regex_constants::icase, + }; + + 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) }; + } + + 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]))" }; + + if (value.empty() || !std::regex_match(value.begin(), value.end(), compact_duration)) + { + return std::nullopt; + } + + std::chrono::seconds total{ 0 }; + const std::string input(value); + const std::sregex_iterator end; + for (std::sregex_iterator it(input.begin(), input.end(), compact_segment); it != end; ++it) + { + const auto amount = parse_fixed_uint((*it)[1].str()); + if (!amount) + { + return std::nullopt; + } + + switch ((*it)[2].str().front()) + { + case 'y': + total += std::chrono::duration_cast(std::chrono::years{ + static_cast(*amount) }); + break; + case 'M': + total += std::chrono::duration_cast( + std::chrono::months{ static_cast(*amount) } + ); + break; + case 'w': + total += std::chrono::duration_cast(std::chrono::weeks{ + static_cast(*amount) }); + break; + case 'd': + total += std::chrono::duration_cast(std::chrono::days{ + static_cast(*amount) }); + break; + case 'h': + total += std::chrono::duration_cast(std::chrono::hours{ + static_cast(*amount) }); + break; + case 'm': + total += std::chrono::duration_cast( + std::chrono::minutes{ static_cast(*amount) } + ); + break; + case 's': + total += std::chrono::seconds{ + static_cast(*amount) + }; + break; + default: + return std::nullopt; + } + } + + return 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, + 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; + } + + const auto now = CutoffInstant{ std::chrono::seconds{ now_seconds } }; + + if (auto duration = parse_duration_seconds(value)) + { + return to_unix_seconds(duration_cutoff(*duration, now)); + } + + if (auto instant = parse_date_only(value)) + { + return to_unix_seconds(*instant); + } + + if (auto instant = parse_datetime(value)) + { + return to_unix_seconds(*instant); + } + + throw_invalid_exclude_newer(value, package_name); + } + + /** 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(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 + { + if (const auto cutoff = cutoff_for(package_name)) + { + return pkg_timestamp > *cutoff; + } + return false; + } + + /** Resolve each entry in ``exclude_newer_package`` to a cutoff or exemption. */ + auto resolve_exclude_newer_package_cutoffs( + const std::vector>& exclude_newer_package, + std::uint64_t now_seconds + ) -> ExcludeNewerPackageCutoffs + { + auto out = ExcludeNewerPackageCutoffs{}; + for (const auto& [name, value] : exclude_newer_package) + { + const auto trimmed = util::strip_if(std::string_view{ value }, is_space); + if (equals_ci(trimmed, "false")) + { + out.emplace(name, std::nullopt); + } + else + { + out.emplace(name, resolve_exclude_newer_cutoff(trimmed, now_seconds, name)); + } + } + return out; + } + + auto resolve_exclude_newer_policy( + std::string_view exclude_newer, + const std::vector>& exclude_newer_package, + std::uint64_t now_seconds + ) -> ExcludeNewerPolicy + { + return { + /* .global= */ exclude_newer.empty() + ? std::nullopt + : resolve_exclude_newer_cutoff(exclude_newer, now_seconds), + /* .per_package= */ resolve_exclude_newer_package_cutoffs(exclude_newer_package, now_seconds), + }; + } + +} // namespace mamba diff --git a/libmamba/src/core/package_database_loader.cpp b/libmamba/src/core/package_database_loader.cpp index edc3b334d3..cedc59cc21 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() && ctx.exclude_newer_package.empty()) { auto maybe_repo = subdir.valid_libsolv_cache_path().and_then( [&](fs::u8path&& solv_file) diff --git a/libmamba/src/solver/libsolv/database.cpp b/libmamba/src/solver/libsolv/database.cpp index acb8bb1011..4855a084a1 100644 --- a/libmamba/src/solver/libsolv/database.cpp +++ b/libmamba/src/solver/libsolv/database.cpp @@ -184,7 +184,7 @@ namespace mamba::solver::libsolv package_types, settings().matchspec_parser, verify_artifacts, - settings().exclude_newer_timestamp + settings().exclude_newer_policy ); } @@ -262,12 +262,9 @@ 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..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, - std::optional exclude_newer_timestamp = std::nullopt + ExcludeNewerPolicy 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 + ExcludeNewerPolicy 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 + ExcludeNewerPolicy 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 + ExcludeNewerPolicy 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 + ExcludeNewerPolicy exclude_newer_policy ) -> expected_t { LOG_INFO << "Reading repodata.json file " << filename << " for repo " << repo.name() @@ -766,7 +766,7 @@ namespace mamba::solver::libsolv pkgs, json_signatures, ms_parser, - exclude_newer_timestamp + exclude_newer_policy ); } if (auto pkgs = repodata_doc["packages"]; !pkgs.error()) @@ -781,7 +781,7 @@ namespace mamba::solver::libsolv json_signatures, added, ms_parser, - exclude_newer_timestamp + exclude_newer_policy ); } } @@ -799,7 +799,7 @@ namespace mamba::solver::libsolv pkgs, json_signatures, ms_parser, - exclude_newer_timestamp + exclude_newer_policy ); } @@ -815,7 +815,7 @@ namespace mamba::solver::libsolv pkgs, json_signatures, ms_parser, - exclude_newer_timestamp + exclude_newer_policy ); } } diff --git a/libmamba/src/solver/libsolv/helpers.hpp b/libmamba/src/solver/libsolv/helpers.hpp index 459236e096..a01267ca5a 100644 --- a/libmamba/src/solver/libsolv/helpers.hpp +++ b/libmamba/src/solver/libsolv/helpers.hpp @@ -13,6 +13,7 @@ #include #include "mamba/core/error_handling.hpp" +#include "mamba/core/exclude_newer.hpp" #include "mamba/solver/libsolv/parameters.hpp" #include "mamba/solver/request.hpp" #include "mamba/solver/solution.hpp" @@ -72,7 +73,7 @@ namespace mamba::solver::libsolv PackageTypes types, MatchSpecParser parser, bool verify_artifacts, - std::optional exclude_newer_timestamp = std::nullopt + ExcludeNewerPolicy exclude_newer_policy = {} ) -> expected_t; [[nodiscard]] auto read_solv( diff --git a/libmamba/tests/CMakeLists.txt b/libmamba/tests/CMakeLists.txt index db0e38d240..661f505d15 100644 --- a/libmamba/tests/CMakeLists.txt +++ b/libmamba/tests/CMakeLists.txt @@ -96,6 +96,7 @@ set( src/core/test_env_file_reading.cpp src/core/test_env_lockfile.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 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..6c955499d2 --- /dev/null +++ b/libmamba/tests/src/core/test_exclude_newer.cpp @@ -0,0 +1,335 @@ +// 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 "mamba/core/error_handling.hpp" +#include "mamba/core/exclude_newer.hpp" + +using namespace mamba; + +namespace +{ + template + [[nodiscard]] constexpr std::uint64_t unit_seconds(Duration unit) + { + return static_cast( + std::chrono::duration_cast(unit).count() + ); + } + + constexpr std::uint64_t seconds_per_year = unit_seconds(std::chrono::years{ 1 }); + constexpr std::uint64_t seconds_per_month = unit_seconds(std::chrono::months{ 1 }); + constexpr std::uint64_t seconds_per_week = unit_seconds(std::chrono::weeks{ 1 }); + constexpr std::uint64_t seconds_per_day = unit_seconds(std::chrono::days{ 1 }); + constexpr std::uint64_t seconds_per_hour = unit_seconds(std::chrono::hours{ 1 }); + constexpr std::uint64_t seconds_per_minute = unit_seconds(std::chrono::minutes{ 1 }); +} + +namespace +{ + TEST_CASE("resolve_exclude_newer_cutoff") + { + constexpr std::uint64_t now = 1'700'000'000; + + SECTION("empty values disable the policy") + { + 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") + { + 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 * seconds_per_day); + REQUIRE(resolve_exclude_newer_cutoff("1w", now) == now - seconds_per_week); + REQUIRE( + resolve_exclude_newer_cutoff("3d12h", now) + == now - (3 * seconds_per_day + 12 * seconds_per_hour) + ); + REQUIRE(resolve_exclude_newer_cutoff("1y", now) == now - seconds_per_year); + REQUIRE(resolve_exclude_newer_cutoff("6M", now) == now - 6 * seconds_per_month); + REQUIRE( + resolve_exclude_newer_cutoff("1y6M7d", now) + == now - (seconds_per_year + 6 * seconds_per_month + 7 * seconds_per_day) + ); + } + + SECTION("ISO 8601 durations resolve relative to now") + { + REQUIRE(resolve_exclude_newer_cutoff("P7D", now) == now - 7 * seconds_per_day); + REQUIRE(resolve_exclude_newer_cutoff("PT24H", now) == now - 24 * seconds_per_hour); + REQUIRE( + resolve_exclude_newer_cutoff("P1DT12H", now) + == now - (seconds_per_day + 12 * seconds_per_hour) + ); + REQUIRE(resolve_exclude_newer_cutoff("P1Y", now) == now - seconds_per_year); + REQUIRE(resolve_exclude_newer_cutoff("P6M", now) == now - 6 * seconds_per_month); + REQUIRE(resolve_exclude_newer_cutoff("PT1M", now) == now - seconds_per_minute); + REQUIRE( + resolve_exclude_newer_cutoff("P3Y6M4DT12H30M5S", now) + == now + - (3 * seconds_per_year + 6 * seconds_per_month + 4 * seconds_per_day + + 12 * seconds_per_hour + 30 * seconds_per_minute + 5) + ); + } + + 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("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); + 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), mamba_error); + REQUIRE_THROWS_AS(resolve_exclude_newer_cutoff("P", now), mamba_error); + } + } + + 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") + { + 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("parse_compact_duration_seconds") + { + constexpr std::uint64_t y = seconds_per_year; + constexpr std::uint64_t mon = seconds_per_month; + constexpr std::uint64_t w = seconds_per_week; + constexpr std::uint64_t d = seconds_per_day; + constexpr std::uint64_t h = seconds_per_hour; + constexpr std::uint64_t min = seconds_per_minute; + + const auto sec = [](std::int64_t n) { return std::chrono::seconds{ n }; }; + const auto parse = [](std::string_view value) + { 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; + + 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 * seconds_per_day); + } + } + + TEST_CASE("ExcludeNewerPolicy cutoff behavior") + { + constexpr std::uint64_t global_cutoff = 2000; + + const ExcludeNewerPolicy policy{ + /* .global= */ global_cutoff, + /* .per_package= */ + { + { "exempt-pkg", std::nullopt }, + { "custom-pkg", 1500 }, + }, + }; + + 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); + 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..1c6b5a0b0e 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" @@ -196,7 +197,7 @@ namespace const std::uint64_t cutoff = 2000; auto db_filtered = libsolv::Database( {}, - { matchspec_parser, /* exclude_newer_timestamp= */ cutoff } + { matchspec_parser, ExcludeNewerPolicy{ /* .global= */ cutoff } } ); auto old_pkg = specs::PackageInfo(); @@ -224,7 +225,7 @@ namespace const std::uint64_t cutoff = 2000000000; auto db_filtered = libsolv::Database( {}, - { matchspec_parser, /* exclude_newer_timestamp= */ cutoff } + { matchspec_parser, ExcludeNewerPolicy{ /* .global= */ cutoff } } ); auto ms_pkg = specs::PackageInfo(); @@ -271,7 +272,7 @@ namespace / "repodata/conda-forge-numpy-linux-64.json"; auto db_filtered = libsolv::Database( {}, - { matchspec_parser, /* exclude_newer_timestamp= */ std::uint64_t(1700000000) } + { matchspec_parser, ExcludeNewerPolicy{ /* .global= */ std::uint64_t(1700000000) } } ); auto repo1 = db_filtered.add_repo_from_repodata_json( repodata, @@ -294,6 +295,64 @@ 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, + ExcludeNewerPolicy{ + /* .global= */ std::uint64_t(2000), + /* .per_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(); @@ -328,7 +387,7 @@ namespace auto db_filtered = libsolv::Database( {}, - { matchspec_parser, /* exclude_newer_timestamp= */ std::uint64_t(2000) } + { matchspec_parser, ExcludeNewerPolicy{ /* .global= */ std::uint64_t(2000) } } ); auto repo1 = db_filtered.add_repo_from_repodata_json( repodata, @@ -345,6 +404,150 @@ 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, ExcludeNewerPolicy{ /* .global= */ 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, + ExcludeNewerPolicy{ + /* .global= */ cutoff_2019, + /* .per_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, + ExcludeNewerPolicy{ + /* .global= */ cutoff_2019, + /* .per_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 diff --git a/libmambapy/bindings/solver_libsolv.cpp b/libmambapy/bindings/solver_libsolv.cpp index f1f8a01ed1..6e6a72409a 100644 --- a/libmambapy/bindings/solver_libsolv.cpp +++ b/libmambapy/bindings/solver_libsolv.cpp @@ -151,7 +151,7 @@ namespace mambapy channel_params, Database::Settings{ matchspec_parser, - exclude_newer_timestamp, + ExcludeNewerPolicy{ /* .global= */ exclude_newer_timestamp }, } ); } 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" diff --git a/micromamba/src/common_options.cpp b/micromamba/src/common_options.cpp index 5c2de81717..6498da4b9a 100644 --- a/micromamba/src/common_options.cpp +++ b/micromamba/src/common_options.cpp @@ -397,6 +397,21 @@ 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& exclude_newer_package = config.at("exclude_newer_package"); + subcom->add_option_function( + "--exclude-newer-package", + [&exclude_newer_package](const std::string& value) + { exclude_newer_package.set_cli_yaml_value(value); }, + exclude_newer_package.description() + ); + auto& allow_softlinks = config.at("allow_softlinks"); subcom->add_flag( "--allow-softlinks,!--no-allow-softlinks", diff --git a/micromamba/tests/test_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" diff --git a/micromamba/tests/test_install.py b/micromamba/tests/test_install.py index 2e9b1a4711..eb3eb8657c 100644 --- a/micromamba/tests/test_install.py +++ b/micromamba/tests/test_install.py @@ -48,6 +48,21 @@ def teardown_method(cls): if Path(TestInstall.prefix).exists(): helpers.rmtree(TestInstall.prefix) + def test_exclude_newer_cli(self): + res = helpers.install( + "-p", + TestInstall.prefix, + "xtensor", + "--exclude-newer", + "7d", + "--exclude-newer-package", + "{numpy: 'false', pandas: '7d'}", + "--print-config-only", + ) + assert res["exclude_newer"] == "7d" + # `--print-config-only` returns YAML; the string "false" is loaded as a bool. + assert res["exclude_newer_package"] == {"numpy": False, "pandas": "7d"} + @classmethod def config_tests(cls, res, root_prefix=root_prefix, target_prefix=prefix): assert res["root_prefix"] == root_prefix