Source code for meta_package_manager.manager

# 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.
"""Abstract base class tying together every package manager definition.

Defines :py:class:`meta_package_manager.manager.PackageManager`, the class each concrete
manager in :py:mod:`meta_package_manager.managers` inherits from, together with its
:py:class:`meta_package_manager.manager.MetaPackageManager` metaclass and the
:py:class:`meta_package_manager.manager.ManagerScope` classification.

A subclass declares its identity (supported platforms, version requirement, deprecation
status) and implements the operations it supports (``installed``, ``outdated``,
``install``, ``upgrade``, ...). The CLI-execution engine it inherits lives in
:py:mod:`meta_package_manager.execution`, the operation vocabulary in
:py:mod:`meta_package_manager.capabilities`, and the package objects operations yield in
:py:mod:`meta_package_manager.package`. On top of the engine, this module adds the
availability policy: whether the manager is supported, fresh, and ready to use.
"""

from __future__ import annotations

import logging
import platform
import re
from enum import Enum
from functools import cached_property
from pathlib import Path
from typing import ClassVar, cast

from click_extra.theme import get_current_theme as theme
from extra_platforms import (
    Group,
    Platform,
    extract_members,
)

from .execution import CLIExecutor, highlight_cli_name
from .package import Package
from .version import VersionRange

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Iterable, Iterator


[docs] class ManagerScope(Enum): """Filesystem scope a package manager operates within.""" SYSTEM = "system" """Manages software installed globally, machine-wide. All currently-maintained managers are system-scoped. """ PROJECT = "project" """Manages dependencies confined to a project's working tree. Not supported yet. See :py:meth:`meta_package_manager.manager.PackageManager.discover_projects`. """
[docs] class MetaPackageManager(type): """Custom metaclass used as a class factory for package managers.""" def __init__(cls, name, bases, dct) -> None: """Sets some class defaults, but only if they're not redefined in the final manager class. Also normalize list of platform, by ungrouping groups, deduplicate entries and freeze them into a set of unique platforms. """ if "id" not in dct: cls.id = name.lower().replace("_", "-") if "name" not in dct: cls.name = name if "cli_names" not in dct: cls.cli_names = (cls.id,) if "virtual" not in dct: cls.virtual = name == "PackageManager" or not cls.cli_names if "platforms" in dct: cls.platforms = frozenset(extract_members(dct["platforms"])) assert all(isinstance(p, Platform) for p in cls.platforms), ( f"Manager {cls} has invalid entries in its platforms list." )
[docs] class PackageManager(CLIExecutor, metaclass=MetaPackageManager): """Base class from which all package manager definitions inherits.""" scope: ClassVar[ManagerScope] = ManagerScope.SYSTEM """Whether the manager operates on globally-installed software or project-local dependencies. Defaults to :py:attr:`ManagerScope.SYSTEM`, which covers every manager maintained today: they install and query software machine-wide. Project-scoped managers (Poetry, Bundler, Maven, ...) resolve dependencies confined to a working tree and are not supported yet. """ deprecated: bool = False """A manager marked as deprecated will be hidden from all package selection by default. You can still use it but need to explicitly call for it on the command line. Implementation of a deprecated manager will be kept within mpm source code, but some of its features or total implementation are allowed to be scraped in the face of maintenance pain and adversity. Integration tests and unittests for deprecated managers can be removed. We do not care if a deprecated manager is not 100% reliable. A flakky deprecated manager should not block a release due to flakky tests. """ deprecation_url: str | None = None """Announcement from the official project or evidence of abandonment of maintenance.""" id: str """Package manager's ID. Derived by defaults from the lower-cased class name in which underscores ``_`` are replaced by dashes ``-``. This ID must be unique among all package manager definitions and lower-case, as they're used as feature flags for the :program:`mpm` CLI. """ name: str """Return package manager's common name. Default value is based on class name. """ homepage_url: str | None = None """Home page of the project, only used in documentation for reference.""" platforms: frozenset[Platform] | Group | Platform | Iterable[Platform | Group] = ( frozenset() ) """List of platforms supported by the manager. Allows for a mishmash of platforms and groups of platforms. Will be normalized into a `frozenset` of ``Platform`` instances at instantiation. """ requirement: str | None = None """Version requirement specifier. Supports a comma-separated range of constraints (e.g. ``">=1.20.0,<2.0.0"``). A bare version string like ``"1.20.0"`` is treated as ``>=1.20.0``. Parsed by :py:class:`meta_package_manager.version.VersionRange`. Defaults to ``None``, which deactivates version check entirely. """ virtual: bool """Should we expose the package manager to the user? Virtual package manager are just skeleton classes used to factorize code among managers of the same family. """ ignore_auto_updates: bool = True """Some managers can report or ignore packages which have their own auto-update mechanism."""
[docs] def package(self, **kwargs) -> Package: """Instantiate a ``Package`` object from the manager. Sets its ``manage_id`` to the manager it belongs to. """ kwargs.setdefault("manager_id", self.id) return Package(**kwargs)
@cached_property def supported(self) -> bool: """Is the package manager supported on that platform?""" # After metaclass initialization, platforms is always a frozenset[Platform]. platforms = cast("frozenset[Platform]", self.platforms) return any(p.current for p in platforms) @cached_property def fresh(self) -> bool: """Does the package manager match the version requirement?""" # Version is mandatory. if not self.version: return False if self.requirement and self.version not in VersionRange(self.requirement): logging.debug( f"{self.id} {self.version} does not satisfy " f"{self.requirement!r} version requirement.", ) return False return True @cached_property def available(self) -> bool: """Is the package manager available and ready-to-use on the system? Returns ``True`` only if the main CLI: 1. is :py:attr:`supported on the current platform <meta_package_manager.manager.PackageManager.supported>`, 2. was :py:attr:`found on the system <meta_package_manager.manager.PackageManager.cli_path>`, 3. is :py:attr:`executable <meta_package_manager.manager.PackageManager.executable>`, and 4. :py:attr:`match the version requirement <meta_package_manager.manager.PackageManager.fresh>`. """ logging.debug( f"{theme().invoked_command(self.id)} is: " f"deprecated? {self.deprecated}; " f"supported? {self.supported}; " f"found at: {highlight_cli_name(self.cli_path, self.cli_names)}; " f"executable? {self.executable}; " f"fresh? {self.fresh}.", ) return bool(self.supported and self.cli_path and self.executable and self.fresh) @property def unavailable_reason(self) -> str | None: """Short, human-readable explanation of why :py:attr:`available` is ``False``, or ``None`` if the manager is available. Returned in priority order so the most actionable cause is reported first: platform support, then CLI lookup, then executable bit, then version requirement. """ if self.supported is False: return f"not supported on {platform.system()!r}" if not self.cli_path: cli_names = ", ".join(self.cli_names) or self.id return f"no executable named {cli_names!r} found in PATH" if not self.executable: return f"{self.cli_path!r} is not executable" if not self.fresh: if not self.version: return f"could not parse version from {self.cli_path!r} output" return ( f"version {self.version} does not satisfy " f"{self.requirement!r} requirement" ) return None @property def installed(self) -> Iterator[Package]: """List packages currently installed on the system. Optional. Will be simply skipped by :program:`mpm` if not implemented. """ raise NotImplementedError @cached_property def installed_ids(self) -> frozenset[str]: """Installed package IDs, materialized once from :py:meth:`installed`.""" return frozenset(pkg.id for pkg in self.installed) @property def outdated(self) -> Iterator[Package]: """List installed packages with available upgrades. Optional. Will be simply skipped by :program:`mpm` if not implemented. """ raise NotImplementedError @property def refiltered_outdated(self) -> Iterator[Package]: """Wraps :py:meth:`outdated` with a version-equality filter. Some package managers report packages as outdated when the version strings differ at the character level but are numerically equal after parsing (e.g., Perl floating-point versions ``2.0000`` vs ``2.000000``). This filter drops those false positives. """ for pkg in self.outdated: if ( pkg.installed_version is None or pkg.latest_version is None or pkg.installed_version != pkg.latest_version ): yield pkg
[docs] @classmethod def query_parts(cls, query: str) -> set[str]: """Returns a set of all contiguous alphanumeric string segments. Contrary to :py:class:`meta_package_manager.version.TokenizedString`, do no splits on colated number/alphabetic junctions. """ return {p for p in re.split(r"\W+", query) if p}
[docs] def search(self, query: str, extended: bool, exact: bool) -> Iterator[Package]: """Search packages available for install. There is no need for this method to be perfect and sensitive to ``extended`` and ``exact`` parameters. If the package manager is not supporting these kind of options out of the box, just returns the closest subset of matching package you can come up with. Finer refiltering will happens in the :py:meth:`meta_package_manager.manager.PackageManager.refiltered_search` method below. Optional. Will be simply skipped by :program:`mpm` if not implemented. """ raise NotImplementedError
[docs] def install(self, package_id: str, version: str | None = None) -> str: """Install one package and one only. Allows a specific ``version`` to be provided. """ raise NotImplementedError
[docs] def upgrade_all_cli(self) -> tuple[str, ...]: """Returns the complete CLI to upgrade all outdated packages on the system.""" raise NotImplementedError
[docs] def upgrade_one_cli( self, package_id: str, version: str | None = None, ) -> tuple[str, ...]: """Returns the complete CLI to upgrade one package and one only. Allows a specific ``version`` to be provided. """ raise NotImplementedError
[docs] def upgrade(self, package_id: str | None = None, version: str | None = None) -> str: """Perform an upgrade of either all or one package. Executes the CLI provided by either :py:meth:`meta_package_manager.manager.PackageManager.upgrade_all_cli` or :py:meth:`meta_package_manager.manager.PackageManager.upgrade_one_cli`. If the manager doesn't provides a full upgrade one-liner (i.e. if :py:meth:`meta_package_manager.manager.PackageManager.upgrade_all_cli` raises :py:exc:`NotImplementedError`), then the list of all outdated packages will be fetched (via :py:meth:`meta_package_manager.manager.PackageManager.outdated`) and each package will be updated one by one by calling :py:meth:`meta_package_manager.manager.PackageManager.upgrade_one_cli`. See for example the case of :py:meth:`meta_package_manager.managers.pip.Pip.upgrade_one_cli`. """ if package_id: cli = self.upgrade_one_cli(package_id, version=version) else: try: cli = self.upgrade_all_cli() except NotImplementedError: logging.info( "upgrade_all_cli operation not implemented. " "Call single upgrade operation on each package, one-by-one.", ) logs = [] for package in self.refiltered_outdated: output = self.upgrade(package.id) if output: logs.append(output) return "\n".join(logs) return self.run(cli, extra_env=self.extra_env)
[docs] def remove(self, package_id: str) -> str: """Remove one package and one only. Optional. Will be simply skipped by :program:`mpm` if not implemented. """ raise NotImplementedError
[docs] def sync(self) -> None: """Refresh package metadata from remote repositories. Optional. Will be simply skipped by :program:`mpm` if not implemented. """ raise NotImplementedError
[docs] def cleanup(self) -> None: """Prune left-overs, remove orphaned dependencies and clear caches. Optional. Will be simply skipped by :program:`mpm` if not implemented. """ raise NotImplementedError
[docs] def discover_projects(self) -> Iterator[Path]: """Locate project trees this manager governs by scanning the filesystem. Extension point reserved for :py:attr:`ManagerScope.PROJECT` managers: detecting virtual environments, lockfiles, or project manifests scattered across the filesystem. .. caution:: Not implemented for any manager yet. System-scoped managers (the default) own no project trees to discover. """ raise NotImplementedError