# 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 ast
import inspect
import os
import re
import sys
from pathlib import Path, PurePath
from string import ascii_letters, ascii_lowercase, digits
import pytest
from boltons.iterutils import unique
from boltons.urlutils import URL
from extra_platforms import ALL_PLATFORMS, Platform, is_windows
from meta_package_manager import cli_explore, cli_maintenance
from meta_package_manager.capabilities import Operations
from meta_package_manager.cli import XKCD_MANAGER_ORDER
from meta_package_manager.execution import CLIExecutor
from meta_package_manager.manager import PackageManager
from meta_package_manager.package import Package
from meta_package_manager.pool import pool
from meta_package_manager.version import TokenizedString
from .conftest import all_managers, manager_classes_params
""" Test the structure, data and types returned by all package managers.
This test suite try to automate most of the basic reviewing work for the addition of
new package manager definitions.
"""
[docs]
def test_xkcd_set():
assert len(unique(XKCD_MANAGER_ORDER)) == len(XKCD_MANAGER_ORDER)
assert set(pool.all_manager_ids).issuperset(XKCD_MANAGER_ORDER)
[docs]
@all_managers
def test_unmaintained(manager):
"""An unmaintained manager must document itself with a non-empty
`unmaintained_message`, and any URL embedded in that markdown message must be a
well-formed `http(s)` link.
Conversely, an `unmaintained_message` is only meaningful on an unmaintained
manager.
"""
if manager.unmaintained:
assert manager.unmaintained_message, (
f"Unmaintained manager {manager.id!r} must set an unmaintained_message."
)
if manager.unmaintained_message is not None:
assert manager.unmaintained is True
assert manager.unmaintained_message.strip()
for url in re.findall(r"https?://[^\s)]+", manager.unmaintained_message):
location = URL(url)
assert location
assert location.scheme.lower() in ("http", "https")
[docs]
@all_managers
def test_maintenance_note(manager):
"""A `maintenance_note` is a MyST markdown block whose embedded URLs must be
well-formed `http(s)` links.
It is mutually exclusive with the `unmaintained` flag: a confirmed-dead manager
documents itself with an `unmaintained_message` instead of a watch note.
"""
if manager.maintenance_note is None:
return
assert manager.maintenance_note.strip()
assert manager.unmaintained is False, (
f"Manager {manager.id!r} cannot be both unmaintained and carry a "
"maintenance_note."
)
for url in re.findall(r"https?://[^\s)]+", manager.maintenance_note):
location = URL(url)
assert location
assert location.scheme.lower() in ("http", "https")
[docs]
@all_managers
def test_ascii_id(manager):
"""All package manager IDs should be short ASCII strings."""
assert manager.id
assert manager.id.isascii()
assert manager.id[0] in ascii_lowercase
assert manager.id[-1] in ascii_lowercase + digits
assert set(manager.id).issubset(ascii_lowercase + digits + "-")
# Make sure there is no potential conflix by prefixing a manager ID by
# "no", which is the convention for single manager flags in the CLI.
assert not manager.id.startswith("no")
[docs]
@all_managers
def test_name(manager):
"""Check all managers have a name."""
assert manager.name
assert set(manager.name).issubset(ascii_letters + digits + " '-")
[docs]
def test_unique_names():
assert len({m.name for m in pool.values()}) == len(pool.all_manager_ids)
[docs]
@all_managers
def test_homepage_url(manager):
assert manager.homepage_url
location = URL(manager.homepage_url)
assert location
assert location.scheme.lower() in ("http", "https")
[docs]
@all_managers
def test_requirement(manager):
"""Each manager is required to specify a version requirement or `None`."""
if manager.requirement is not None:
assert set(manager.requirement).issubset(digits + ".>=<!, ")
# Check each version component is lossless once passed via TokenizedString.
for part in manager.requirement.split(","):
part = part.strip().lstrip("><=!")
if part:
assert str(TokenizedString(part)) == part
[docs]
@all_managers
def test_cli_names_type(manager):
"""Check the pointed CLI name and path are file-system compatible."""
assert manager.cli_names
for name in manager.cli_names:
# Underscores appear in real binary names, like OpenBSD's pkg_info.
assert name.replace("-", "").replace(".", "").replace("_", "").isalnum()
assert PurePath(name).name == name
[docs]
@all_managers
def test_cli_search_path(manager):
assert len(set(manager.cli_search_path)) == len(manager.cli_search_path)
for search_path in manager.cli_search_path:
path_obj = Path(search_path).resolve()
assert path_obj.is_absolute()
# Reserved names only exist on Windows: POSIX's is_reserved() always returned
# False and was removed in Python 3.15, so the check is Windows-gated.
if is_windows():
if sys.version_info >= (3, 13):
assert not os.path.isreserved(path_obj)
else:
assert not path_obj.is_reserved()
if path_obj.exists():
assert path_obj.is_file() or path_obj.is_dir()
[docs]
@all_managers
def test_global_args_type(manager):
"""Check that definitions returns CLI args as a list of non-empty strings."""
for global_args in (manager.pre_cmds, manager.pre_args, manager.post_args):
assert all(global_args)
for arg in global_args:
assert set(arg).issubset(ascii_letters + digits + "-=+")
[docs]
@all_managers
def test_version_cli_options(manager):
"""Version CLI options must be a list of non empty strings."""
assert all(manager.version_cli_options)
[docs]
@all_managers
def test_version_regexes(manager):
"""Version regex is required.
Check it compiles and match has a `<version>` group.
"""
assert isinstance(manager.version_regexes, tuple)
for version_regex in manager.version_regexes:
assert isinstance(version_regex, str)
assert version_regex
regex = re.compile(version_regex)
assert "version" in regex.groupindex
[docs]
@pytest.mark.parametrize(
("manager_id", "sample_output", "expected_version"),
(
(
"guix",
"guix (GNU Guix) 1.4.0\nCopyright (C) 2026 the Guix authors\n",
"1.4.0",
),
(
"guix",
"guix (GNU Guix) 1.4.0-2563-gabc1234\nCopyright (C) 2026\n",
"1.4.0-2563-gabc1234",
),
("guix", "guix (GNU Guix) abc1234\nCopyright (C) 2026\n", "abc1234"),
("mise", "2026.6.3 macos-arm64 (2026-06-13)\n", "2026.6.3"),
("nix", "nix-env (Nix) 2.18.1\n", "2.18.1"),
("stew", "stew version v0.7.0\n", "0.7.0"),
("steamcmd", "Valve Corporation - version 1234567890\n", "1234567890"),
),
)
def test_version_regex_matches_sample(manager_id, sample_output, expected_version):
"""Each manager's `version_regexes` parses the documented `--version` output.
Regression guard for the case where `re.VERBOSE` silently swallowed literal
spaces in patterns like `guix \\(GNU Guix\\) ...`, leaving the manager
unable to detect its own version.
"""
manager = pool[manager_id]
matched = None
for regex in manager.version_regexes:
match = re.compile(regex, re.MULTILINE).search(sample_output)
if match and match.group("version"):
matched = match.group("version")
break
assert matched == expected_version
[docs]
@all_managers
def test_cli_path(manager):
if manager.cli_path is not None:
assert isinstance(manager.cli_path, Path)
assert manager.cli_path.is_absolute()
# Reserved names only exist on Windows: POSIX's is_reserved() always returned
# False and was removed in Python 3.15, so the check is Windows-gated.
if is_windows():
if sys.version_info >= (3, 13):
assert not os.path.isreserved(manager.cli_path)
else:
assert not manager.cli_path.is_reserved()
assert manager.cli_path.is_file()
[docs]
@pytest.mark.parametrize(
("query", "query_parts"),
(
("cli-l-cli", {"cli", "l"}),
("ab12--cd34", {"ab12", "cd34"}),
("123/extra.34", {"123", "extra", "34"}),
("AB ab", {"AB", "ab"}),
),
)
def test_query_parts(query, query_parts):
assert Package.query_parts(query) == query_parts
[docs]
@pytest.mark.parametrize(
("pkg_id", "pkg_name", "query", "extended", "exact", "expected"),
(
# Fuzzy (default): case-insensitive, tokenized substring on ID and name.
("gnu-sed", None, "sed", False, False, True),
("gnu-sed", None, "SED", False, False, True),
("gnu-sed", None, "GnU", False, False, True),
("gnu-sed", None, "gnu sed", False, False, True),
("gnu-sed", None, "perl", False, False, False),
# Fuzzy also matches on the display name.
("pkg-1", "GNU Sed", "sed", False, False, True),
# Description is ignored unless extended.
("pkg-1", None, "stream", False, False, False),
("pkg-1", None, "stream", True, False, True),
# Exact: whole-string, case-sensitive, ID or name only.
("gnu-sed", None, "gnu-sed", False, True, True),
("gnu-sed", None, "sed", False, True, False),
("gnu-sed", None, "GNU-SED", False, True, False),
("pkg-1", "GNU Sed", "GNU Sed", False, True, True),
# Empty or punctuation-only queries never match.
("gnu-sed", None, "", False, False, False),
("gnu-sed", None, "@@@", False, False, False),
),
)
def test_package_matches(pkg_id, pkg_name, query, extended, exact, expected):
package = Package(
id=pkg_id,
manager_id="fake",
name=pkg_name,
description="a stream editor",
)
assert package.matches(query, extended=extended, exact=exact) is expected
def _collect_class_members(klass: type, name: str) -> tuple[list[str], list[str]]:
"""Collect a base class's `(attributes, methods)` in declaration order.
Parses the AST so naked type annotations (`AnnAssign`) are collected
alongside plain assignments and function definitions.
"""
tree = ast.parse(Path(inspect.getfile(klass)).read_bytes())
class_node = next(
n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == name
)
attributes: list[str] = []
methods: list[str] = []
for node in class_node.body:
if isinstance(node, ast.AnnAssign):
attributes.append(node.target.id) # type: ignore[union-attr]
if isinstance(node, ast.Assign):
attributes.extend(t.id for t in node.targets) # type: ignore[attr-defined]
if isinstance(node, ast.FunctionDef):
methods.append(node.name)
return attributes, methods
CANONICAL_ATTRS = (
# Identity.
"scope",
"unmaintained",
"unmaintained_message",
"maintenance_note",
"id",
"name",
"homepage_url",
"logo",
# Export mappings.
"brewfile_entry_type",
"brewfile_skip_warning",
"platforms",
"virtual",
# Escalation policy.
"sudo",
"default_sudo",
"internal_sudo",
# Version gate and native cooldown marker.
"requirement",
"cooldown_env_var",
# CLI plumbing.
"cli_names",
"cli_search_path",
"extra_env",
"pre_cmds",
"pre_args",
"post_args",
# Version probe.
"version_cli",
"version_cli_options",
"version_regexes",
# Behavior toggles and platform specifics.
"ignore_auto_updates",
"stop_on_error",
"dry_run",
"plan",
"timeout",
"_active_operation",
"progress",
"cooldown",
"require_cooldown_support",
"windows_creation_flags",
"windows_processes_to_cleanup",
"cli_errors",
"_last_run",
"run_cache",
# Parsing constants.
"_NAME_VERSION_REGEXP",
)
"""Canonical declaration order of the attributes a manager class may override.
The documented convention every manager module follows: identity first, then the
escalation policy, the version requirement, the CLI plumbing, the version probe,
and the rarely-overridden toggles. Manager-specific constants (the `_*_REGEXP`
parsers) are not governed and conventionally sit between the attributes and the
operations. Consumed by `test_content_order`, which checks each manager's
relative declaration order against this sequence.
"""
[docs]
def collect_props_ref():
"""Build the canonical member-order reference for manager classes.
Attributes follow {data}`CANONICAL_ATTRS`; methods follow the base classes'
own declaration order, `CLIExecutor`'s (binary discovery, version probe,
execution engine) before `PackageManager`'s (helpers, availability, then
the operations). The curated attribute list is cross-checked against the
base classes so a new base attribute cannot silently escape governance.
"""
pm_attrs, pm_methods = _collect_class_members(PackageManager, "PackageManager")
exec_attrs, exec_methods = _collect_class_members(CLIExecutor, "CLIExecutor")
declared = set(pm_attrs) | set(exec_attrs)
assert set(CANONICAL_ATTRS) == declared, (
"CANONICAL_ATTRS drifted from the base classes: "
f"missing {declared - set(CANONICAL_ATTRS)}, "
f"stale {set(CANONICAL_ATTRS) - declared}"
)
return (*CANONICAL_ATTRS, *exec_methods, *pm_methods)
props_ref = tuple(collect_props_ref())
[docs]
def test_operation_order():
"""Double check operation IDs are ordered and aligned to the base manager class and
CLI implementation.
The subcommands span two section modules: the read-only queries in
`cli_explore`, the state changers and diagnostics in `cli_maintenance`.
Walked in that order, their definitions must follow the enum order.
"""
direct_operation_ids = [op for op in Operations.__members__ if op != "upgrade_all"]
base_operations = [p for p in props_ref if p in direct_operation_ids]
assert list(direct_operation_ids) == list(base_operations)
implemented_operations: list[str] = []
for cli_module in (cli_explore, cli_maintenance):
cli_tree = ast.parse(Path(inspect.getfile(cli_module)).read_bytes())
implemented_operations.extend(
n.name
for n in cli_tree.body
if isinstance(n, ast.FunctionDef) and n.name in direct_operation_ids
)
assert list(direct_operation_ids) == list(implemented_operations)
# Check the code of each file registered in the pool.
[docs]
@manager_classes_params
def test_content_order(manager_class):
"""Lint each manager class to check its members follow the canonical order.
Attributes must follow {data}`CANONICAL_ATTRS` and methods the base classes'
own declaration order (see {func}`collect_props_ref`); ungoverned names
(manager-specific parsing constants and helpers) may sit anywhere.
"""
# Collect in order the IDs of all attributes (plain and annotated
# assignments alike) and functions of the class.
collected_props = []
klass_tree = ast.parse(inspect.getsource(manager_class))
class_node = klass_tree.body[0]
assert isinstance(class_node, ast.ClassDef)
for node in class_node.body:
if isinstance(node, ast.AnnAssign):
collected_props.append(node.target.id) # type: ignore[union-attr]
if isinstance(node, ast.Assign):
collected_props.extend([t.id for t in node.targets]) # type: ignore[attr-defined]
if isinstance(node, ast.FunctionDef):
collected_props.append(node.name)
enforced_props = tuple(p for p in collected_props if p in props_ref)
expected_order = tuple(p for p in props_ref if p in collected_props)
assert enforced_props == expected_order