Source code for tests.test_cooldown

# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

from __future__ import annotations

import gc
import logging
import threading
from datetime import UTC, datetime, timedelta
from pathlib import Path
from types import SimpleNamespace

import pytest
from click_extra import Duration

from meta_package_manager.capabilities import cooldown_is_synthesized
from meta_package_manager.cli import package_task
from meta_package_manager.cli_maintenance import _attempt_install, cooldown_permits
from meta_package_manager.cooldown import (
    Cooldown,
    CooldownPolicy,
    CooldownSettings,
    parse_cooldown_section,
    resolve_cooldown,
)
from meta_package_manager.execution import CLIError
from meta_package_manager.manager import COOLDOWN_EXEMPT, CooldownHold
from meta_package_manager.managers.flatpak import Flatpak
from meta_package_manager.managers.gem import Gem
from meta_package_manager.managers.homebrew import Homebrew
from meta_package_manager.managers.mas import MAS
from meta_package_manager.managers.npm import NPM
from meta_package_manager.managers.pacman import _YAY_COOLDOWN_INIT_LUA, Paru, Yay
from meta_package_manager.managers.pip import Pip
from meta_package_manager.managers.pipx import Pipx
from meta_package_manager.managers.uv import UV, UVX
from meta_package_manager.specifier import Specifier
from meta_package_manager.version import parse_version

"""Test the supply-chain release-age cooldown feature.

Parsing of the `--cooldown` value itself (friendly, ISO 8601 and RFC 3339
shapes, calendar-unit rejection, zero / future-timestamp disabling) is covered
upstream by click-extra's `Duration` test suite: only the cooldown semantics
built on top of the parsed `timedelta` are exercised here."""


[docs] @pytest.mark.parametrize( ("cooldown_input", "release_iso", "should_pass"), ( # idna 3.6 was published 2023-11-25. # idna 3.7 was published 2024-04-11. # Anchor cooldown=P7D against a fake "today" of 2024-04-15: # cutoff = 2024-04-08, so 3.6 (older) passes but 3.7 (Apr 11) is blocked. # We assert the cutoff math directly since it does not depend on a real # registry call. ("P7D", "2023-11-25T00:00:00Z", True), # idna 3.6: older than cutoff. ("P7D", "2024-04-11T00:00:00Z", False), # idna 3.7: newer than cutoff. ), ) def test_release_anchored_cutoff_math( cooldown_input, release_iso, should_pass, monkeypatch ): """Verify the cooldown cutoff blocks fresh releases and lets older ones pass. Pattern borrowed from astral-sh/uv#19475: anchor against real upstream release timestamps (idna 3.6 / 3.7 on PyPI) and a frozen 'today' so the arithmetic stays deterministic. """ fake_now = datetime(2024, 4, 15, tzinfo=UTC) cooldown = Duration().convert(cooldown_input, None, None) assert cooldown is not None cutoff = fake_now - cooldown release_time = datetime.fromisoformat(release_iso) assert (release_time <= cutoff) is should_pass
[docs] @pytest.mark.parametrize( ("manager_class", "env_var"), ( (NPM, "npm_config_min-release-age"), (Pip, "PIP_UPLOADED_PRIOR_TO"), (Pipx, "PIP_UPLOADED_PRIOR_TO"), (UV, "UV_EXCLUDE_NEWER"), (UVX, "UV_EXCLUDE_NEWER"), ), ) def test_supported_managers_advertise_cooldown(manager_class, env_var): manager = manager_class() assert manager.supports_cooldown is True assert manager.cooldown_env_var == env_var
[docs] @pytest.mark.parametrize("manager_class", (Gem, Homebrew)) def test_ungateable_managers_lack_cooldown(manager_class): manager = manager_class() assert manager.supports_cooldown is False assert manager.cooldown_env_var is None # Even with a cooldown set, no environment is injected. manager.cooldown = timedelta(days=7) assert manager.cooldown_env() == {}
[docs] @pytest.mark.parametrize("manager_class", (Pip, Pipx, UV, UVX)) def test_timestamp_based_managers_inject_cutoff(manager_class): """Managers whose env var expects an RFC 3339 cutoff timestamp.""" manager = manager_class() # No cooldown means no injection. assert manager.cooldown_env() == {} # A cooldown injects exactly one env var holding an RFC 3339 cutoff in the past. manager.cooldown = timedelta(days=7) env = manager.cooldown_env() assert set(env) == {manager.cooldown_env_var} cutoff = datetime.fromisoformat(env[manager.cooldown_env_var]) now = datetime.now(tz=UTC) # The cutoff sits roughly one cooldown in the past (a minute of slack). assert abs((now - cutoff) - timedelta(days=7)) < timedelta(minutes=1)
[docs] @pytest.mark.parametrize( ("cooldown", "expected_days"), ( (timedelta(days=7), "7"), (timedelta(weeks=2), "14"), (timedelta(days=1), "1"), # Sub-day durations round up to avoid silently disabling the gate. (timedelta(hours=12), "1"), (timedelta(hours=25), "2"), (timedelta(seconds=1), "1"), ), ) def test_npm_injects_integer_days(cooldown, expected_days): """npm's `min-release-age` expects an integer count of days, not a timestamp.""" manager = NPM() assert manager.cooldown_env() == {} manager.cooldown = cooldown assert manager.cooldown_env() == {"npm_config_min-release-age": expected_days}
[docs] def test_yay_advertises_cooldown_while_idle(): """While idle, yay reports its structural capability without a version probe. The import-time `COOLDOWN_SUPPORTED_MANAGERS` help text reads this for every manager, so it must never shell out to `yay --version`. """ manager = Yay() assert manager.cooldown is None assert manager.supports_cooldown is True assert manager.cooldown_env_var == "XDG_CONFIG_HOME" assert "version" not in manager.__dict__ assert manager.cooldown_env() == {}
[docs] @pytest.mark.parametrize( ("version", "supported"), ( ("13.0.0", True), ("13.0.2", True), ("14.1.0", True), ("12.4.2", False), ("11.0.0", False), ), ) def test_yay_cooldown_version_gate(version, supported): """yay can enforce a cooldown only from v13.0.0, when its Lua hooks landed.""" manager = Yay() # Bypass the live `yay --version` probe with a parsed fake. manager.__dict__["version"] = parse_version(version) manager.cooldown = timedelta(days=7) assert manager.supports_cooldown is supported # An unsupported version must never inject a half-configured environment. if not supported: assert manager.cooldown_env() == {}
[docs] def test_yay_cooldown_undetectable_version(): """A yay whose version cannot be parsed cannot enforce a cooldown.""" manager = Yay() manager.__dict__["version"] = None manager.cooldown = timedelta(days=7) assert manager.supports_cooldown is False assert manager.cooldown_env() == {}
[docs] def test_yay_cooldown_policy_gates_both_paths(): """The generated policy gates upgrades and installs off the same cutoff.""" assert 'create_autocmd("UpgradeSelect"' in _YAY_COOLDOWN_INIT_LUA assert 'create_autocmd("AURPreInstall"' in _YAY_COOLDOWN_INIT_LUA assert "MPM_COOLDOWN_EPOCH" in _YAY_COOLDOWN_INIT_LUA
[docs] @pytest.mark.parametrize( ("env", "expected"), ( ({"XDG_CONFIG_HOME": "/xdg", "HOME": "/home/u"}, Path("/xdg/yay")), ({"HOME": "/home/u"}, Path("/home/u/.config/yay")), ({}, None), ), ) def test_yay_user_config_dir(env, expected, monkeypatch): """The real config dir follows yay's own XDG_CONFIG_HOME over HOME precedence.""" monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) monkeypatch.delenv("HOME", raising=False) for key, value in env.items(): monkeypatch.setenv(key, value) assert Yay._user_yay_config_dir() == expected
[docs] def test_yay_cooldown_overlay(tmp_path, monkeypatch): """An active cooldown on a recent yay builds a lossless XDG_CONFIG_HOME overlay.""" user_cfg = tmp_path / ".config" / "yay" user_cfg.mkdir(parents=True) (user_cfg / "config.json").write_text("{}") monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) monkeypatch.setenv("HOME", str(tmp_path)) manager = Yay() manager.__dict__["version"] = parse_version("13.0.2") manager.cooldown = timedelta(days=7) env = manager.cooldown_env() assert set(env) == {"XDG_CONFIG_HOME", "MPM_COOLDOWN_EPOCH", "MPM_YAY_USER_DIR"} assert env["MPM_YAY_USER_DIR"] == str(user_cfg) # The cutoff sits roughly one cooldown in the past (a minute of slack). epoch = env["MPM_COOLDOWN_EPOCH"] assert epoch is not None cutoff = datetime.fromtimestamp(int(epoch), tz=UTC) now = datetime.now(tz=UTC) assert abs((now - cutoff) - timedelta(days=7)) < timedelta(minutes=1) # The overlay carries the generated policy and a symlink back to the real config. xdg_home = env["XDG_CONFIG_HOME"] assert xdg_home is not None overlay = Path(xdg_home) / "yay" assert (overlay / "init.lua").read_text(encoding="UTF-8") == _YAY_COOLDOWN_INIT_LUA config_link = overlay / "config.json" assert config_link.is_symlink() assert config_link.resolve() == (user_cfg / "config.json").resolve() # The directory is memoized, so repeated calls reuse the same overlay. assert manager.cooldown_env()["XDG_CONFIG_HOME"] == env["XDG_CONFIG_HOME"]
[docs] def test_yay_cooldown_overlay_without_user_config(tmp_path, monkeypatch): """With no user config present, the overlay omits the config.json symlink.""" monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) monkeypatch.setenv("HOME", str(tmp_path)) manager = Yay() manager.__dict__["version"] = parse_version("13.0.2") manager.cooldown = timedelta(days=7) env = manager.cooldown_env() xdg_home = env["XDG_CONFIG_HOME"] assert xdg_home is not None overlay = Path(xdg_home) / "yay" assert (overlay / "init.lua").is_file() assert not (overlay / "config.json").exists()
[docs] def test_yay_cooldown_overlay_survives_manager_gc(tmp_path, monkeypatch): """The overlay must outlive the Yay instance's garbage collection. Cleanup is registered with `atexit`, not `weakref.finalize(self, ...)`: yay re-reads `init.lua` mid-run, so a GC-tied removal could delete the overlay before yay finishes and silently fail the gate open. """ monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) monkeypatch.setenv("HOME", str(tmp_path)) manager = Yay() manager.__dict__["version"] = parse_version("13.0.2") manager.cooldown = timedelta(days=7) xdg_home = manager.cooldown_env()["XDG_CONFIG_HOME"] assert xdg_home is not None init_lua = Path(xdg_home) / "yay" / "init.lua" assert init_lua.is_file() del manager gc.collect() assert init_lua.is_file(), "overlay deleted when the Yay instance was collected"
[docs] def test_yay_cooldown_epoch_clamped_to_zero(tmp_path, monkeypatch): """A cooldown reaching before 1970 clamps the epoch to 0. A negative Unix timestamp parses back to nil in yay's gopher-lua, which silently drops the gate; epoch 0 keeps the floor effective. """ monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) monkeypatch.setenv("HOME", str(tmp_path)) manager = Yay() manager.__dict__["version"] = parse_version("13.0.2") manager.cooldown = timedelta(days=40000) # ~109 years: cutoff predates 1970. assert manager.cooldown_env()["MPM_COOLDOWN_EPOCH"] == "0"
[docs] def test_yay_cooldown_no_recursion_when_version_resolved_lazily(tmp_path, monkeypatch): """Resolving the version lazily under an active cooldown must not recurse. `version` runs `yay --version` through `run()`, which injects `cooldown_env()`, which consults `supports_cooldown` -> `version`. The re-entrancy guard must break that loop. Regression for a live `RecursionError` the pre-seeded-version tests above could not catch. """ monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) monkeypatch.setenv("HOME", str(tmp_path)) manager = Yay() manager.cooldown = timedelta(days=7) # Make the binary look present so the `version` property actually probes. manager.__dict__["supported"] = True manager.__dict__["executable"] = True def fake_run_cli(*args, **kwargs): # The real run() injects cooldown_env() on every CLI call; mimic that so the # version probe re-enters cooldown_env -> supports_cooldown -> version. manager.cooldown_env() return "yay v13.0.2 - libalpm v13.0.1" monkeypatch.setattr(manager, "run_cli", fake_run_cli) # Must terminate and resolve correctly (used to raise RecursionError). assert str(manager.version) == "13.0.2" assert manager.supports_cooldown is True assert set(manager.cooldown_env()) == { "XDG_CONFIG_HOME", "MPM_COOLDOWN_EPOCH", "MPM_YAY_USER_DIR", }
_FLATPAK_REMOTE_INFO_OUTPUT = """\ GNOME Dictionary - Check word definitions and spellings ID: org.gnome.Dictionary Ref: app/org.gnome.Dictionary/x86_64/stable Arch: x86_64 Branch: stable Collection: org.flathub.Stable Commit: 5697aaea8f6a55b02c34e77504cbe4e419257b482ec7cba434255f5bd6f4 Subject: Export org.gnome.Dictionary Date: 2020-12-08 12:00:26 +0000 """ def _probed_flatpak(monkeypatch, dates, cooldown=timedelta(days=7)): """A flatpak manager whose release-date probe answers from `dates`.""" manager = Flatpak() manager.cooldown = cooldown monkeypatch.setattr(manager, "release_date", dates.get) return manager def _stub_outdated(monkeypatch, manager_class, package_ids): """List `package_ids` as the outdated packages of `manager_class`.""" outdated = tuple( SimpleNamespace(id=package_id, installed_version="1.0", latest_version="2.0") for package_id in package_ids ) monkeypatch.setattr( manager_class, "outdated", property(lambda self: iter(outdated)) ) def _record_runs(monkeypatch, manager): """Replace `manager.run` with a stub, returning the list of commands it gets.""" ran = [] def fake_run(*args, **kwargs): ran.append(args[0]) return "" monkeypatch.setattr(manager, "run", fake_run) return ran
[docs] @pytest.mark.parametrize("manager_class", (Flatpak, MAS, Paru)) def test_probe_managers_advertise_synthesized_cooldown(manager_class): manager = manager_class() assert manager.supports_cooldown is True assert manager.cooldown_env_var is None # The gate runs through the probe: no environment is ever injected. manager.cooldown = timedelta(days=7) assert manager.cooldown_env() == {}
[docs] def test_cooldown_is_synthesized_classifier(): assert cooldown_is_synthesized(Flatpak) is True assert cooldown_is_synthesized(MAS) is True assert cooldown_is_synthesized(Paru) is True # A native env var (npm), an env-var overlay (yay) and an ungateable # manager (brew) all sit outside the synthesized classification. assert cooldown_is_synthesized(NPM) is False assert cooldown_is_synthesized(Yay) is False assert cooldown_is_synthesized(Homebrew) is False
[docs] def test_cooldown_permits_probe_backed_manager(): manager = Flatpak() manager.cooldown = timedelta(days=7) assert cooldown_permits(manager) is True
[docs] def test_hold_inactive_without_cooldown(monkeypatch): manager = Flatpak() monkeypatch.setattr( manager, "release_date", lambda package_id: pytest.fail("probe must not run without a cooldown"), ) assert manager.cooldown_hold("org.example.Fig") is None
[docs] def test_hold_passes_aged_release(monkeypatch): aged = datetime.now(tz=UTC) - timedelta(days=30) manager = _probed_flatpak(monkeypatch, {"org.example.Fig": aged}) assert manager.cooldown_hold("org.example.Fig") is None
[docs] def test_hold_dates_fresh_release(monkeypatch): fresh = datetime.now(tz=UTC) - timedelta(days=1) manager = _probed_flatpak(monkeypatch, {"org.example.Kiwi": fresh}) hold = manager.cooldown_hold("org.example.Kiwi") assert hold == CooldownHold(released=fresh, eligible=fresh + timedelta(days=7)) assert "within the cooldown window" in hold.reason
[docs] def test_hold_fail_closed_on_unknown_date(monkeypatch): manager = _probed_flatpak(monkeypatch, {}) hold = manager.cooldown_hold("org.example.Plum") assert hold == CooldownHold(released=None, eligible=None) assert hold.reason == "its latest release cannot be dated (fail-closed)"
[docs] def test_hold_fail_closed_on_probe_error(monkeypatch): manager = Flatpak() manager.cooldown = timedelta(days=7) def broken_probe(package_id): raise CLIError(1, "", "error: nothing matches org.example.Plum") monkeypatch.setattr(manager, "release_date", broken_probe) hold = manager.cooldown_hold("org.example.Plum") assert hold is not None assert hold.reason == "its latest release cannot be dated (fail-closed)"
[docs] def test_hold_best_effort_waives_unknown_date(monkeypatch, caplog): caplog.set_level(logging.INFO) manager = _probed_flatpak(monkeypatch, {}) manager.cooldown_policy = CooldownPolicy.best_effort assert manager.cooldown_hold("org.example.Plum") is None assert "without the supply-chain safeguard" in caplog.text
[docs] def test_hold_off_policy_skips_probe(monkeypatch): manager = Flatpak() manager.cooldown = timedelta(days=7) manager.cooldown_policy = CooldownPolicy.off monkeypatch.setattr( manager, "release_date", lambda package_id: pytest.fail("probe must not run under an off policy"), ) assert manager.cooldown_hold("org.example.Fig") is None
[docs] def test_hold_dry_run_skips_probe(monkeypatch): manager = Flatpak() manager.cooldown = timedelta(days=7) manager.dry_run = True monkeypatch.setattr( manager, "release_date", lambda package_id: pytest.fail("probe must not run under --dry-run"), ) assert manager.cooldown_hold("org.example.Fig") is None
[docs] def test_hold_naive_datetime_read_as_utc(monkeypatch): fresh_naive = datetime.now(tz=UTC).replace(tzinfo=None) - timedelta(days=1) manager = _probed_flatpak(monkeypatch, {"org.example.Kiwi": fresh_naive}) hold = manager.cooldown_hold("org.example.Kiwi") assert hold is not None assert hold.released == fresh_naive.replace(tzinfo=UTC)
[docs] def test_hold_probes_each_package_once(monkeypatch): """A verdict is reused whatever it says: held, cleared or undatable.""" fresh = datetime.now(tz=UTC) - timedelta(days=1) dates = { "org.example.Fig": fresh - timedelta(days=30), "org.example.Kiwi": fresh, } probed = [] def probe(package_id): probed.append(package_id) return dates.get(package_id) manager = Flatpak() manager.cooldown = timedelta(days=7) monkeypatch.setattr(manager, "release_date", probe) package_ids = ("org.example.Fig", "org.example.Kiwi", "org.example.Plum") verdicts = [manager.cooldown_hold(package_id) for package_id in package_ids] assert verdicts == [ None, CooldownHold(released=fresh, eligible=fresh + timedelta(days=7)), CooldownHold(), ] assert [manager.cooldown_hold(package_id) for package_id in package_ids] == verdicts assert probed == list(package_ids)
[docs] def test_synthesized_gate_reroutes_upgrade_all(monkeypatch): """An active probe-backed cooldown takes the per-package upgrade path. It replaces the native one-shot upgrade: eligible packages upgrade, too-fresh ones are held. """ manager = Flatpak() manager.cooldown = timedelta(days=7) now = datetime.now(tz=UTC) dates = { "org.example.Fig": now - timedelta(days=30), "org.example.Kiwi": now - timedelta(days=1), } monkeypatch.setattr(manager, "release_date", dates.get) _stub_outdated(monkeypatch, Flatpak, sorted(dates)) monkeypatch.setattr( manager, "upgrade_all_cli", lambda: pytest.fail("the native one-shot upgrade must not run"), ) monkeypatch.setattr( manager, "upgrade_one_cli", lambda package_id, version=None: ("flatpak", "update", package_id), ) ran = _record_runs(monkeypatch, manager) manager.upgrade() assert ran == [("flatpak", "update", "org.example.Fig")]
[docs] def test_upgrade_all_without_cooldown_keeps_native_path(monkeypatch): manager = Flatpak() assert manager.cooldown is None monkeypatch.setattr( manager, "upgrade_all_cli", lambda: ("flatpak", "update", "--noninteractive"), ) ran = _record_runs(monkeypatch, manager) manager.upgrade() assert ran == [("flatpak", "update", "--noninteractive")]
[docs] def test_flatpak_release_date_reads_installed_origin(monkeypatch): manager = Flatpak() monkeypatch.setitem( manager.__dict__, "installed_ids", frozenset({"org.gnome.Dictionary"}) ) def fake_run_cli(*args, **kwargs): if args[0] == "info": return "ID: org.gnome.Dictionary\nOrigin: flathub\n" assert args[0] == "remote-info" assert args[2] == "flathub" return _FLATPAK_REMOTE_INFO_OUTPUT monkeypatch.setattr(manager, "run_cli", fake_run_cli) published = manager.release_date("org.gnome.Dictionary") assert published == datetime(2020, 12, 8, 12, 0, 26, tzinfo=UTC)
[docs] def test_flatpak_release_date_probes_every_remote_for_a_new_app(monkeypatch): manager = Flatpak() monkeypatch.setitem(manager.__dict__, "installed_ids", frozenset()) def fake_run_cli(*args, **kwargs): if args[0] == "remotes": return "fedora\nflathub\n" assert args[0] == "remote-info" if args[2] == "fedora": raise CLIError(1, "", "error: nothing matches org.gnome.Dictionary") return _FLATPAK_REMOTE_INFO_OUTPUT monkeypatch.setattr(manager, "run_cli", fake_run_cli) published = manager.release_date("org.gnome.Dictionary") assert published == datetime(2020, 12, 8, 12, 0, 26, tzinfo=UTC)
[docs] def test_flatpak_release_date_none_without_date_line(monkeypatch): manager = Flatpak() monkeypatch.setitem(manager.__dict__, "installed_ids", frozenset()) def fake_run_cli(*args, **kwargs): if args[0] == "remotes": return "flathub\n" return "ID: org.gnome.Dictionary\n" monkeypatch.setattr(manager, "run_cli", fake_run_cli) assert manager.release_date("org.gnome.Dictionary") is None
_PARU_AUR_INFO_OUTPUT = """\ Repository : aur Name : paru Version : 2.1.0-1 Description : Feature packed AUR helper First Submitted : Wed, 21 Oct 2020 20:07:31 Last Modified : Sat, 12 Jul 2025 14:52:15 Out Of Date : No """ _PARU_REPO_INFO_OUTPUT = """\ Repository : extra Name : firefox Version : 141.0-1 Description : Fast, Private & Safe Web Browser Build Date : Tue, 22 Jul 2025 09:14:02 """
[docs] @pytest.mark.parametrize( ("package_id", "output", "expected"), ( pytest.param( "paru", _PARU_AUR_INFO_OUTPUT, datetime(2025, 7, 12, 14, 52, 15, tzinfo=UTC), id="aur-last-modified", ), pytest.param( "paru", _PARU_AUR_INFO_OUTPUT.replace( "Sat, 12 Jul 2025 14:52:15", "Wed, 2 Jul 2025 08:01:02" ), datetime(2025, 7, 2, 8, 1, 2, tzinfo=UTC), id="space-padded-day", ), pytest.param( "firefox", _PARU_REPO_INFO_OUTPUT, COOLDOWN_EXEMPT, id="repository-package-exempt", ), pytest.param( "paru", _PARU_AUR_INFO_OUTPUT.replace( "Sat, 12 Jul 2025 14:52:15", "sometime last summer" ), None, id="unparsable-date", ), pytest.param("paru", "Name : paru\n", None, id="no-date-field"), ), ) def test_paru_release_date(stub_run_cli, package_id, output, expected): """An AUR package is dated by its `Last Modified` field. A repository package is exempt, and an answer with no readable date dates nothing. """ manager = Paru() stub_run_cli(manager, output) published = manager.release_date(package_id) assert published == expected # The exemption is a sentinel: only that very object reads as exempt. assert (published is COOLDOWN_EXEMPT) is (expected is COOLDOWN_EXEMPT)
[docs] def test_hold_passes_exempt_sentinel(monkeypatch): manager = _probed_flatpak(monkeypatch, {"org.example.Fig": COOLDOWN_EXEMPT}) assert manager.cooldown_hold("org.example.Fig") is None
[docs] def test_paru_gated_upgrade_all_rides_the_ignore_flag(monkeypatch): """Held AUR packages are excluded from the single `--sysupgrade` transaction. They do not reroute to per-package upgrades. """ manager = Paru() manager.cooldown = timedelta(days=7) now = datetime.now(tz=UTC) dates = { "fig": COOLDOWN_EXEMPT, "kiwi": now - timedelta(days=1), "plum": now - timedelta(days=30), } monkeypatch.setattr(manager, "release_date", dates.get) _stub_outdated(monkeypatch, Paru, sorted(dates)) monkeypatch.setattr(manager, "build_cli", lambda *args, sudo=False: ("paru", *args)) ran = _record_runs(monkeypatch, manager) manager.upgrade() assert ran == [ ("paru", "--sync", "--refresh", "--sysupgrade", "--ignore=kiwi"), ]
[docs] def test_paru_gated_upgrade_all_without_holds_keeps_native_cli(monkeypatch): manager = Paru() manager.cooldown = timedelta(days=7) aged = datetime.now(tz=UTC) - timedelta(days=30) monkeypatch.setattr( manager, "release_date", {"fig": COOLDOWN_EXEMPT, "plum": aged}.get ) _stub_outdated(monkeypatch, Paru, ("fig", "plum")) monkeypatch.setattr(manager, "build_cli", lambda *args, sudo=False: ("paru", *args)) ran = _record_runs(monkeypatch, manager) manager.upgrade() assert ran == [("paru", "--sync", "--refresh", "--sysupgrade")]
[docs] @pytest.mark.parametrize( ("output", "expected"), ( pytest.param( '{"adamID":999999999,"currentVersionReleaseDate":' '"2020-03-18T17:39:23Z","name":"Papaya","version":"2.0"}', datetime(2020, 3, 18, 17, 39, 23, tzinfo=UTC), id="catalog-record", ), pytest.param('{"adamID":999999999,"name":"Papaya"}', None, id="no-date-field"), pytest.param( '{"adamID":999999999,"currentVersionReleaseDate":"soon","name":"Papaya"}', None, id="unparsable-date", ), ), ) def test_mas_release_date(capture_run_cli, output, expected): """The release date is the `currentVersionReleaseDate` of the catalog record. The record `mas lookup` prints. The date is `None` when the record holds no readable date. """ manager = MAS() calls = capture_run_cli(manager, output) assert manager.release_date("999999999") == expected assert calls == [("lookup", "999999999", "--json")]
[docs] def test_attempt_install_reports_cooldown_status(monkeypatch): manager = _probed_flatpak(monkeypatch, {}) spec = Specifier(raw_spec="org.example.Fig", package_id="org.example.Fig") assert _attempt_install(manager, spec) == "cooldown"
[docs] def test_package_task_holds_fresh_release(monkeypatch): fresh = datetime.now(tz=UTC) - timedelta(days=1) manager = _probed_flatpak(monkeypatch, {"org.example.Kiwi": fresh}) spec = Specifier(raw_spec="org.example.Kiwi", package_id="org.example.Kiwi") task = package_task( manager, spec, threading.Lock(), action=lambda m, s: pytest.fail("a held package must not run its action"), verb="install", operation="install", record_failure=lambda s: pytest.fail( "a held package must not be recorded as a failure" ), ) ok, label = task() assert ok is False assert "(cooldown)" in label
[docs] def test_cooldown_permits_without_cooldown(): manager = Homebrew() manager.cooldown = None assert cooldown_permits(manager) is True
[docs] def test_cooldown_permits_supported_manager(): manager = UV() manager.cooldown = timedelta(days=7) assert cooldown_permits(manager) is True
[docs] def test_cooldown_permits_blocks_ungateable(caplog): caplog.set_level(logging.WARNING) manager = Homebrew() manager.cooldown = timedelta(days=7) manager.cooldown_policy = CooldownPolicy.enforce assert cooldown_permits(manager) is False assert "cannot enforce" in caplog.text assert "--cooldown best-effort" in caplog.text
[docs] def test_cooldown_permits_allows_best_effort(caplog): caplog.set_level(logging.WARNING) manager = Homebrew() manager.cooldown = timedelta(days=7) manager.cooldown_policy = CooldownPolicy.best_effort assert cooldown_permits(manager) is True assert "without the supply-chain safeguard" in caplog.text
[docs] def test_cooldown_permits_allows_off(caplog): """`off` exempts a manager from the gate entirely, like best-effort.""" caplog.set_level(logging.WARNING) manager = Homebrew() manager.cooldown = timedelta(days=7) manager.cooldown_policy = CooldownPolicy.off assert cooldown_permits(manager) is True
[docs] def test_off_policy_suppresses_env_injection(): """An `off` policy holds back the cutoff even where a manager could honor it.""" manager = UV() manager.cooldown = timedelta(days=7) manager.cooldown_policy = CooldownPolicy.off assert manager.cooldown_env() == {}
[docs] def test_cli_rejects_invalid_cooldown(invoke): result = invoke("--cooldown", "bogus", "managers") assert result.exit_code == 2 assert "not a valid duration" in result.stderr
[docs] @pytest.mark.parametrize( ("raw", "expected"), ( # Duration grammar flows through click-extra's Duration type. ("7 days", timedelta(days=7)), ("12h", timedelta(hours=12)), ("P7D", timedelta(days=7)), # Zero, off and future timestamps all read as "no gate". ("0", CooldownPolicy.off), ("off", CooldownPolicy.off), ("OFF", CooldownPolicy.off), ("2999-01-01T00:00:00Z", CooldownPolicy.off), # Posture keywords, case-insensitively. ("enforce", CooldownPolicy.enforce), ("best-effort", CooldownPolicy.best_effort), ("Best-Effort", CooldownPolicy.best_effort), # Empty stays "unspecified": resolution inherits both axes. ("", None), ), ) def test_cooldown_option_parses_windows_and_keywords(raw, expected): """The `--cooldown` union spells a window or an enforcement posture.""" assert Cooldown().convert(raw, None, None) == expected
[docs] @pytest.mark.parametrize( ("section", "expected"), ( (None, CooldownSettings(duration=None, policy=None)), ({}, CooldownSettings(duration=None, policy=None)), ( {"period": "1 week"}, CooldownSettings(duration=timedelta(days=7), policy=None), ), ( {"period": "1 week", "policy": "best-effort"}, CooldownSettings( duration=timedelta(days=7), policy=CooldownPolicy.best_effort ), ), # A zero period reads as "no window". ({"period": "0"}, CooldownSettings(duration=None, policy=None)), # The deprecated top-level string spelling is the window. ( "1 week", CooldownSettings(duration=timedelta(days=7), policy=None, legacy=True), ), ), ) def test_parse_cooldown_section(section, expected): assert parse_cooldown_section(section) == expected
[docs] @pytest.mark.parametrize( ("section", "reason"), ( # A posture without a window would be a standing no-op gate. ({"policy": "best-effort"}, "requires a period"), # off is a CLI-only keyword; configuration disables via the period. ({"period": "1 week", "policy": "off"}, "CLI-only"), ({"policy": "sideways"}, "unknown policy"), ({"period": "bogus"}, "not a valid duration"), ({"windows": "1 week"}, "unknown key"), ), ) def test_parse_cooldown_section_rejects(section, reason): with pytest.raises(ValueError, match=reason): parse_cooldown_section(section)
[docs] def test_parse_cooldown_section_rejects_wrong_shape(): """A section that is neither a table nor a string is a type error.""" with pytest.raises(TypeError, match="expected a table"): parse_cooldown_section(["1 week"])
[docs] @pytest.mark.parametrize( ("flag", "settings", "expected"), ( # Nothing anywhere: no gate, fail-closed posture by default. (None, CooldownSettings(None, None), (None, CooldownPolicy.enforce)), # The configuration alone decides both axes. ( None, CooldownSettings(timedelta(days=7), CooldownPolicy.best_effort), (timedelta(days=7), CooldownPolicy.best_effort), ), # A flag window inherits the configured posture. ( timedelta(days=1), CooldownSettings(timedelta(days=7), CooldownPolicy.best_effort), (timedelta(days=1), CooldownPolicy.best_effort), ), # A flag posture inherits the configured window. ( CooldownPolicy.best_effort, CooldownSettings(timedelta(days=7), None), (timedelta(days=7), CooldownPolicy.best_effort), ), # A flag posture overrides the configured one. ( CooldownPolicy.enforce, CooldownSettings(timedelta(days=7), CooldownPolicy.best_effort), (timedelta(days=7), CooldownPolicy.enforce), ), # off forces the window off too, whatever the configuration. ( CooldownPolicy.off, CooldownSettings(timedelta(days=7), CooldownPolicy.best_effort), (None, CooldownPolicy.off), ), ), ) def test_resolve_cooldown(flag, settings, expected): """The flag and the configuration merge axis by axis.""" assert resolve_cooldown(flag, settings) == expected
[docs] def test_cli_cooldown_config_table(tmp_path, invoke): """A `[mpm.cooldown]` table reaches the gate through the configuration.""" conf = tmp_path / "config.toml" conf.write_text('[mpm.cooldown]\nperiod = "1 week"\n', encoding="UTF-8") # A posture keyword inherits the configured window, so the run has an # active gate and the "no effect" note stays off. result = invoke( "--config", str(conf), "--verbosity", "INFO", "--cooldown", "best-effort", "managers", ) assert result.exit_code == 0 assert "has no effect" not in result.stderr
[docs] def test_cli_cooldown_keyword_without_window_is_a_noop(invoke): """A posture keyword with no window anywhere logs an INFO note.""" result = invoke("--verbosity", "INFO", "--cooldown", "best-effort", "managers") assert result.exit_code == 0 assert "--cooldown best-effort has no effect" in result.stderr
[docs] def test_cli_cooldown_legacy_config_spelling(tmp_path, invoke): """The deprecated top-level string still sets the window, with a warning.""" conf = tmp_path / "config.toml" conf.write_text('[mpm]\ncooldown = "1 week"\n', encoding="UTF-8") result = invoke("--config", str(conf), "managers") assert result.exit_code == 0 assert "Deprecated configuration" in result.stderr assert "[mpm.cooldown] period" in result.stderr
[docs] def test_cli_cooldown_config_policy_without_period(tmp_path, invoke): """A standing posture without a window is rejected at load time.""" conf = tmp_path / "config.toml" conf.write_text('[mpm.cooldown]\npolicy = "best-effort"\n', encoding="UTF-8") result = invoke("--config", str(conf), "managers") assert result.exit_code == 1 assert "requires a period" in result.stderr