platypush/platypush/utils/manifest.py

336 lines
10 KiB
Python

from dataclasses import dataclass, field
import enum
import importlib
import inspect
import json
import logging
import os
import pathlib
import shutil
import sys
from typing import (
Dict,
Generator,
List,
Optional,
Iterable,
Mapping,
Set,
Type,
Union,
)
import yaml
from platypush.message.event import Event
supported_package_managers = {
'apk': ['apk', 'add', '--no-cache', '--no-progress'],
'apt': ['apt', 'install', '-y', '-q'],
'pacman': ['pacman', '-S', '--noconfirm', '--noprogressbar'],
}
_available_package_manager = None
logger = logging.getLogger(__name__)
class ManifestType(enum.Enum):
"""
Manifest types.
"""
PLUGIN = 'plugin'
BACKEND = 'backend'
@dataclass
class Dependencies:
"""
Dependencies for a plugin/backend.
"""
before: List[str] = field(default_factory=list)
""" Commands to execute before the component is installed. """
packages: Set[str] = field(default_factory=set)
""" System packages required by the component. """
pip: Set[str] = field(default_factory=set)
""" pip dependencies. """
after: List[str] = field(default_factory=list)
""" Commands to execute after the component is installed. """
@classmethod
def from_config(
cls, conf_file: Optional[str] = None, pkg_manager: Optional[str] = None
) -> "Dependencies":
"""
Parse the required dependencies from a configuration file.
"""
deps = cls()
for manifest in Manifests.by_config(conf_file, pkg_manager=pkg_manager):
deps.before += manifest.install.before
deps.pip.update(manifest.install.pip)
deps.packages.update(manifest.install.packages)
deps.after += manifest.install.after
return deps
def to_pkg_install_commands(
self, pkg_manager: Optional[str] = None, skip_sudo: bool = False
) -> Generator[str, None, None]:
"""
Generates the package manager commands required to install the given
dependencies on the system.
:param pkg_manager: Force package manager to use (default: looks for
the one available on the system).
:param skip_sudo: Skip sudo when installing packages (default: it will
look if the current user is root and use sudo otherwise).
"""
wants_sudo = not skip_sudo and os.getuid() != 0
pkg_manager = pkg_manager or get_available_package_manager()
if self.packages and pkg_manager:
yield ' '.join(
[
*(['sudo'] if wants_sudo else []),
*supported_package_managers[pkg_manager],
*sorted(self.packages),
]
)
def to_pip_install_commands(self) -> Generator[str, None, None]:
"""
Generates the pip commands required to install the given dependencies on
the system.
"""
# Recent versions want an explicit --break-system-packages option when
# installing packages via pip outside of a virtual environment
wants_break_system_packages = (
sys.version_info > (3, 10)
and sys.prefix == sys.base_prefix # We're not in a venv
)
if self.pip:
yield (
'pip install -U --no-input --no-cache-dir '
+ ('--break-system-packages ' if wants_break_system_packages else '')
+ ' '.join(sorted(self.pip))
)
def to_install_commands(
self, pkg_manager: Optional[str] = None, skip_sudo: bool = False
) -> Generator[str, None, None]:
"""
Generates the commands required to install the given dependencies on
this system.
:param pkg_manager: Force package manager to use (default: looks for
the one available on the system).
:param skip_sudo: Skip sudo when installing packages (default: it will
look if the current user is root and use sudo otherwise).
"""
for cmd in self.before:
yield cmd
for cmd in self.to_pkg_install_commands(
pkg_manager=pkg_manager, skip_sudo=skip_sudo
):
yield cmd
for cmd in self.to_pip_install_commands():
yield cmd
for cmd in self.after:
yield cmd
class Manifest:
"""
Base class for plugin/backend manifests.
"""
def __init__(
self,
package: str,
description: Optional[str] = None,
install: Optional[Dict[str, Iterable[str]]] = None,
events: Optional[Mapping] = None,
pkg_manager: Optional[str] = None,
**_,
):
self._pkg_manager = pkg_manager or get_available_package_manager()
self.description = description
self.install = self._init_deps(install or {})
self.events = self._init_events(events or {})
self.package = package
self.component_name = '.'.join(package.split('.')[2:])
self.component = None
def _init_deps(self, install: Mapping[str, Iterable[str]]) -> Dependencies:
deps = Dependencies()
for key, items in install.items():
if key == 'pip':
deps.pip.update(items)
elif key == 'before':
deps.before += items
elif key == 'after':
deps.after += items
elif key == self._pkg_manager:
deps.packages.update(items)
return deps
@staticmethod
def _init_events(
events: Union[Iterable[str], Mapping[str, Optional[str]]]
) -> Dict[Type[Event], str]:
evt_dict = events if isinstance(events, Mapping) else {e: None for e in events}
ret = {}
for evt_name, doc in evt_dict.items():
evt_module_name, evt_class_name = evt_name.rsplit('.', 1)
try:
evt_module = importlib.import_module(evt_module_name)
evt_class = getattr(evt_module, evt_class_name)
except Exception as e:
raise AssertionError(f'Could not load event {evt_name}: {e}') from e
ret[evt_class] = doc or evt_class.__doc__
return ret
@classmethod
def from_file(cls, filename: str, pkg_manager: Optional[str] = None) -> "Manifest":
"""
Parse a manifest filename into a ``Manifest`` class.
"""
with open(str(filename), 'r') as f:
manifest = yaml.safe_load(f).get('manifest', {})
assert 'type' in manifest, f'Manifest file {filename} has no type field'
comp_type = ManifestType(manifest.pop('type'))
manifest_class = _manifest_class_by_type[comp_type]
return manifest_class(**manifest, pkg_manager=pkg_manager)
def __repr__(self):
"""
:return: A JSON serialized representation of the manifest.
"""
return json.dumps(
{
'description': self.description,
'install': self.install,
'events': {
'.'.join([evt_type.__module__, evt_type.__name__]): doc
for evt_type, doc in self.events.items()
},
'type': _manifest_type_by_class[self.__class__].value,
'package': self.package,
'component_name': self.component_name,
}
)
# pylint: disable=too-few-public-methods
class PluginManifest(Manifest):
"""
Plugin manifest.
"""
# pylint: disable=too-few-public-methods
class BackendManifest(Manifest):
"""
Backend manifest.
"""
class Manifests:
"""
General-purpose manifests utilities.
"""
@staticmethod
def by_base_class(
base_class: Type, pkg_manager: Optional[str] = None
) -> Generator[Manifest, None, None]:
"""
Get all the manifest files declared under the base path of a given class
and parse them into :class:`Manifest` objects.
"""
for mf in pathlib.Path(os.path.dirname(inspect.getfile(base_class))).rglob(
'manifest.yaml'
):
yield Manifest.from_file(str(mf), pkg_manager=pkg_manager)
@staticmethod
def by_config(
conf_file: Optional[str] = None,
pkg_manager: Optional[str] = None,
) -> Generator[Manifest, None, None]:
"""
Get all the manifest objects associated to the extensions declared in a
given configuration file.
"""
import platypush
from platypush.config import Config
conf_args = []
if conf_file:
conf_args.append(conf_file)
Config.init(*conf_args)
app_dir = os.path.dirname(inspect.getfile(platypush))
for name in Config.get_backends().keys():
yield Manifest.from_file(
os.path.join(app_dir, 'backend', *name.split('.'), 'manifest.yaml'),
pkg_manager=pkg_manager,
)
for name in Config.get_plugins().keys():
yield Manifest.from_file(
os.path.join(app_dir, 'plugins', *name.split('.'), 'manifest.yaml'),
pkg_manager=pkg_manager,
)
def get_available_package_manager() -> Optional[str]:
"""
Get the name of the available package manager on the system, if supported.
"""
# pylint: disable=global-statement
global _available_package_manager
if _available_package_manager:
return _available_package_manager
available_package_managers = [
pkg_manager
for pkg_manager in supported_package_managers
if shutil.which(pkg_manager)
]
if not available_package_managers:
logger.warning(
'\nYour OS does not provide any of the supported package managers.\n'
'You may have to install some optional dependencies manually.\n'
'Supported package managers: %s.\n',
', '.join(supported_package_managers.keys()),
)
return None
_available_package_manager = available_package_managers[0]
return _available_package_manager
_manifest_class_by_type: Mapping[ManifestType, Type[Manifest]] = {
ManifestType.PLUGIN: PluginManifest,
ManifestType.BACKEND: BackendManifest,
}
_manifest_type_by_class: Mapping[Type[Manifest], ManifestType] = {
cls: t for t, cls in _manifest_class_by_type.items()
}