Source code for tests.test_pool

# Copyright Kevin Deldycke <kevin@deldycke.com> 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 inspect
import itertools
import threading
from importlib import import_module
from pathlib import Path

import click
import pytest
from click_extra.context import JOBS, VERBOSITY_LEVEL
from click_extra.logging import LogLevel

import meta_package_manager
from meta_package_manager.capabilities import Delegate
from meta_package_manager.cli import mpm
from meta_package_manager.dispatch import (
    SHARED_LOCK_FAMILIES,
    merge_into_probe_lanes,
    warm_availability,
)
from meta_package_manager.labels import MANAGER_LABEL_GROUPS
from meta_package_manager.manager import PackageManager
from meta_package_manager.managers.pacman import Pacman
from meta_package_manager.pool import manager_classes, pool

from .conftest import (
    default_manager_ids,
    maintained_manager_ids,
    unsupported_manager_ids,
)

""" Test the pool and its content. """


[docs] def test_manager_definition_inventory(): """Check all classes implementing a package manager are accounted for in the pool.""" found_classes = set() # Search for manager definitions in the managers subfolder. for py_file in Path(inspect.getfile(meta_package_manager)).parent.glob( "managers/*.py" ): module = import_module( f"meta_package_manager.managers.{py_file.stem}", package=__package__ ) for _, klass in inspect.getmembers(module, inspect.isclass): if issubclass(klass, PackageManager) and not klass.virtual: found_classes.add(klass) assert sorted(map(str, found_classes)) == sorted(map(str, manager_classes))
[docs] def test_manager_classes_order(): """Check manager classes are ordered by their IDs.""" assert [c.__name__ for c in manager_classes] == sorted( (c.__name__ for c in manager_classes), key=str.casefold )
[docs] def test_manager_count(): """Check all implemented package managers are accounted for, and unique.""" assert len(manager_classes) == 78 # Forty-one extra beyond the built-in classes: the bundled config-defined # managers (apt-cyg, basalt, bpkg, bun, cargo, cave, choco, chromebrew, clib, # cpan, fink, # gcloud, gh-ext, haxelib, jpm, juliaup, krew, macports, micro, ollama, opam, # opkg, pamac, pkg-tools, pkgin, pyenv, rustup, slapt-get, soar, sorcery, # steamcmd, stew, swupd, tlmgr, topgrade, urpmi, vscode, vscodium, xcodes, # yazi, zerobrew), shipped as package data and loaded into the pool at # construction. assert len(pool) == 119 assert len(pool) == len(pool.all_manager_ids) assert pool.all_manager_ids == tuple(sorted(set(pool)))
[docs] def test_shared_lock_families_are_disjoint(): """No manager may belong to two lock families, or its lane grouping is ambiguous.""" seen: set[str] = set() for family in SHARED_LOCK_FAMILIES: overlap = seen & family.members assert not overlap, f"managers in more than one lock family: {overlap}" seen |= family.members
[docs] def test_shared_lock_family_members_exist_in_pool(): """Every lock-family member must be a real manager id (catches typos).""" for manager_id in set().union(*(f.members for f in SHARED_LOCK_FAMILIES)): assert manager_id in pool.all_manager_ids
[docs] def test_lock_families_nest_in_label_groups(): """Every lock family must sit inside a single ecosystem label group. The two constants answer different questions. {data}`MANAGER_LABEL_GROUPS` groups managers by the packaging ecosystem an issue lands in, {data}`SHARED_LOCK_FAMILIES` the ones that cannot run at once on a real host. The implication runs one way only: contending for a backend lock means sharing that backend's ecosystem, so a lock family is always contained in a label group. Never the reverse, and the gaps are the point: `fink` and `opkg` speak dpkg through databases of their own, `zerobrew` has its own prefix and `dkp-pacman` its own repositories, while `pypi-based`, `npm-based` and the plugin-manager groups share a registry or a host program rather than any lock at all. Asserting equality here would be asserting those four facts away. Catches a manager wired into a lock family but forgotten in the label groups, which is what it caught for `nala`. """ for family in SHARED_LOCK_FAMILIES: assert any( family.members <= group for group in MANAGER_LABEL_GROUPS.values() ), ( f"lock family {sorted(family.members)} is not contained in any ecosystem " "label group" )
[docs] def test_delegating_managers_share_their_target_lock(): """A manager delegating an operation to another's CLI runs that manager's own binary against that manager's own state, so the two must serialize. The delegation is declared in the class body ({class}`Delegate`), which makes this derivable rather than a list to keep in step: wiring a new delegate without a lock family fails here. """ family_of = {mid: f for f in SHARED_LOCK_FAMILIES for mid in f.members} id_of = {type(pool[manager_id]): manager_id for manager_id in pool.all_manager_ids} for manager_id in pool.all_manager_ids: for klass in type(pool[manager_id]).__mro__: for attribute in vars(klass).values(): if not isinstance(attribute, Delegate): continue target = id_of[attribute.source_class] sharing = family_of.get(manager_id) assert sharing and target in sharing.members, ( f"{manager_id} delegates to {target} but they do not share a " "lock family" )
[docs] def test_pacman_subclasses_share_the_pacman_lock(): """Every manager inheriting from `Pacman` drives pacman's own database, so it must serialize with the rest of that family. `dkp-pacman` is the sole exemption: devkitPro ships it to sit beside a distribution's `pacman` with its own repositories and database, so it contends with nothing. Any *other* subclass missing from the family is the bug this catches, which is how the AUR helpers went unserialized through `7.6.1`. Inheritance is a sufficient signal, never a necessary one: `pamac` reaches the same `libalpm` through a bundled definition and is covered by the family without appearing here. """ family = next(f for f in SHARED_LOCK_FAMILIES if "pacman" in f.members).members for manager_id in pool.all_manager_ids: if manager_id == "dkp-pacman" or not isinstance(pool[manager_id], Pacman): continue assert manager_id in family, ( f"{manager_id} inherits from Pacman but is missing from its lock family" )
[docs] def test_cached_pool(): assert pool == pool # noqa: PLR0124 assert pool is pool # noqa: PLR0124
[docs] @maintained_manager_ids def test_maintained_managers(manager_id): assert pool[manager_id].unmaintained is False
[docs] @default_manager_ids def test_supported_managers(manager_id): assert pool[manager_id].supported is True
[docs] @unsupported_manager_ids def test_unsupported_managers(manager_id): assert pool[manager_id].supported is False
[docs] def test_manager_groups(): """Test relationships between manager groups.""" assert set(pool.maintained_manager_ids).issubset(pool.all_manager_ids) assert set(pool.default_manager_ids).issubset(pool.all_manager_ids) assert set(pool.unsupported_manager_ids).issubset(pool.all_manager_ids) assert set(pool.default_manager_ids).issubset(pool.maintained_manager_ids) assert set(pool.unsupported_manager_ids).issubset(pool.maintained_manager_ids) assert len(pool.default_manager_ids) + len(pool.unsupported_manager_ids) == len( pool.maintained_manager_ids, ) assert ( tuple(sorted(set(pool.default_manager_ids).union(pool.unsupported_manager_ids))) == pool.maintained_manager_ids )
[docs] def test_extra_option_allowlist(): assert pool.ALLOWED_EXTRA_OPTION.issubset(opt.name for opt in mpm.params)
selection_cases = { # Selection-logic cases pass `drop_not_found=False` so they test only # how `keep`/`drop` plumbing handles ordering, deduplication and # collection types, without depending on whether the named managers # have a real binary on PATH. Hermetic builders (Guix, Nixpkgs, etc.) # otherwise see these cases return empty tuples because `uv` and # `gem` are not installed. "single_selector": ( {"keep": ("uv",), "drop_not_found": False}, ("uv",), ), "list_input": ( {"keep": ["uv"], "drop_not_found": False}, ("uv",), ), "set_input": ( {"keep": {"uv"}, "drop_not_found": False}, ("uv",), ), "empty_selector": ( {"keep": (), "drop_not_found": False}, (), ), "duplicate_selectors": ( {"keep": ("uv", "uv"), "drop_not_found": False}, ("uv",), ), "multiple_selectors": ( {"keep": ("uv", "gem"), "drop_not_found": False}, ("uv", "gem"), ), "ordered_selectors": ( {"keep": ("gem", "uv"), "drop_not_found": False}, ("gem", "uv"), ), "single_exclusion": ( {"drop": {"uv"}}, tuple( mid for mid in pool.all_manager_ids if pool[mid].supported and pool[mid].available and mid != "uv" ), ), "duplicate_exclusions": ( {"drop": ("uv", "uv")}, tuple( mid for mid in pool.all_manager_ids if pool[mid].supported and pool[mid].available and mid != "uv" ), ), "multiple_exclusions": ( {"drop": ("uv", "gem")}, tuple( mid for mid in pool.all_manager_ids if pool[mid].supported and pool[mid].available and mid not in ("uv", "gem") ), ), "selector_priority": ( {"keep": {"uv"}, "drop": {"gem"}, "drop_not_found": False}, ("uv",), ), "exclusion_override": ( {"keep": {"uv"}, "drop": {"uv"}, "drop_not_found": False}, (), ), "default_selection": ( {}, tuple( mid for mid in pool.all_manager_ids if pool[mid].supported and pool[mid].available ), ), "explicit_default_selection": ( {"keep": None, "drop": None}, tuple( mid for mid in pool.all_manager_ids if pool[mid].supported and pool[mid].available ), ), "keep_unmaintained": ( {"keep_unmaintained": True}, tuple(mid for mid in pool.all_manager_ids if pool[mid].available), ), "drop_unmaintained": ( {"keep_unmaintained": False}, tuple( mid for mid in pool.all_manager_ids if not pool[mid].unmaintained and pool[mid].supported and pool[mid].available ), ), "keep_unsupported": ( {"keep_unsupported": True}, tuple(mid for mid in pool.all_manager_ids if pool[mid].available), ), "drop_unsupported": ( {"keep_unsupported": False}, tuple( mid for mid in pool.all_manager_ids if pool[mid].supported and pool[mid].available ), ), "drop_not_found": ( {"drop_not_found": True}, tuple( mid for mid in pool.all_manager_ids if not pool[mid].unmaintained and pool[mid].supported and pool[mid].available ), ), "keep_not_found": ( {"drop_not_found": False}, tuple( mid for mid in pool.all_manager_ids if not pool[mid].unmaintained and pool[mid].supported ), ), }
[docs] @pytest.mark.parametrize( ("kwargs", "expected"), tuple( pytest.param(kwargs, expected, id=test_id) for test_id, (kwargs, expected) in selection_cases.items() ), ) def test_select_managers(kwargs, expected): """We use tuple everywhere so we can check that select_managers() conserve the original order.""" selection = pool._select_managers(**kwargs) assert tuple(m.id for m in selection) == expected
[docs] def test_select_managers_timeout_stamping(): """The user's `--timeout` lands on every selected manager even when unavailable ones are kept (`mpm managers`), whose version probes fire lazily at rendering time, after selection: an unstamped instance would fall back to the read-only default and let a wedged binary hold each table row for 120 seconds. A per-manager `[mpm.managers.<id>]` override keeps precedence over the global option. """ originals = {mid: pool[mid].timeout for mid in ("gem", "uv")} try: selection = pool._select_managers( keep=("gem", "uv"), drop_not_found=False, timeout=987, ) assert [manager.timeout for manager in selection] == [987, 987] pool.overridden_fields.setdefault("gem", set()).add("timeout") try: selection = pool._select_managers( keep=("gem", "uv"), drop_not_found=False, timeout=123, ) assert [manager.timeout for manager in selection] == [987, 123] finally: pool.overridden_fields["gem"].discard("timeout") finally: for mid, value in originals.items(): pool[mid].timeout = value
class _RecordingManager: """Stand-in whose `available` probe records the thread it ran on. Carries the four attributes {func}`probe_signature` reads. `probe` varies them per instance by default, so each stand-in lands on a lane of its own; passing the same `probe` to several puts them on a shared lane instead. """ cli_search_path: tuple[str, ...] = () version_cli = None version_cli_options: tuple[str, ...] = ("--version",) run_cache: dict | None = None _counter = itertools.count() def __init__(self, log: list, probe: str | None = None) -> None: self._log = log self.cli_names = (probe or f"fake-cli-{next(self._counter)}",) self.cache_seen: dict | None = None self.thread_seen: threading.Thread | None = None @property def available(self) -> bool: self._log.append(threading.current_thread()) # The cache is installed for the round only, so snapshot it while probing. self.cache_seen = self.run_cache self.thread_seen = threading.current_thread() return True def _jobs_context(jobs: int, verbosity: str = "INFO") -> click.Context: ctx = click.Context(click.Command("mpm")) ctx.meta[JOBS] = jobs ctx.meta[VERBOSITY_LEVEL] = LogLevel[verbosity] return ctx
[docs] def test_warm_availability_skips_without_context(): """No active CLI context: leave probing to the lazy, sequential filter loop.""" accessed: list = [] managers = [_RecordingManager(accessed), _RecordingManager(accessed)] warm_availability(managers) # type: ignore[arg-type] assert accessed == []
[docs] @pytest.mark.parametrize( ("jobs", "verbosity", "count"), ( (1, "INFO", 4), # --jobs 1 leaves probing to the sequential loop. (4, "DEBUG", 4), # DEBUG keeps the interleaved probe logs readable. (4, "INFO", 1), # A single candidate has nothing to parallelize. ), ) def test_warm_availability_skips_when_not_concurrent(jobs, verbosity, count): accessed: list = [] managers = [_RecordingManager(accessed) for _ in range(count)] with _jobs_context(jobs, verbosity): warm_availability(managers) # type: ignore[arg-type] assert accessed == []
[docs] def test_warm_availability_probes_concurrently(): """With --jobs > 1 and several candidates, probes run off the main thread.""" threads: list = [] managers = [_RecordingManager(threads) for _ in range(4)] with _jobs_context(jobs=4): warm_availability(managers) # type: ignore[arg-type] assert len(threads) == 4 assert all(thread is not threading.main_thread() for thread in threads)
[docs] def test_merge_into_probe_lanes_groups_by_signature(): """Managers whose probe may resolve to the same command share a lane.""" accessed: list = [] twins = [_RecordingManager(accessed, probe="shared-cli") for _ in range(3)] loners = [_RecordingManager(accessed) for _ in range(2)] lanes = merge_into_probe_lanes([*twins, *loners]) # type: ignore[arg-type] assert [len(lane) for lane in lanes] == [3, 1, 1] assert lanes[0] == tuple(twins)
[docs] def test_merge_into_probe_lanes_partitions_the_whole_pool(): """Every manager lands on exactly one lane, whatever the pool grows to.""" lanes = merge_into_probe_lanes(pool.values()) laned = [manager for lane in lanes for manager in lane] assert len(laned) == len(pool) assert {manager.id for manager in laned} == set(pool.all_manager_ids)
[docs] def test_warm_availability_shares_one_cache_within_a_lane(): """A lane's managers probe against one shared `run_cache`, then get their own value back: that dict is what collapses `brew` and `cask` both running `brew --version` into a single subprocess.""" accessed: list = [] twins = [_RecordingManager(accessed, probe="shared-cli") for _ in range(2)] loner = _RecordingManager(accessed) sentinel: dict = {} twins[1].run_cache = sentinel with _jobs_context(jobs=4): warm_availability([*twins, loner]) # type: ignore[arg-type] assert twins[0].cache_seen is twins[1].cache_seen is not None # A lane of one gets no cache at all: there is no peer to share with. assert loner.cache_seen is None # A lane runs on a single worker, which is what makes the shared dict safe. assert twins[0].thread_seen is twins[1].thread_seen # Pre-existing values are restored rather than blanked. assert twins[0].run_cache is None assert twins[1].run_cache is sentinel