Source code for tests.test_orphans

# 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.
"""Checks for the two orphan features and their native-command mappings.

`remove --orphans` (scoped,
{meth}`~meta_package_manager.manager.PackageManager.remove_orphan`) drops one
package's own orphaned dependencies; `cleanup --orphans` (system-wide,
{meth}`~meta_package_manager.manager.PackageManager.cleanup_orphan`) sweeps every
orphaned package. `mark_explicit` keeps both from removing a package the user
installed again while the manager still held it as a dependency. The argv
assertions stub `run_cli` on the pooled manager singleton to capture command
tokens without spawning a subprocess, so they run identically on any host.
"""

from __future__ import annotations

from pathlib import Path

import pytest
from click_extra import strip_ansi

from meta_package_manager.capabilities import (
    Operations,
    cleanup_orphan_is_synthesized,
    implements,
    implements_method,
    supports_cleanup_cache,
    supports_cleanup_repair,
)
from meta_package_manager.execution import CLIError
from meta_package_manager.manager import PackageManager
from meta_package_manager.pool import pool
from meta_package_manager.tables import PackageOutcome
from meta_package_manager.version import parse_version

from .fake_manager import FakeManager
from .test_cli import check_report


def _capture_run_cli(monkeypatch, manager_id, call):
    """Invoke `call(manager)` with the binary-resolution seams stubbed.

    `run_cli` records the positional argv of every invocation instead of executing
    it; `sibling_cli` is neutralized because its `same_dir` resolution asserts on
    a `cli_path` that is absent on a host lacking the manager (xbps); `which` is
    stubbed for the operations probing helper binaries (emerge's `eclean`). Returns
    the flat list of every captured token across all invocations.
    """
    manager = pool[manager_id]
    calls: list[tuple[str, ...]] = []

    def record_run_cli(*args, **kwargs):
        calls.append(args)
        return ""

    monkeypatch.setattr(manager, "run_cli", record_run_cli)
    monkeypatch.setattr(
        manager,
        "sibling_cli",
        lambda name, **kwargs: Path("/fake/bin") / name,
    )
    monkeypatch.setattr(
        manager,
        "which",
        lambda name: Path("/fake/bin") / name,
    )
    call(manager)
    return [token for args in calls for token in args]


# remove --orphans: scoped cascade verbs (one package's own orphaned dependencies).


[docs] @pytest.mark.parametrize( ("manager_id", "cascade_token"), ( ("apt", "--auto-remove"), ("dnf", "autoremove"), ("dnf5", "autoremove"), ("pacman", "--recursive"), ("xbps", "--recursive"), ("yum", "autoremove"), ("zypper", "--clean-deps"), ), ) def test_remove_orphan_uses_native_cascade(monkeypatch, manager_id, cascade_token): """`remove --orphans` maps to each manager's native remove-plus-orphans verb.""" tokens = _capture_run_cli( monkeypatch, manager_id, lambda m: m.remove_orphan("firefox") ) assert cascade_token in tokens assert "firefox" in tokens
[docs] @pytest.mark.parametrize( ("manager_id", "cascade_token"), ( ("dnf", "autoremove"), ("dnf5", "autoremove"), ("xbps", "--recursive"), ("yum", "autoremove"), ), ) def test_plain_remove_no_longer_cascades(monkeypatch, manager_id, cascade_token): """Plain `remove` keeps orphaned dependencies: dnf and xbps used to cascade.""" tokens = _capture_run_cli(monkeypatch, manager_id, lambda m: m.remove("firefox")) assert cascade_token not in tokens assert "firefox" in tokens
[docs] @pytest.mark.parametrize( "manager_id", ("brew", "cask", "flatpak", "npm", "pip", "pkg", "snap"), ) def test_remove_orphan_unsupported_raises_not_implemented(manager_id): """A manager with no scoped cascade leaves `remove_orphan` unimplemented. So the CLI action catches the `NotImplementedError` and falls back to a plain removal. """ with pytest.raises(NotImplementedError): pool[manager_id].remove_orphan("firefox")
# cleanup --orphans: system-wide orphan sweeps (every orphaned package).
[docs] @pytest.mark.parametrize( ("manager_id", "sweep_token"), ( ("apt", "autoremove"), ("brew", "autoremove"), ("cask", "autoremove"), ("dnf", "autoremove"), ("dnf5", "autoremove"), ("flatpak", "--unused"), ("pkg", "autoremove"), ("xbps", "--remove-orphans"), ("yum", "autoremove"), ), ) def test_cleanup_orphan_uses_native_sweep(monkeypatch, manager_id, sweep_token): """`cleanup --orphans` maps to each manager's system-wide orphan sweep.""" tokens = _capture_run_cli(monkeypatch, manager_id, lambda m: m.cleanup_orphan()) assert sweep_token in tokens
[docs] @pytest.mark.parametrize( ("manager_id", "sweep_token", "cache_token"), ( ("apt", "autoremove", "clean"), ("brew", "autoremove", "cleanup"), ("dnf", "autoremove", "clean"), ("pkg", "autoremove", "clean"), ), ) def test_plain_cleanup_never_sweeps(monkeypatch, manager_id, sweep_token, cache_token): """The `cleanup` composer runs the non-destructive categories only. The orphan sweep stays behind an explicit `--orphans`, even where a native sweep exists. """ tokens = _capture_run_cli(monkeypatch, manager_id, lambda m: m.cleanup()) assert sweep_token not in tokens assert cache_token in tokens
[docs] @pytest.mark.parametrize("manager_id", ("npm", "pip", "snap")) def test_cleanup_orphan_unsupported_raises_not_implemented(manager_id): """A manager with no sweep and no `orphans` query raises from the base. It propagates `NotImplementedError` from the base sweep, so `cleanup --orphans` skips it. """ with pytest.raises(NotImplementedError): pool[manager_id].cleanup_orphan()
[docs] @pytest.mark.parametrize( ("manager_id", "synthesized", "supported"), ( # Native sweep verb: supported, nothing to synthesize. ("apt", False, True), ("brew", False, True), ("cask", False, True), ("dnf", False, True), ("flatpak", False, True), ("pkg", False, True), ("xbps", False, True), # Native sweep declared by their bundled TOML definition or class split. ("cave", False, True), ("emerge", False, True), ("pkg-tools", False, True), # No native verb, but orphans + remove: mpm synthesizes the sweep. ("pacaur", True, True), ("pacman", True, True), ("paru", True, True), ("yay", True, True), ("zypper", True, True), # No orphan support at all. ("npm", False, False), ("pip", False, False), ("snap", False, False), ), ) def test_cleanup_orphan_capability_matrix(manager_id, synthesized, supported): """The synthesized sweep covers exactly the managers that need it. Those with an orphan listing and per-package removal but no native sweep verb, mirroring `upgrade --all`. """ manager = pool[manager_id] assert cleanup_orphan_is_synthesized(manager) is synthesized assert ( implements_method(manager, "cleanup_orphan") or cleanup_orphan_is_synthesized(manager) ) is supported
[docs] @pytest.mark.parametrize( ("manager_id", "cache_support", "repair_support"), ( ("apt", True, False), ("brew", True, False), ("cave", False, False), ("dnf", True, False), ("emerge", True, False), ("flatpak", False, True), ("npm", True, False), ("pacman", True, False), ("pkg", True, False), ("pkg-tools", False, False), ("xbps", True, False), ("zypper", True, False), ), ) def test_cleanup_category_support(manager_id, cache_support, repair_support): """Category support is a plain method-override check. Every manager declares its cleanup work through category methods, never a monolithic `cleanup`. """ manager = pool[manager_id] assert implements_method(manager, "cleanup") is False assert supports_cleanup_cache(manager) is cache_support assert supports_cleanup_repair(manager) is repair_support
[docs] @pytest.mark.parametrize( ("manager_id", "method", "token"), ( ("apt", "cleanup_cache", "clean"), ("brew", "cleanup_cache", "cleanup"), ("dnf", "cleanup_cache", "clean"), ("emerge", "cleanup_cache", "distfiles"), ("flatpak", "cleanup_repair", "repair"), ("pkg", "cleanup_cache", "clean"), ("xbps", "cleanup_cache", "--clean-cache"), ), ) def test_cleanup_category_argv(monkeypatch, manager_id, method, token): """The extracted category steps run their native commands.""" tokens = _capture_run_cli(monkeypatch, manager_id, lambda m: getattr(m, method)()) assert token in tokens
[docs] def test_synthesized_sweep_fixpoint(monkeypatch): """The synthesized sweep re-queries between rounds until the list settles. Removing an orphan can orphan its own dependencies. """ manager = pool["pacman"] rounds = iter(( "liborphan 1.0-1\nlibleaf 2.0-1", "libnewly-orphaned 0.5-1", "", )) removed = [] def fake_run_cli(*args, **kwargs): if "--query" in args: return next(rounds) removed.append(args) return "" monkeypatch.setattr(manager, "run_cli", fake_run_cli) manager.cleanup_orphan() assert removed == [ ("--remove", "--recursive", "liborphan"), ("--remove", "--recursive", "libleaf"), ("--remove", "--recursive", "libnewly-orphaned"), ]
[docs] def test_synthesized_sweep_stops_without_progress(monkeypatch): """A removal that keeps failing cannot spin the loop. Two identical consecutive listings end the sweep. """ manager = pool["pacman"] removed = [] def fake_run_cli(*args, **kwargs): if "--query" in args: return "libstuck 1.0-1" removed.append(args) return "" monkeypatch.setattr(manager, "run_cli", fake_run_cli) manager.cleanup_orphan() assert removed == [("--remove", "--recursive", "libstuck")]
# orphans query: native read-only listings parsed into packages. _APT_ORPHANS_OUTPUT = """\ NOTE: This is only a simulation! apt needs root privileges for real execution. Reading package lists... The following packages will be REMOVED: libx11-dev libxcb1-dev 0 upgraded, 0 newly installed, 2 to remove and 0 not upgraded. Remv libx11-dev [2:1.8.7-1] Remv libxcb1-dev [1.15-1] """ _BREW_ORPHANS_OUTPUT = """\ ==> Would autoremove 3 unneeded formulae: libpng little-cms2 openjpeg Warning: some warning to ignore """ _DNF_ORPHANS_OUTPUT = """\ libfoo-1.0.2-3.el9.x86_64 python3-extra-0:3.9.18-3.el9.noarch """ _EMERGE_ORPHANS_OUTPUT = """\ Calculating dependencies... done! >>> These are the packages that would be unmerged: dev-libs/libpcre selected: 8.45-r1 protected: none omitted: none app-misc/tmux selected: 3.3a protected: none omitted: none All selected packages: =dev-libs/libpcre-8.45-r1 =app-misc/tmux-3.3a >>> 'Selected' packages are slated for removal. >>> 'Protected' and 'omitted' packages will not be removed. """ _PACMAN_ORPHANS_OUTPUT = """\ gtest 1.14.0-1 libwlroots 0.16.2-2 """ _PKG_ORPHANS_OUTPUT = """\ Checking integrity... done (0 conflicting) Deinstallation has been requested for the following 2 packages: Installed packages to be REMOVED: \tlibiconv: 1.17 \tpcre: 8.45_3 Number of packages to be removed: 2 """ _XBPS_ORPHANS_OUTPUT = """\ libglvnd-1.7.0_1 orc-0.4.34_1 """ _ZYPPER_ORPHANS_OUTPUT = """\ Loading repository data... Reading installed packages... S | Repository | Name | Version | Arch ---+------------+---------+-----------+------- i | @System | libfoo | 1.2.3-1.1 | x86_64 i+ | openSUSE | libbar | 0.9-2.4 | noarch """
[docs] @pytest.mark.parametrize( ("manager_id", "output", "expected"), ( ( "apt", _APT_ORPHANS_OUTPUT, {("libx11-dev", "2:1.8.7-1"), ("libxcb1-dev", "1.15-1")}, ), ( "brew", _BREW_ORPHANS_OUTPUT, {("libpng", None), ("little-cms2", None), ("openjpeg", None)}, ), ( "dnf", _DNF_ORPHANS_OUTPUT, {("libfoo", "1.0.2-3.el9"), ("python3-extra", "3.9.18-3.el9")}, ), ( "emerge", _EMERGE_ORPHANS_OUTPUT, {("dev-libs/libpcre", "8.45-r1"), ("app-misc/tmux", "3.3a")}, ), ( "pacman", _PACMAN_ORPHANS_OUTPUT, {("gtest", "1.14.0-1"), ("libwlroots", "0.16.2-2")}, ), ( "pkg", _PKG_ORPHANS_OUTPUT, {("libiconv", "1.17"), ("pcre", "8.45_3")}, ), ( "xbps", _XBPS_ORPHANS_OUTPUT, {("libglvnd", "1.7.0_1"), ("orc", "0.4.34_1")}, ), ( "zypper", _ZYPPER_ORPHANS_OUTPUT, {("libfoo", "1.2.3-1.1"), ("libbar", "0.9-2.4")}, ), ), ) def test_orphans_parsing(monkeypatch, stub_run_cli, manager_id, output, expected): """Each manager's `orphans` query parses its native listing into packages. The canned outputs mirror the `shell-session` samples of the `orphans` docstrings, so the documented format and the tested format cannot drift apart. """ manager = pool[manager_id] stub_run_cli(manager, output) monkeypatch.setattr( manager, "sibling_cli", lambda name, **kwargs: Path("/fake/bin") / name, ) packages = { ( package.id, str(package.installed_version) if package.installed_version else None, ) for package in manager.orphans } assert packages == expected
[docs] @pytest.mark.parametrize("manager_id", ("cask", "npm", "pip", "snap")) def test_orphans_query_unsupported(manager_id): """Managers without a native orphan listing do not advertise `orphans`. A native read-only orphan listing. This includes cask: casks are never installed as dependencies. """ assert implements(pool[manager_id], Operations.orphans) is False
[docs] @pytest.mark.parametrize( "manager_id", ( "apt", "brew", "cave", "dnf", "dnf5", "emerge", "pacman", "pkg", "pkg-tools", "xbps", "yum", "zypper", ), ) def test_orphans_query_supported(manager_id): assert implements(pool[manager_id], Operations.orphans) is True
# Capability introspection shared by the CLI selection and the documentation.
[docs] def test_implements_method_introspection(): """`implements_method` reports overrides of the base stubs. The base `NotImplementedError` stubs, with the same MRO walk the CLI and the docs generator rely on. """ assert implements_method(pool["apt"], "remove_orphan") is True assert implements_method(pool["apt"], "cleanup_orphan") is True assert implements_method(pool["pacman"], "remove_orphan") is True assert implements_method(pool["pacman"], "cleanup_orphan") is False assert implements_method(pool["brew"], "remove_orphan") is False assert implements_method(pool["brew"], "cleanup_orphan") is True # Inherited overrides count (dnf5 and yum inherit from dnf). assert implements_method(pool["dnf5"], "remove_orphan") is True
[docs] def test_base_orphan_operations_not_implemented(): """Both operations are optional, exactly like `remove` and `cleanup`.""" with pytest.raises(NotImplementedError): PackageManager().remove_orphan("firefox") with pytest.raises(NotImplementedError): PackageManager().cleanup_orphan()
# mark_explicit: a package installed again stops being a dependency. EXPLICIT_INSTALL_NOT_MARKED = { "brew": "`brew install` sets `installed_on_request` on a formula it holds.", "cask": "`brew autoremove` never removes a cask.", "cave": "`cave resolve` adds its target to the world set.", "claude-code-plugins": "`plugin install` clears the `auto` flag of a plugin.", "elan": "`toolchain gc` removes unused toolchains, whatever the reason.", "emerge": "`emerge` adds its target to the world set.", "juliaup": "`gc` keeps every version a channel points at.", "pkg-tools": "`pkg_add` tags an installed package as installed manually.", "spack": "`spack install` marks an installed spec as explicit.", "vagrant": "`box prune` removes older box versions, whatever the reason.", "zypper": "zypper has no command that changes only the install reason.", } """Managers that remove orphans but need no `mark_explicit`, with the reason.""" def _removes_orphans(manager: PackageManager) -> bool: """Whether an orphan sweep or a recursive removal reaches this manager.""" return ( implements_method(manager, "cleanup_orphan") or cleanup_orphan_is_synthesized(manager) or implements_method(manager, "remove_orphan") )
[docs] @pytest.mark.parametrize( "manager", [manager for manager in pool.values() if _removes_orphans(manager)], ids=lambda manager: manager.id, ) def test_orphan_removal_spares_explicit_installs(manager): """A manager that removes orphans marks an explicit install. Or it says why it needs not. """ assert implements_method(manager, "mark_explicit") is ( manager.id not in EXPLICIT_INSTALL_NOT_MARKED )
[docs] def test_explicit_install_exemptions_remove_orphans(): """Every exemption names a manager that still removes orphans.""" assert not [ manager_id for manager_id in EXPLICIT_INSTALL_NOT_MARKED if not _removes_orphans(pool[manager_id]) ]
[docs] @pytest.mark.parametrize( ("manager_id", "expected"), ( ("apt", ["manual", "firefox"]), ("apt-mint", ["manual", "firefox"]), ("aptitude", ["unmarkauto", "firefox"]), ("aura", ["--database", "--asexplicit", "firefox"]), ("dkp-pacman", ["--database", "--asexplicit", "firefox"]), ("dnf", ["mark", "install", "firefox"]), ("dnf5", ["mark", "user", "firefox"]), ("flatpak", ["pin", "firefox"]), ("nala", ["manual", "firefox"]), ("pacaur", ["--database", "--asexplicit", "firefox"]), ("pacman", ["--database", "--asexplicit", "firefox"]), ("pamac", ["--database", "--asexplicit", "firefox"]), ("paru", ["--database", "--asexplicit", "firefox"]), ("pikaur", ["--database", "--asexplicit", "firefox"]), ("pkg", ["set", "--automatic", "0", "--yes", "firefox"]), ("shelly", ["mark", "explicit", "firefox"]), ("trizen", ["--database", "--asexplicit", "firefox"]), ("xbps", ["--mode", "manual", "firefox"]), ("yay", ["--database", "--asexplicit", "firefox"]), ), ) def test_mark_explicit_uses_native_command(monkeypatch, manager_id, expected): """`mark_explicit` runs the command that changes only the install reason.""" tokens = _capture_run_cli( monkeypatch, manager_id, lambda m: m.mark_explicit("firefox") ) assert tokens == expected
[docs] @pytest.mark.parametrize( ("version", "verb"), ((None, "install"), ("4.24.0", "install"), ("5.4.3.0", "user")), ) def test_yum_mark_explicit_follows_dnf_generation(monkeypatch, version, verb): """`yum` fronts dnf4 or dnf5, whose `mark` verbs differ.""" monkeypatch.setitem( pool["yum"].__dict__, "version", parse_version(version) if version else None ) tokens = _capture_run_cli(monkeypatch, "yum", lambda m: m.mark_explicit("firefox")) assert tokens == ["mark", verb, "firefox"]
[docs] @pytest.mark.parametrize("manager_id", ("brew", "emerge", "spack", "zypper")) def test_mark_explicit_unsupported_raises_not_implemented(manager_id): with pytest.raises(NotImplementedError): pool[manager_id].mark_explicit("firefox")
_FLATPAK_RUNTIMES = "org.freedesktop.Platform\t24.08\norg.gnome.Platform\t48\n"
[docs] @pytest.mark.parametrize( ("package_id", "expected"), ( ("org.gnome.Platform", True), ("org.gnome.Platform//48", True), ("org.gnome.Platform//47", False), ("org.gnome.Dictionary", False), ), ) def test_flatpak_may_hold_only_runtimes_as_dependencies( stub_run_cli, package_id, expected ): """Only an installed runtime can be a dependency, by ID or by `ID//BRANCH`.""" manager = pool["flatpak"] stub_run_cli(manager, _FLATPAK_RUNTIMES) assert manager.may_hold_as_dependency(package_id) is expected
[docs] def test_flatpak_may_hold_as_dependency_survives_a_failed_listing(monkeypatch): manager = pool["flatpak"] def failing_run_cli(*args, **kwargs): raise CLIError(1, "", "error: runtime listing failed") monkeypatch.setattr(manager, "run_cli", failing_run_cli) assert manager.may_hold_as_dependency("org.gnome.Platform") is False
# CLI plumbing, driven through deterministic fakes.
[docs] class RemovableFakeManager(FakeManager): """Fake manager that removes packages but has no scoped orphan-cascade verb. Exercises the `remove --orphans` fallback: `remove_orphan` stays unimplemented (base `NotImplementedError`) while `remove` succeeds, so the CLI must catch the former and fall back to the latter. """
[docs] def remove(self, package_id: str) -> str: return f"Removed {package_id}."
[docs] class CleanupFakeManager(FakeManager): """Fake manager with a cache category only: no system-wide orphan sweep.""" def __init__(self) -> None: super().__init__() self.calls: list[str] = []
[docs] def cleanup_cache(self) -> None: self.calls.append("cleanup_cache")
[docs] class SweepSynthesizingFakeManager(CleanupFakeManager): """Fake manager with cleanup, orphan listing and removal, but no sweep. The orphan listing is inherited, and it has no native sweep verb: `cleanup --orphans` must go through the synthesized base sweep, never the full `cleanup`. """
[docs] def remove(self, package_id: str) -> str: self.calls.append(f"remove:{package_id}") return ""
[docs] class DecomposedCleanupFakeManager(CleanupFakeManager): """Fake manager with both orphan and cache category steps."""
[docs] def cleanup_orphan(self) -> None: self.calls.append("cleanup_orphan")
[docs] @pytest.fixture def removable_fake_pool(patch_pool_with): return patch_pool_with(RemovableFakeManager())
[docs] def test_remove_orphans_falls_back_to_plain_removal(invoke, removable_fake_pool): """`remove --orphans` without a cascade verb removes the package only. It narrates the skip at `INFO` and still exits cleanly. """ result = invoke("--verbosity", "INFO", "remove", "--orphans", "fake-pkg-alpha") assert result.exit_code == 0 assert "Does not implement orphan removal, removing the package only." in ( result.stderr )
[docs] def test_cleanup_orphans_reports_the_sweep(invoke, changing_fake_pool): """The trail counts the packages the sweep removed, and the report names them.""" result = invoke("cleanup", "--orphans") assert result.exit_code == 0 mid = changing_fake_pool.id assert f"✓ {mid}.cleanup (orphans: 1 removed)" in strip_ansi(result.stderr) check_report(result.stdout, {"fake-pkg-epsilon": (PackageOutcome.REMOVED.label,)})
[docs] @pytest.mark.parametrize( ("manager_class", "args", "expected_calls"), ( # A manager decomposed into an orphan and a cache category. No flag runs # the non-destructive default categories only: an orphan sweep does not # join in without an explicit `--orphans`. pytest.param( DecomposedCleanupFakeManager, (), ["cleanup_cache"], id="decomposed" ), # `--skip-orphans` is a harmless no-op: orphans is not in the default. pytest.param( DecomposedCleanupFakeManager, ("--skip-orphans",), ["cleanup_cache"], id="decomposed-skip-orphans", ), # Positive flags narrow the run to exactly the listed categories, so # `--orphans` runs the sweep and never the full `cleanup`. pytest.param( DecomposedCleanupFakeManager, ("--orphans",), ["cleanup_orphan"], id="decomposed-orphans", ), pytest.param( DecomposedCleanupFakeManager, ("--cache",), ["cleanup_cache"], id="decomposed-cache", ), pytest.param( DecomposedCleanupFakeManager, ("--orphans", "--cache"), ["cleanup_orphan", "cleanup_cache"], id="decomposed-orphans-cache", ), # Skip flags subtract categories from the default selection: skipping cache # leaves nothing, since orphans never joins without a positive flag. pytest.param( DecomposedCleanupFakeManager, ("--skip-cache",), [], id="decomposed-skip-cache", ), # An unsupported selected category is simply not run. pytest.param( DecomposedCleanupFakeManager, ("--repair",), [], id="decomposed-repair" ), # A cache-only manager runs its single category under `--cache`. With an # orphan listing but no removal, it has no sweep at all, so `--orphans` # skips it rather than run a full cleanup. pytest.param( CleanupFakeManager, ("--cache",), ["cleanup_cache"], id="cache-only" ), pytest.param(CleanupFakeManager, ("--orphans",), [], id="cache-only-orphans"), # With a removal but no native sweep verb, `--orphans` synthesizes the # sweep: the listed orphans are removed one by one, and the full `cleanup` # never runs. pytest.param( SweepSynthesizingFakeManager, ("--orphans",), ["remove:fake-orphan-alpha"], id="synthesized-orphans", ), # A plain run keeps to the native categories. The flag pairs default to # `True`, so `--help` renders each default as its positive side, and only # a flag the user actually set counts as a positive that engages the # synthesized sweep. pytest.param( SweepSynthesizingFakeManager, (), ["cleanup_cache"], id="synthesized" ), # A skip flag subtracts from the native bundle only: `--skip-cache` must # not surprise-remove packages, so the manager is skipped entirely. pytest.param( SweepSynthesizingFakeManager, ("--skip-cache",), [], id="synthesized-skip-cache", ), # `--skip-orphans` subtracts the sweep alone: the native categories run. pytest.param( SweepSynthesizingFakeManager, ("--skip-orphans",), ["cleanup_cache"], id="synthesized-skip-orphans", ), ), ) def test_cleanup_category_selection( invoke, patch_pool_with, manager_class, args, expected_calls ): """The tri-state category flags compose, whatever the cleanup steps. Whatever cleanup steps a manager has: an orphan and a cache category, a cache category alone, or a sweep it can only synthesize from its orphan listing and its removal. """ fake = patch_pool_with(manager_class()) result = invoke("cleanup", *args) assert result.exit_code == 0 assert fake.calls == expected_calls
[docs] def test_cleanup_narration_names_categories(invoke, patch_pool_with): """The per-manager narration names the categories dispatched to it. Matching the `✓`/`✘` trail labels. """ fake = patch_pool_with(DecomposedCleanupFakeManager()) result = invoke("--verbosity", "DEBUG", "cleanup", "--orphans", "--cache") assert result.exit_code == 0 assert "Clean up orphans, cache." in result.stderr assert fake.calls == ["cleanup_orphan", "cleanup_cache"]
[docs] def test_cleanup_all_categories_skipped_errors(invoke, patch_pool_with): """Skipping every default category is a usage error, not a silent no-op. `--skip-orphans` is not needed, as orphans is not in the default set. """ fake = patch_pool_with(DecomposedCleanupFakeManager()) result = invoke("cleanup", "--skip-cache", "--skip-repair") assert result.exit_code == 2 assert "Every cleanup category is skipped." in result.stderr assert fake.calls == []
[docs] class ReasonKeepingFakeManager(FakeManager): """Fake manager that keeps the install reason of a package it reinstalls. It keeps the dependency install reason, and has a command changing only that reason. Its `installed` listing holds `fake-pkg-alpha`, and its search finds any package, so a plain `mpm install` reaches it. """ def __init__(self) -> None: super().__init__() self.calls: list[str] = []
[docs] def search(self, query, extended, exact): yield self.package(id=query, latest_version="1.0.0")
[docs] def install(self, package_id, version=None): self.calls.append(f"install:{package_id}") return ""
[docs] def mark_explicit(self, package_id): self.calls.append(f"mark_explicit:{package_id}") return ""
[docs] class FailingMarkFakeManager(ReasonKeepingFakeManager): """Fake manager whose install reason command fails."""
[docs] def mark_explicit(self, package_id): raise CLIError(1, "", "error: database is locked")
[docs] def test_install_marks_an_installed_package_explicit(invoke, patch_pool_with): fake = patch_pool_with(ReasonKeepingFakeManager()) result = invoke("install", "fake-pkg-alpha") assert result.exit_code == 0 assert fake.calls == ["install:fake-pkg-alpha", "mark_explicit:fake-pkg-alpha"]
[docs] def test_install_leaves_a_fresh_package_alone(invoke, patch_pool_with): """A fresh install is explicit already: no reason command runs.""" fake = patch_pool_with(ReasonKeepingFakeManager()) result = invoke("install", "fake-pkg-gamma") assert result.exit_code == 0 assert fake.calls == ["install:fake-pkg-gamma"]
[docs] def test_install_survives_a_failed_mark(invoke, patch_pool_with): """The package is installed even when its reason cannot change: warn only.""" patch_pool_with(FailingMarkFakeManager()) result = invoke("install", "fake-pkg-alpha") assert result.exit_code == 0 assert "Could not mark fake-pkg-alpha as explicitly installed." in result.stderr
[docs] def test_restore_marks_an_installed_package_explicit(invoke, patch_pool_with, tmp_path): fake = patch_pool_with(ReasonKeepingFakeManager()) snapshot = tmp_path / "packages.toml" snapshot.write_text(f'[{fake.id}]\nfake-pkg-alpha = "1.0.0"\n', encoding="UTF-8") result = invoke("restore", str(snapshot)) assert result.exit_code == 0 assert fake.calls == ["install:fake-pkg-alpha", "mark_explicit:fake-pkg-alpha"]
[docs] def test_invocation_drops_inventory_caches(invoke, patch_pool_with): """The inventory an invocation cached is gone once it closes. So the next invocation in the same process sees the package the first one installed. """ fake = patch_pool_with(ReasonKeepingFakeManager()) fake.__dict__["installed_ids"] = frozenset({"fake-pkg-alpha"}) result = invoke("install", "fake-pkg-gamma") assert result.exit_code == 0 assert fake.calls == ["install:fake-pkg-gamma"] assert "installed_ids" not in fake.__dict__