# 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__