# 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 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 refiltered_search(
self,
query: str,
extended: bool,
exact: bool,
) -> Iterator[Package]:
"""Returns search results with extra manual refiltering to refine gross
matchings.
Some package managers returns unbounded results, and/or don't support fine
search criterions. In which case we use this method to manually refilters
:py:meth:`meta_package_manager.manager.PackageManager.search` results to either
exclude non-extended or non-exact matches.
Returns a generator producing the same data as the
:py:meth:`meta_package_manager.manager.PackageManager.search` method above.
.. tip::
If you are implementing a package manager definition, do not waste time to
filter CLI results. Let this method do this job.
Instead, just implement the core
:py:meth:`meta_package_manager.manager.PackageManager.search` method above and
try to produce results as precise as possible using the native filtering
capabilities of the package manager CLI.
"""
# Pre-compute normalized query parts once for all results.
normalized_query_parts = {p.lower() for p in self.query_parts(query)}
for match in self.search(query, extended, exact):
# Look by default into package ID and name.
search_content = {match.id, match.name}
# Rejects fuzzy results: only keep packages strictly matching on ID or name.
if exact and query not in search_content:
continue
# Add description to the list of content to look into.
if extended:
search_content.add(match.description)
# Normalize searched content.
serialized_content = "".join(s.lower() for s in search_content if s)
# Exclude packages not matching any part of the query.
if not any(part in serialized_content for part in normalized_query_parts):
continue
# Report the package as matching.
yield match
[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