Source code for meta_package_manager.definitions

# 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.
"""Declarative package managers: the TOML schema and its class factory.

A ``[mpm.managers.<id>]`` configuration section describes a manager as data. This
module owns everything that turns such a description into a live
:py:class:`~meta_package_manager.manager.PackageManager` subclass:

- the **schema vocabulary**: which manager attributes a section may set, both on a
  shipped manager (:data:`OVERRIDABLE_FIELDS`) and on a brand-new definition
  (:data:`DEFINITION_CLI_FIELDS`, the operations DSL constants);
- the **validation and parsing** layer (:func:`parse_manager_definition`), shared
  by ``--validate-config`` and the runtime registration path so a config that
  survives one survives the other;
- the **class factory** (:func:`build_manager_class`), which synthesizes a
  :class:`ConfigDrivenManager` subclass implementing exactly the operations the
  definition declares;
- the **bundled-definition loader** (:func:`load_bundled_definitions`,
  :func:`build_bundled_managers`): mpm ships some managers as ``*.toml`` package
  data under ``meta_package_manager/managers/``, each a single
  ``[mpm.managers.<id>]`` section in the exact schema a user would write.

The runtime *policy* around definitions stays in
:py:mod:`meta_package_manager.config`: where sections may be loaded from, the
trust gate on local files, the override-application pass and the registration
passes wired into the CLI. The split keeps this module dependent on
:py:mod:`meta_package_manager.manager` only, so the configuration layer can build
on it without a circular import.
"""

from __future__ import annotations

import importlib.resources
import logging
import re
import sys
from dataclasses import dataclass
from functools import cache
from typing import cast

from click_extra.config import ValidationError
from extra_platforms import ALL_GROUP_IDS, ALL_PLATFORMS, traits_from_ids

from .manager import MetaPackageManager, PackageManager

if sys.version_info >= (3, 11):
    import tomllib
else:
    import tomli as tomllib  # type: ignore[import-not-found]

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Callable, Iterator, Mapping
    from pathlib import Path
    from typing import Any, Final

    from .package import Package


def _to_str(value: Any) -> str:
    """Validate that the value is a string."""
    if not isinstance(value, str):
        raise TypeError(f"expected a string, got {type(value).__name__}: {value!r}")
    return value


def _to_bool(value: Any) -> bool:
    """Validate that the value is a boolean."""
    if not isinstance(value, bool):
        raise TypeError(f"expected a boolean, got {type(value).__name__}: {value!r}")
    return value


def _to_int(value: Any) -> int:
    """Validate that the value is an integer.

    Rejects ``bool`` because :py:class:`bool` is a subclass of :py:class:`int`
    in Python, but configuration intent is unambiguous: a boolean is never an
    acceptable integer.
    """
    if not isinstance(value, int) or isinstance(value, bool):
        raise TypeError(f"expected an integer, got {type(value).__name__}: {value!r}")
    return value


def _to_str_tuple(value: Any) -> tuple[str, ...]:
    """Validate that the value is a list/tuple of strings, return a tuple."""
    if not isinstance(value, (list, tuple)):
        raise TypeError(
            f"expected a list of strings, got {type(value).__name__}: {value!r}"
        )
    for item in value:
        if not isinstance(item, str):
            raise TypeError(
                f"expected all entries to be strings, "
                f"got {type(item).__name__}: {item!r}"
            )
    return tuple(value)


def _to_str_dict(value: Any) -> dict[str, str]:
    """Validate that the value is a mapping of strings to strings."""
    if not isinstance(value, dict):
        raise TypeError(
            f"expected a table of string-to-string, "
            f"got {type(value).__name__}: {value!r}"
        )
    for k, v in value.items():
        if not isinstance(k, str) or not isinstance(v, str):
            raise TypeError(
                f"expected a table of string-to-string entries, got {k!r} = {v!r}"
            )
    return dict(value)


OVERRIDABLE_FIELDS: Final[Mapping[str, Callable[[Any], Any]]] = {
    "cli_names": _to_str_tuple,
    "cli_search_path": _to_str_tuple,
    "deprecated": _to_bool,
    "dry_run": _to_bool,
    "extra_env": _to_str_dict,
    "ignore_auto_updates": _to_bool,
    "post_args": _to_str_tuple,
    "pre_args": _to_str_tuple,
    "pre_cmds": _to_str_tuple,
    "requirement": _to_str,
    "stop_on_error": _to_bool,
    "sudo": _to_bool,
    "timeout": _to_int,
    "version_cli_options": _to_str_tuple,
    "version_regexes": _to_str_tuple,
}
"""Per-manager attributes a user is allowed to override from the ``[mpm.managers.<id>]``
configuration section.

Each entry maps a :py:class:`meta_package_manager.manager.PackageManager` attribute name
to a converter that validates the raw TOML value and returns the value as the
attribute's expected runtime type. Lists are coerced into tuples to match the
attributes' tuple types.

.. note::
    ``id``, ``name``, ``platforms``, ``homepage_url`` and ``virtual`` are
    intentionally excluded: they are identity, lookup or platform-classification
    attributes that the pool's registration relies on. Phase 1 of TOML-driven
    configuration only exposes attributes whose runtime override is safe.
"""


VALID_PLATFORM_TOKENS: Final[frozenset[str]] = frozenset(
    {platform.id for platform in ALL_PLATFORMS} | set(ALL_GROUP_IDS),
)
"""Platform and group IDs accepted in a definition's ``platforms`` list.

Union of every :py:class:`extra_platforms.Platform` ID and every group ID, so both a
specific platform (``ubuntu``) and a group (``linux``, ``all_platforms``) resolve.
"""


DEFINITION_CLI_FIELDS: Final[Mapping[str, Callable[[Any], Any]]] = {
    **{
        name: OVERRIDABLE_FIELDS[name]
        for name in (
            "cli_names",
            "cli_search_path",
            "extra_env",
            "post_args",
            "pre_args",
            "pre_cmds",
            "requirement",
            "timeout",
            "version_cli_options",
            "version_regexes",
        )
    },
    # Definition-only fields, with no OVERRIDABLE_FIELDS counterpart: a built-in
    # manager's escalation policies, version probe and Brewfile mapping are reviewed
    # code, while a definition declares them as data.
    "brewfile_entry_type": _to_str,
    "brewfile_skip_warning": _to_str,
    "default_sudo": _to_bool,
    "internal_sudo": _to_bool,
    "version_cli": _to_str,
}
"""CLI-execution attributes a definition may set, mostly reusing the override
converters.

The runtime-preference fields (``deprecated``, ``dry_run``, ``ignore_auto_updates``,
``stop_on_error``) are excluded: they are command-line/global concerns, not part of a
manager's identity, and resolve through the usual option precedence.

Five fields are definition-only:

- ``brewfile_entry_type`` maps the manager onto a Homebrew Bundle DSL entry so its
  installed packages join ``mpm dump --brewfile`` exports (see
  :py:attr:`~meta_package_manager.manager.PackageManager.brewfile_entry_type`).
- ``brewfile_skip_warning`` is the message emitted when the manager's packages are
  deliberately left out of such an export (see
  :py:attr:`~meta_package_manager.manager.PackageManager.brewfile_skip_warning`).
- ``default_sudo`` is the manager's built-in escalation policy (see
  :py:attr:`~meta_package_manager.execution.CLIExecutor.default_sudo`). Operations
  marked ``sudo = true`` escalate by default, while the user's global ``--no-sudo``
  flag or a ``sudo`` override still win.
- ``internal_sudo`` marks a manager whose CLI invokes ``sudo`` itself mid-run (see
  :py:attr:`~meta_package_manager.execution.CLIExecutor.internal_sudo`). mpm never
  wraps its commands in ``sudo``; priming instead reuses a warm credential cache
  for these internal escalations. See ``docs/sudo.md``.
- ``version_cli`` names an alternate binary for the version probe (see
  :py:attr:`~meta_package_manager.execution.CLIExecutor.version_cli`), for suites
  whose own binaries expose no version flag (OpenBSD's ``pkg_add``).
"""


DEFINITION_IDENTITY_FIELDS: Final[frozenset[str]] = frozenset(
    {"name", "platforms", "homepage_url", "operations"},
)
"""Top-level keys of a definition section that are not CLI-execution fields."""


QUERY_OPERATIONS: Final[frozenset[str]] = frozenset(
    {"installed", "outdated", "search"},
)
"""Operations that parse the command's stdout into packages."""


COMMAND_OPERATIONS: Final[frozenset[str]] = frozenset(
    {"install", "remove", "sync", "cleanup", "upgrade_one", "upgrade_all"},
)
"""Operations that only run a command and produce no inventory to parse."""


ALL_DEFINITION_OPERATIONS: Final[frozenset[str]] = QUERY_OPERATIONS | COMMAND_OPERATIONS
"""Every operation name a definition may declare."""


RECOGNIZED_PARSE_FIELDS: Final[frozenset[str]] = frozenset(
    {"package_id", "installed_version", "latest_version"},
)
"""Named regex groups / JSON field keys a query parser may map to a package."""


REQUIRED_PARSE_FIELDS: Final[Mapping[str, frozenset[str]]] = {
    "installed": frozenset({"package_id"}),
    "outdated": frozenset({"package_id", "latest_version"}),
    "search": frozenset({"package_id"}),
}
"""Parse fields each query operation must extract to be useful.

``installed`` needs only the package ID: some tools genuinely track no per-package
version (Clear Linux bundles under ``swupd``, Cygwin listings under ``apt-cyg``), and
mpm's package model treats the installed version as optional everywhere. ``outdated``
without a ``latest_version`` would report nothing actionable, so there the version
capture stays mandatory.
"""


OPERATION_ARG_PLACEHOLDER: Final[Mapping[str, str]] = {
    "install": "package_id",
    "remove": "package_id",
    "upgrade_one": "package_id",
}
"""Placeholder each operation's ``args`` must reference, so a value is actually passed
to the CLI (a ``remove`` with no ``{package_id}`` would target nothing).

``search`` is deliberately absent: its ``{query}`` placeholder is optional. A tool
with no real search command can still declare the operation by listing its whole
catalog (``opkg list``, ``swupd bundle-list --all``) and letting
:py:meth:`meta_package_manager.manager.PackageManager.refiltered_search` narrow the
results, mirroring the search-from-scratch augmentation some built-in managers use.
"""


SEARCH_REFINEMENT_KEYS: Final[frozenset[str]] = frozenset(
    {"exact_args", "extended_args", "id_name_only_args"},
)
"""Optional per-refinement argument templates of the ``search`` operation.

Each key holds the CLI arguments spliced into the ``args`` template β€” at the
position of the matching ``{exact_args}``-style marker β€” when the refinement is
active: ``exact_args`` for an ``--exact`` search, ``extended_args`` for an
``--extended`` one, and ``id_name_only_args`` for the default ID/name-restricted
mode (mpm's ``--id-name-only``, for tools like Chocolatey whose *unrestricted*
search is the default and take a flag to narrow it). An inactive refinement
expands its marker to nothing.

Declaring a key advertises native support for the matching mpm flag
(``exact_args`` sets the ``search`` method's ``exact_support`` introspection
attribute, either of the other two sets ``extended_support``), which feeds the
augmentations documentation.
:py:meth:`meta_package_manager.manager.PackageManager.refiltered_search` still
refines the results client-side either way, exactly as for the built-in managers.
"""


ALLOWED_ARG_PLACEHOLDERS: Final[Mapping[str, frozenset[str]]] = {
    "install": frozenset({"package_id"}),
    "remove": frozenset({"package_id"}),
    "search": frozenset({"query"}) | SEARCH_REFINEMENT_KEYS,
    "upgrade_one": frozenset({"package_id"}),
}
"""Placeholders each operation's ``args`` may reference.

Operations absent from this mapping take no placeholder at all. Any ``{token}``
outside the operation's set is rejected at parse time: a typoed ``{qeury}`` would
otherwise reach the CLI as a literal argument and fail in silent, tool-specific ways.
"""


ARG_PLACEHOLDER_REGEX: Final = re.compile(r"\{([a-z_]+)\}")
"""Match ``{placeholder}`` tokens in an operation's args, for validation."""


JSON_FIELD_SELECTOR_REGEX: Final = re.compile(
    r"^(?P<key>[^\[\]]+?)(?:\[(?P<index>\d+)\])?$",
)
"""Parse a JSON field selector: a key name with an optional ``[N]`` list index.

A bare ``version`` maps the package field to the item's ``version`` key. A
``versions[0]`` selector additionally picks one element out of a list-valued key
(zerobrew reports each package's installed versions as an array). Anything more
nested stays out of the DSL on purpose: a manager needing real JSON traversal is
better served by a Python class.
"""


QUERY_OPERATION_KEYS: Final[frozenset[str]] = frozenset(
    {"args", "cli", "regex", "format", "fields", "list_path", "sudo"},
)
"""Keys allowed in a query operation's table.

``cli`` is the same alternate-binary hook as on command operations. ``sudo = true``
marks the query as privileged, for the rare tool that gates even its read-only
listings behind root (``deb-get``'s upgradable check); escalation then follows the
usual per-manager policy.
"""


SEARCH_OPERATION_KEYS: Final[frozenset[str]] = (
    QUERY_OPERATION_KEYS | SEARCH_REFINEMENT_KEYS
)
"""Keys allowed in the ``search`` operation's table: a query operation plus the
per-refinement argument templates of :data:`SEARCH_REFINEMENT_KEYS`."""


COMMAND_OPERATION_KEYS: Final[frozenset[str]] = frozenset({"args", "cli", "sudo"})
"""Keys allowed in a command operation's table.

``cli`` names an alternate binary for this operation, resolved on the search path
at call time: it lets one definition span sibling binaries (``urpmq`` querying
while ``urpmi`` installs). ``sudo = true`` marks the operation as privileged,
mirroring the ``sudo=True`` flag built-in managers pass to ``run_cli``: escalation
then follows the per-manager policy (the definition's ``default_sudo``, overridden
by the user's ``--sudo``/``--no-sudo``).
"""


[docs] @dataclass(frozen=True) class OperationSpec: """Declarative specification of one operation of a config-defined manager.""" args: tuple[str, ...] """CLI arguments appended after the resolved binary, before :py:attr:`~meta_package_manager.execution.CLIExecutor.post_args`. May embed the ``{package_id}`` and ``{query}`` placeholders, substituted at call time. ``{version}`` is intentionally unsupported: config-defined managers do not pin versions (see :func:`_make_install`). """ cli: str | None = None """Alternate binary name for this operation, or ``None`` for the manager's main :py:attr:`~meta_package_manager.execution.CLIExecutor.cli_path`. Resolved with :py:meth:`~meta_package_manager.manager.PackageManager.which` at call time, so one definition can span sibling binaries (``urpmi``/``urpme``/ ``urpmq``, ``cast``/``dispel``/``gaze``). The operation fails with :py:exc:`FileNotFoundError` when the binary is missing rather than silently falling back to the main CLI. """ sudo: bool = False """Mark the operation as privileged, mirroring the ``sudo=True`` flag built-in managers pass to :py:meth:`~meta_package_manager.execution.CLIExecutor.run_cli`. Escalation still follows the per-manager policy: the definition's ``default_sudo``, overridden by the user's ``--sudo``/``--no-sudo``. Command operations are the usual bearers; a query may also set it, for the rare tool that gates its read-only listings behind root (``deb-get``). """ exact_args: tuple[str, ...] | None = None """Arguments spliced at the ``{exact_args}`` marker of a ``search``'s ``args`` when an exact match is requested, or ``None`` when the tool has no native exact mode. See :data:`SEARCH_REFINEMENT_KEYS`.""" extended_args: tuple[str, ...] | None = None """Arguments spliced at the ``{extended_args}`` marker of a ``search``'s ``args`` when the extended (description-reaching) mode is requested, or ``None`` when the tool has no native switch for it. See :data:`SEARCH_REFINEMENT_KEYS`.""" id_name_only_args: tuple[str, ...] | None = None """Arguments spliced at the ``{id_name_only_args}`` marker of a ``search``'s ``args`` when the default ID/name-restricted mode is requested, for tools whose unrestricted search is the default (Chocolatey's ``--by-id-only``), or ``None``. See :data:`SEARCH_REFINEMENT_KEYS`.""" parse_mode: str = "none" """How to turn the command's stdout into packages: ``"regex"`` (per-line named groups), ``"json"`` (structured extraction), or ``"none"`` for command-only operations that produce no inventory (install, remove, sync, ...).""" regex: str | None = None """Regular expression matched against each stdout line in ``"regex"`` mode. Recognized named groups: ``package_id`` (required), ``installed_version`` and ``latest_version`` (optional). Compiled with :py:data:`re.MULTILINE`. """ list_path: str | None = None """Dotted path to the package array inside the JSON document in ``"json"`` mode. ``None`` or empty means the document is itself the array. """ fields: dict[str, str] | None = None """Mapping of recognized package field (``package_id``, ``installed_version``, ``latest_version``) to its JSON selector, in ``"json"`` mode. A selector is a key name with an optional ``[N]`` list index (``versions[0]``); see :data:`JSON_FIELD_SELECTOR_REGEX`. """
[docs] @dataclass(frozen=True) class ManagerDefinition: """A brand-new package manager declared from a ``[mpm.managers.<id>]`` section. Produced by :py:func:`parse_manager_definition` after validation, consumed by :func:`build_manager_class`. """ manager_id: str """Manager ID, taken from the configuration section name.""" name: str """Human-readable manager name.""" platforms: tuple[str, ...] """Platform and group ID strings, resolved to :py:class:`extra_platforms.Platform` members at build time.""" homepage_url: str | None """Project home page, for documentation reference only.""" cli_fields: dict[str, object] """Overridable CLI-execution attributes (``cli_names``, ``requirement``, ``version_regexes``, ...), pre-coerced to their runtime types.""" operations: dict[str, OperationSpec] """Declared operations keyed by name (``installed``, ``install``, ...)."""
[docs] class ConfigDrivenManager(PackageManager): """Base class for managers synthesized from configuration. Carries no operation methods on purpose: only the dynamically-created subclass returned by :func:`build_manager_class` defines the operations the user actually declared, so :py:func:`meta_package_manager.capabilities.implements` reports an accurate capability set. Defining an operation here would make *every* config-defined manager falsely advertise it. Exists mainly as a marker (``isinstance(manager, ConfigDrivenManager)`` distinguishes user-defined managers from built-ins) and as a shared home for any future config-driven behavior. """ definition_source: str | None = None """Repo-relative path to the bundled TOML file this manager was defined in. Set by :py:func:`build_bundled_managers` for the managers mpm ships as package data; stays ``None`` for a manager defined in a user's own configuration file. The documentation generator links a bundled manager's benchmark entry to this file, a config-defined manager having no Python source line to point at. """
[docs] def parse_manager_definition( manager_id: str, section: Any, ) -> ManagerDefinition: """Validate and parse one ``[mpm.managers.<id>]`` definition section. Returns a :py:class:`ManagerDefinition` ready for :py:func:`build_manager_class`. Raises :class:`click_extra.ValidationError` (path relative to the ``[mpm.managers]`` root) on any problem, so the same function backs both ``--validate-config`` and the runtime registration path. """ if not isinstance(section, dict): raise ValidationError( manager_id, f"expected a table, got {type(section).__name__}", code="invalid_type", ) if not re.fullmatch(r"[a-z][a-z0-9-]*", manager_id): raise ValidationError( manager_id, f"invalid manager ID {manager_id!r}: use lowercase letters, digits and " "dashes (e.g. 'my-tool').", code="invalid_id", ) for required in ("platforms", "operations"): if required not in section: raise ValidationError( f"{manager_id}.{required}", f"a config-defined manager must declare {required!r}.", code="missing_field", ) try: platforms = _to_str_tuple(section["platforms"]) except TypeError as ex: raise ValidationError( f"{manager_id}.platforms", str(ex), code="invalid_type" ) from ex if not platforms: raise ValidationError( f"{manager_id}.platforms", "must list at least one platform or group.", code="invalid_value", ) for token in platforms: if token.lower() not in VALID_PLATFORM_TOKENS: raise ValidationError( f"{manager_id}.platforms", f"unknown platform or group {token!r}.", code="unknown_platform", ) name = manager_id if "name" in section: try: name = _to_str(section["name"]) except TypeError as ex: raise ValidationError( f"{manager_id}.name", str(ex), code="invalid_type" ) from ex homepage_url = None if "homepage_url" in section: try: homepage_url = _to_str(section["homepage_url"]) except TypeError as ex: raise ValidationError( f"{manager_id}.homepage_url", str(ex), code="invalid_type" ) from ex cli_fields: dict[str, Any] = {} for key, value in section.items(): if key in DEFINITION_IDENTITY_FIELDS: continue converter = DEFINITION_CLI_FIELDS.get(key) if converter is None: allowed = sorted(set(DEFINITION_CLI_FIELDS) | DEFINITION_IDENTITY_FIELDS) raise ValidationError( f"{manager_id}.{key}", f"unknown field. Allowed: {', '.join(allowed)}.", code="unknown_field", ) try: cli_fields[key] = converter(value) except TypeError as ex: raise ValidationError( f"{manager_id}.{key}", str(ex), code="invalid_type" ) from ex operations = _parse_operations(manager_id, section["operations"]) return ManagerDefinition( manager_id=manager_id, name=name, platforms=platforms, homepage_url=homepage_url, cli_fields=cli_fields, operations=operations, )
def _parse_operations( manager_id: str, raw: Any, ) -> dict[str, OperationSpec]: """Validate and parse the ``[mpm.managers.<id>.operations]`` sub-table.""" if not isinstance(raw, dict) or not raw: raise ValidationError( f"{manager_id}.operations", "must be a non-empty table of operations.", code="invalid_type", ) operations = {} for op_name, op_section in raw.items(): if op_name not in ALL_DEFINITION_OPERATIONS: raise ValidationError( f"{manager_id}.operations.{op_name}", f"unknown operation. Allowed: " f"{', '.join(sorted(ALL_DEFINITION_OPERATIONS))}.", code="unknown_operation", ) operations[op_name] = _parse_operation_spec(manager_id, op_name, op_section) return operations def _parse_operation_spec( manager_id: str, op_name: str, raw: Any, ) -> OperationSpec: """Validate and parse one operation table into an :class:`OperationSpec`.""" path = f"{manager_id}.operations.{op_name}" if not isinstance(raw, dict): raise ValidationError( path, f"expected a table, got {type(raw).__name__}", code="invalid_type" ) is_query = op_name in QUERY_OPERATIONS if op_name == "search": allowed_keys = SEARCH_OPERATION_KEYS elif is_query: allowed_keys = QUERY_OPERATION_KEYS else: allowed_keys = COMMAND_OPERATION_KEYS for key in raw: if key not in allowed_keys: raise ValidationError( f"{path}.{key}", f"unknown key. Allowed: {', '.join(sorted(allowed_keys))}.", code="unknown_field", ) if "args" not in raw: raise ValidationError(f"{path}.args", "required.", code="missing_field") try: args = _to_str_tuple(raw["args"]) except TypeError as ex: raise ValidationError(f"{path}.args", str(ex), code="invalid_type") from ex if not args: raise ValidationError( f"{path}.args", "must be a non-empty list.", code="invalid_value" ) found_placeholders = { token for arg in args for token in ARG_PLACEHOLDER_REGEX.findall(arg) } allowed_placeholders = ALLOWED_ARG_PLACEHOLDERS.get(op_name, frozenset()) unknown_placeholders = found_placeholders - allowed_placeholders if unknown_placeholders: listing = ", ".join("{" + p + "}" for p in sorted(unknown_placeholders)) if allowed_placeholders: hint = "Allowed: " + ", ".join( "{" + p + "}" for p in sorted(allowed_placeholders) ) else: hint = f"{op_name} args take no placeholder" raise ValidationError( f"{path}.args", f"unknown placeholder(s): {listing}. {hint}.", code="invalid_value", ) placeholder = OPERATION_ARG_PLACEHOLDER.get(op_name) if placeholder and placeholder not in found_placeholders: raise ValidationError( f"{path}.args", f"{op_name} args must reference the {{{placeholder}}} placeholder.", code="invalid_value", ) cli = None if "cli" in raw: try: cli = _to_str(raw["cli"]) except TypeError as ex: raise ValidationError(f"{path}.cli", str(ex), code="invalid_type") from ex if not cli: raise ValidationError( f"{path}.cli", "must be a non-empty string.", code="invalid_value" ) sudo = False if "sudo" in raw: try: sudo = _to_bool(raw["sudo"]) except TypeError as ex: raise ValidationError(f"{path}.sudo", str(ex), code="invalid_type") from ex common = _parse_search_refinements(path, args, raw, found_placeholders) common.update(cli=cli, sudo=sudo) if is_query: return _parse_query_spec(path, op_name, args, raw, common) return OperationSpec(args=args, **common) def _parse_search_refinements( path: str, args: tuple[str, ...], raw: dict[str, Any], found_placeholders: set[str], ) -> dict[str, Any]: """Validate and parse the per-refinement argument templates of a ``search``. Returns the :data:`SEARCH_REFINEMENT_KEYS` entries of the :class:`OperationSpec` constructor. Only ``search`` can carry them (the key filter upstream rejects them elsewhere), so for every other operation this collapses to three ``None`` values. Each declared refinement must pair with its ``{marker}`` in ``args`` β€” a marker with no argument list would leak into the CLI as a literal token, a list with no marker would never render β€” and the marker must stand as a whole argument, since a list cannot expand inside a larger string. """ refinements: dict[str, Any] = {} for refinement in sorted(SEARCH_REFINEMENT_KEYS): marker = "{" + refinement + "}" for arg in args: if marker in arg and arg != marker: raise ValidationError( f"{path}.args", f"{marker} must be a standalone argument, not embedded in {arg!r}.", code="invalid_value", ) if refinement not in raw: refinements[refinement] = None if refinement in found_placeholders: raise ValidationError( f"{path}.args", f"references {marker} but declares no {refinement!r} list.", code="invalid_value", ) continue try: values = _to_str_tuple(raw[refinement]) except TypeError as ex: raise ValidationError( f"{path}.{refinement}", str(ex), code="invalid_type" ) from ex for value in values: if ARG_PLACEHOLDER_REGEX.search(value): raise ValidationError( f"{path}.{refinement}", f"{value!r} may not carry a placeholder.", code="invalid_value", ) if refinement not in found_placeholders: raise ValidationError( f"{path}.args", f"declares {refinement!r} but the args have no {marker} marker.", code="invalid_value", ) refinements[refinement] = values return refinements def _parse_query_spec( path: str, op_name: str, args: tuple[str, ...], raw: dict[str, Any], common: dict[str, Any], ) -> OperationSpec: """Validate the parser half of a query operation (``regex`` or JSON ``fields``). ``common`` carries the constructor entries already validated upstream (the alternate ``cli``, the ``sudo`` marker, the search refinements). """ has_regex = "regex" in raw has_json = raw.get("format") == "json" or "fields" in raw if has_regex and has_json: raise ValidationError( path, "use either 'regex' or JSON 'fields', not both.", code="invalid_value" ) if not has_regex and not has_json: raise ValidationError( path, "a query operation needs a 'regex' or a JSON 'fields' parser.", code="missing_field", ) required = REQUIRED_PARSE_FIELDS[op_name] if has_regex: try: regex = _to_str(raw["regex"]) except TypeError as ex: raise ValidationError(f"{path}.regex", str(ex), code="invalid_type") from ex try: compiled = re.compile(regex) except re.error as ex: raise ValidationError( f"{path}.regex", f"invalid regular expression: {ex}.", code="invalid_value", ) from ex _check_parse_fields(f"{path}.regex", set(compiled.groupindex), required) return OperationSpec(args=args, parse_mode="regex", regex=regex, **common) if "fields" not in raw: raise ValidationError( f"{path}.fields", "JSON parsing requires a 'fields' mapping.", code="missing_field", ) try: fields = _to_str_dict(raw["fields"]) except TypeError as ex: raise ValidationError(f"{path}.fields", str(ex), code="invalid_type") from ex _check_parse_fields(f"{path}.fields", set(fields), required) for role, selector in fields.items(): if not JSON_FIELD_SELECTOR_REGEX.match(selector): raise ValidationError( f"{path}.fields", f"invalid selector {selector!r} for {role}: use a key name with " "an optional [N] list index, like 'version' or 'versions[0]'.", code="invalid_value", ) list_path = None if "list_path" in raw: try: list_path = _to_str(raw["list_path"]) except TypeError as ex: raise ValidationError( f"{path}.list_path", str(ex), code="invalid_type" ) from ex return OperationSpec( args=args, parse_mode="json", list_path=list_path, fields=fields, **common ) def _check_parse_fields(path: str, present: set[str], required: frozenset[str]) -> None: """Reject unrecognized parse fields and require the mandatory ones.""" unknown = present - RECOGNIZED_PARSE_FIELDS if unknown: raise ValidationError( path, f"unrecognized field(s): {', '.join(sorted(unknown))}. " f"Allowed: {', '.join(sorted(RECOGNIZED_PARSE_FIELDS))}.", code="invalid_value", ) missing = required - present if missing: raise ValidationError( path, f"missing required field(s): {', '.join(sorted(missing))}.", code="invalid_value", ) def _render_args(args: tuple[str, ...], **substitutions: str) -> list[str]: """Substitute ``{token}`` placeholders in each CLI argument. Only the explicitly-passed tokens are replaced; an argument with no placeholder passes through untouched. Substitution is textual on already-split arguments, never a shell expansion, so an injected value stays a single argv element. """ rendered = [] for arg in args: for token, value in substitutions.items(): arg = arg.replace("{" + token + "}", value) rendered.append(arg) return rendered def _navigate_json(data: object, list_path: str | None) -> list: """Walk ``list_path`` into a parsed JSON document and return the package array. Returns an empty list when the path does not resolve to a list, so a malformed or unexpected payload yields no packages rather than raising. """ if list_path: for key in list_path.split("."): if not isinstance(data, dict): return [] data = data.get(key) return data if isinstance(data, list) else [] def _json_field(item: dict, selector: str) -> Any: """Resolve a field ``selector`` against one JSON package ``item``. A bare key returns the key's value; a ``key[N]`` selector picks the ``N``-th element out of a list-valued key (see :data:`JSON_FIELD_SELECTOR_REGEX`, which validated the syntax at parse time). Anything that does not resolve β€” missing key, non-list value under an indexed selector, out-of-range index β€” returns ``None``, so an unexpected payload yields incomplete packages rather than raising. """ match = JSON_FIELD_SELECTOR_REGEX.match(selector) assert match is not None, f"unvalidated selector {selector!r}" value = item.get(match.group("key")) index = match.group("index") if index is None: return value if not isinstance(value, list): return None position = int(index) return value[position] if position < len(value) else None def _iter_parsed( manager: PackageManager, output: str, spec: OperationSpec, compiled: re.Pattern[str] | None, ) -> Iterator[dict[str, str]]: """Yield ``Package`` keyword dicts extracted from a query command's ``output``. Honors :py:attr:`OperationSpec.parse_mode`: per-line named-group matching for ``"regex"``, key lookups under :py:attr:`OperationSpec.list_path` for ``"json"`` (parsed through the shared :py:meth:`~meta_package_manager.manager.PackageManager.parse_json`, so a malformed payload warns and yields nothing, exactly like the built-in managers). Skips entries with no ``package_id`` and version values that are absent or null. """ if spec.parse_mode == "regex": assert compiled is not None for line in output.splitlines(): match = compiled.search(line) if not match: continue groups = match.groupdict() package_id = groups.get("package_id") if not package_id: continue kwargs = {"id": package_id} for role in ("installed_version", "latest_version"): if groups.get(role): kwargs[role] = groups[role] yield kwargs elif spec.parse_mode == "json": assert spec.fields is not None data = manager.parse_json(output) if data is None: return for item in _navigate_json(data, spec.list_path): if not isinstance(item, dict): continue raw_id = _json_field(item, spec.fields["package_id"]) if not raw_id: continue kwargs = {"id": str(raw_id)} for role in ("installed_version", "latest_version"): selector = spec.fields.get(role) if selector is not None: value = _json_field(item, selector) if value is not None: kwargs[role] = str(value) yield kwargs def _op_cli_path(manager: PackageManager, spec: OperationSpec) -> Path | None: """Resolve the operation's alternate binary, or ``None`` for the main CLI. Thin adapter over :py:meth:`~meta_package_manager.execution.CLIExecutor.sibling_cli`, which raises on a declared-but-missing binary: falling back to the main CLI would run the operation's arguments against the wrong program. """ if not spec.cli: return None return manager.sibling_cli(spec.cli) def _make_query_property( spec: OperationSpec, compiled: re.Pattern[str] | None ) -> property: """Build an ``installed``/``outdated`` property that runs the CLI and parses it.""" def query(self: PackageManager) -> Iterator[Package]: output = self.run_cli( *spec.args, override_cli_path=_op_cli_path(self, spec), sudo=spec.sudo, ) for kwargs in _iter_parsed(self, output, spec, compiled): yield self.package(**kwargs) return property(query) def _expand_search_args( spec: OperationSpec, query: str, extended: bool, exact: bool ) -> list[str]: """Render a ``search``'s ``args`` template for one query. Each :data:`SEARCH_REFINEMENT_KEYS` marker expands, in place, to its argument list when the matching refinement is active and to nothing otherwise (``id_name_only_args`` being active when ``extended`` is *not* requested). The ``{query}`` placeholder is substituted last. """ active: dict[str, tuple[str, ...] | None] = { "exact_args": spec.exact_args if exact else (), "extended_args": spec.extended_args if extended else (), "id_name_only_args": spec.id_name_only_args if not extended else (), } expanded: list[str] = [] for arg in spec.args: token = arg[1:-1] if arg.startswith("{") and arg.endswith("}") else None if token in active: expanded.extend(active[token] or ()) else: expanded.append(arg) return _render_args(tuple(expanded), query=query) def _make_search( spec: OperationSpec, compiled: re.Pattern[str] | None ) -> Callable[..., Iterator[Package]]: """Build a ``search`` method. The per-refinement argument templates (see :data:`SEARCH_REFINEMENT_KEYS`) render the tool's native exact/extended switches when declared; :py:meth:`~meta_package_manager.manager.PackageManager.refiltered_search` refilters the raw results either way, exactly as for the built-in managers. """ def search( self: PackageManager, query: str, extended: bool, exact: bool ) -> Iterator[Package]: output = self.run_cli( *_expand_search_args(spec, query, extended, exact), override_cli_path=_op_cli_path(self, spec), sudo=spec.sudo, ) for kwargs in _iter_parsed(self, output, spec, compiled): yield self.package(**kwargs) # Same introspection surface as the search_capabilities decorator on class # managers: a declared refinement template advertises native support, its # absence signals reliance on mpm's refiltering. search.extended_support = ( # type: ignore[attr-defined] spec.extended_args is not None or spec.id_name_only_args is not None ) search.exact_support = spec.exact_args is not None # type: ignore[attr-defined] return search def _warn_version_unsupported(version: str | None) -> None: """Log the standard warning when a version pin reaches a config-defined manager.""" if version: logging.warning( "Configuration-defined managers do not support version pinning. " "Letting the package manager choose the version.", ) def _make_install(spec: OperationSpec) -> Callable[..., str]: """Build an ``install`` method substituting ``{package_id}`` into the args.""" def install( self: PackageManager, package_id: str, version: str | None = None ) -> str: _warn_version_unsupported(version) return self.run_cli( *_render_args(spec.args, package_id=package_id), override_cli_path=_op_cli_path(self, spec), sudo=spec.sudo, ) return install def _make_remove(spec: OperationSpec) -> Callable[..., str]: """Build a ``remove`` method substituting ``{package_id}`` into the args.""" def remove(self: PackageManager, package_id: str) -> str: return self.run_cli( *_render_args(spec.args, package_id=package_id), override_cli_path=_op_cli_path(self, spec), sudo=spec.sudo, ) return remove def _make_void(spec: OperationSpec) -> Callable[..., None]: """Build a ``sync``/``cleanup`` method that runs the CLI and discards its output.""" def operation(self: PackageManager) -> None: self.run_cli( *spec.args, override_cli_path=_op_cli_path(self, spec), sudo=spec.sudo, ) return operation def _make_upgrade_one_cli(spec: OperationSpec) -> Callable[..., tuple[str, ...]]: """Build an ``upgrade_one_cli`` returning the per-package upgrade command line.""" def upgrade_one_cli( self: PackageManager, package_id: str, version: str | None = None ) -> tuple[str, ...]: _warn_version_unsupported(version) return self.build_cli( *_render_args(spec.args, package_id=package_id), override_cli_path=_op_cli_path(self, spec), sudo=spec.sudo, ) return upgrade_one_cli def _make_upgrade_all_cli(spec: OperationSpec) -> Callable[..., tuple[str, ...]]: """Build an ``upgrade_all_cli`` returning the upgrade-everything command line.""" def upgrade_all_cli(self: PackageManager) -> tuple[str, ...]: return self.build_cli( *spec.args, override_cli_path=_op_cli_path(self, spec), sudo=spec.sudo, ) return upgrade_all_cli
[docs] def build_manager_class(definition: ManagerDefinition) -> type[ConfigDrivenManager]: """Synthesize a :py:class:`~meta_package_manager.manager.PackageManager` subclass from a validated definition. Assembles a class namespace from the definition's identity and CLI fields, then adds one method (or property) per declared operation. Only the declared operations land in the namespace, so :py:func:`meta_package_manager.capabilities.implements` reflects exactly what the user configured. Single- and all-package upgrades map to :py:meth:`~meta_package_manager.manager.PackageManager.upgrade_one_cli` / :py:meth:`~meta_package_manager.manager.PackageManager.upgrade_all_cli` so the inherited :py:meth:`~meta_package_manager.manager.PackageManager.upgrade` orchestrator drives them, just like the built-in managers. """ namespace: dict[str, object] = { "id": definition.manager_id, "name": definition.name, "homepage_url": definition.homepage_url, "platforms": traits_from_ids(*definition.platforms), "__module__": __name__, "__doc__": ( f"Package manager {definition.manager_id!r} defined from configuration." ), } namespace.update(definition.cli_fields) for op_name, spec in definition.operations.items(): compiled = None if spec.parse_mode == "regex": assert spec.regex is not None compiled = re.compile(spec.regex, re.MULTILINE) if op_name == "installed": namespace["installed"] = _make_query_property(spec, compiled) elif op_name == "outdated": namespace["outdated"] = _make_query_property(spec, compiled) elif op_name == "search": namespace["search"] = _make_search(spec, compiled) elif op_name == "install": namespace["install"] = _make_install(spec) elif op_name == "remove": namespace["remove"] = _make_remove(spec) elif op_name in ("sync", "cleanup"): namespace[op_name] = _make_void(spec) elif op_name == "upgrade_one": namespace["upgrade_one_cli"] = _make_upgrade_one_cli(spec) elif op_name == "upgrade_all": namespace["upgrade_all_cli"] = _make_upgrade_all_cli(spec) class_name = "Config_" + definition.manager_id.replace("-", "_") return cast( "type[ConfigDrivenManager]", MetaPackageManager(class_name, (ConfigDrivenManager,), namespace), )
# Bundled manager definitions. # # mpm ships a few managers as data rather than Python classes: a TOML file per manager # under meta_package_manager/managers/, each a single [mpm.managers.<id>] section in the # exact schema a user would write. They are parsed and built through the same # parse_manager_definition / build_manager_class path as any user definition, then loaded # into the pool at construction time (ManagerPool.register), so they are always available # and earn first-class --<id> flags. The trust gate guarding user definitions (see # meta_package_manager.config) does not apply: package data shipped in the wheel is # read-only and as trusted as the Python modules beside it. # # Each bundled file also carries a top-level [samples] table: source-derived output # fixtures locking the version probe and the declared parsers, the config-defined twin # of the shell-session samples embedded in class docstrings. The loader below only # consumes the "mpm" key, so samples never reach the runtime; they feed the hermetic # test_bundled_version_regex and test_bundled_parsing suites, which glob the shipped # files and derive their parametrizes from them. BUNDLED_DEFINITIONS_PACKAGE: Final[str] = "meta_package_manager.managers" """Import package whose ``*.toml`` resources hold mpm's bundled manager definitions."""
[docs] @cache def load_bundled_definitions() -> tuple[tuple[ManagerDefinition, str], ...]: """Parse every bundled ``[mpm.managers.<id>]`` definition shipped as package data. Reads each ``*.toml`` resource of :data:`BUNDLED_DEFINITIONS_PACKAGE` via :py:mod:`importlib.resources` (so it works the same from an unpacked install, a zip or a Nuitka onefile), and validates every section with :py:func:`parse_manager_definition`. Returns ``(definition, source)`` pairs, where ``source`` is the repo-relative path used to link the manager's documentation. Cached because the shipped files never change at runtime. A malformed bundled file is a packaging bug, but it is logged and skipped rather than raised so one bad resource cannot break ``mpm`` startup for everyone. The hermetic ``test_bundled_inventory`` and ``test_bundled_registered`` keep the shipped files valid. """ definitions: list[tuple[ManagerDefinition, str]] = [] resources = importlib.resources.files(BUNDLED_DEFINITIONS_PACKAGE) for resource in sorted(resources.iterdir(), key=lambda item: item.name): if not resource.name.endswith(".toml"): continue source = f"meta_package_manager/managers/{resource.name}" try: data = tomllib.loads(resource.read_text(encoding="UTF-8")) except (OSError, tomllib.TOMLDecodeError) as ex: logging.warning(f"Skipping unreadable bundled definition {source}: {ex}") continue sections = data.get("mpm", {}).get("managers", {}) for manager_id, section in sections.items(): try: definitions.append( (parse_manager_definition(manager_id, section), source), ) except ValidationError as ex: logging.warning( f"Skipping invalid bundled manager {manager_id!r} in {source}: {ex}", ) return tuple(definitions)
[docs] def bundled_manager_ids() -> frozenset[str]: """IDs of the managers mpm ships as bundled configuration definitions.""" return frozenset( definition.manager_id for definition, _ in load_bundled_definitions() )
[docs] def build_bundled_managers() -> list[PackageManager]: """Instantiate every bundled definition into a live, pool-ready manager. Each :py:class:`ConfigDrivenManager` subclass records the TOML file it came from in :py:attr:`ConfigDrivenManager.definition_source`, so the documentation generator can link to it. Called once by :py:attr:`meta_package_manager.pool.ManagerPool.register`. """ managers: list[PackageManager] = [] for definition, source in load_bundled_definitions(): klass = build_manager_class(definition) klass.definition_source = source managers.append(klass()) return managers