platypush/platypush/common/reflection/_model/integration.py

333 lines
10 KiB
Python

import contextlib
import inspect
import os
import re
import textwrap as tw
from dataclasses import dataclass, field
from typing import Type, Optional, Dict, Set
from platypush.utils import (
get_backend_class_by_name,
get_plugin_class_by_name,
get_plugin_name_by_class,
get_backend_name_by_class,
get_decorators,
)
from platypush.utils.manifest import Manifest, ManifestType, Dependencies
from .._serialize import Serializable
from . import Constructor, Action
from .component import Component
from .constants import doc_base_url
@dataclass
class Integration(Component, Serializable):
"""
Represents the metadata of an integration (plugin or backend).
"""
_class_type_re = re.compile(r"^<class '(?P<name>[\w_]+)'>$")
name: str
type: Type
doc: Optional[str] = None
constructor: Optional[Constructor] = None
actions: Dict[str, Action] = field(default_factory=dict)
_manifest: Optional[Manifest] = None
_skip_manifest: bool = False
def __post_init__(self):
"""
Initialize the manifest object.
"""
if not self._skip_manifest:
self._init_manifest()
def to_dict(self) -> dict:
return {
"name": self.name,
"type": f"{self.type.__module__}.{self.type.__qualname__}",
"doc": self.doc,
"doc_url": self.doc_url,
"args": {
**(
{name: arg.to_dict() for name, arg in self.constructor.args.items()}
if self.constructor
else {}
),
},
"actions": {
k: {
"doc_url": f"{self.doc_url}#{self.cls.__module__}.{self.cls.__qualname__}.{k}",
**v.to_dict(),
}
for k, v in self.actions.items()
if self.cls
},
"events": {
f"{e.__module__}.{e.__qualname__}": {
"doc": inspect.getdoc(e),
"doc_url": f"{doc_base_url}/events/"
+ ".".join(e.__module__.split(".")[3:])
+ f".html#{e.__module__}.{e.__qualname__}",
}
for e in self.events
},
"deps": self.deps.to_dict(),
}
@classmethod
def _merge_actions(cls, actions: Dict[str, Action], new_actions: Dict[str, Action]):
"""
Utility function to merge a new mapping of actions into an existing one.
"""
for action_name, action in new_actions.items():
# Set the action if it doesn't exist
if action_name not in actions:
actions[action_name] = action
# Set the action documentation if it's not set
if action.doc and not actions[action_name].doc:
actions[action_name].doc = action.doc
# Merge the parameters
cls._merge_params(actions[action_name].args, action.args)
@classmethod
def _merge_events(cls, events: Set[Type], new_events: Set[Type]):
"""
Utility function to merge a new mapping of actions into an existing one.
"""
events.update(new_events)
@classmethod
def by_name(cls, name: str) -> "Integration":
"""
:param name: Integration name.
:return: A parsed Integration class given its type.
"""
type = (
get_backend_class_by_name(".".join(name.split(".")[1:]))
if name.startswith("backend.")
else get_plugin_class_by_name(name)
)
return cls.by_type(type)
@classmethod
def by_type(cls, type: Type, _skip_manifest: bool = False) -> "Integration":
"""
:param type: Integration type (plugin or backend).
:param _skip_manifest: Whether we should skip parsing the manifest file for this integration
(you SHOULDN'T use this flag outside of this class!).
:return: A parsed Integration class given its type.
"""
from platypush.backend import Backend
from platypush.plugins import Plugin
assert issubclass(
type, (Plugin, Backend)
), f"Expected a Plugin or Backend class, got {type}"
name = (
get_plugin_name_by_class(type)
if issubclass(type, Plugin)
else "backend." + get_backend_name_by_class(type)
)
assert name
obj = cls(
name=name,
type=type,
doc=inspect.getdoc(type),
constructor=Constructor.parse(type),
actions={
name: Action.parse(getattr(type, name))
for name in get_decorators(type, climb_class_hierarchy=True).get(
"action", []
)
},
_skip_manifest=_skip_manifest,
)
for p_type in inspect.getmro(type)[1:]:
with contextlib.suppress(AssertionError):
p_obj = cls.by_type(p_type, _skip_manifest=True)
# Merge constructor parameters
if obj.constructor and p_obj.constructor:
cls._merge_params(obj.constructor.args, p_obj.constructor.args)
# Merge actions
cls._merge_actions(obj.actions, p_obj.actions)
# Merge events
try:
cls._merge_events(obj.events, p_obj.events)
except FileNotFoundError:
pass
return obj
@property
def cls(self) -> Optional[Type]:
"""
:return: The class of an integration.
"""
manifest_type = self.manifest.package.split(".")[1]
if manifest_type == "backend":
getter = get_backend_class_by_name
elif manifest_type == "plugins":
getter = get_plugin_class_by_name
else:
return None
return getter(".".join(self.manifest.package.split(".")[2:]))
@property
def base_type(self) -> Type:
"""
:return: The base type of this integration, either :class:`platypush.backend.Backend` or
:class:`platypush.plugins.Plugin`.
"""
from platypush.backend import Backend
from platypush.plugins import Plugin
assert self.cls, f'No class found for integration {self.name}'
if issubclass(self.cls, Plugin):
return Plugin
if issubclass(self.cls, Backend):
return Backend
raise AssertionError(f"Unknown base type for {self.cls}")
@classmethod
def from_manifest(cls, manifest_file: str) -> "Integration":
"""
Create an `IntegrationMetadata` object from a manifest file.
:param manifest_file: Path of the manifest file.
:return: A parsed Integration class given its manifest file.
"""
manifest = Manifest.from_file(manifest_file)
name = ".".join(
[
"backend" if manifest.manifest_type == ManifestType.BACKEND else "",
*manifest.package.split(".")[2:],
]
).strip(".")
return cls.by_name(name)
def _init_manifest(self) -> Manifest:
"""
Initialize the manifest object.
"""
if not self._manifest:
self._manifest = Manifest.from_file(self.manifest_file)
return self._manifest
@classmethod
def _type_str(cls, param_type) -> str:
"""
Utility method to pretty-print the type string of a parameter.
"""
type_str = str(param_type).replace("typing.", "")
if m := cls._class_type_re.match(type_str):
return m.group("name")
return type_str
@property
def manifest(self) -> Manifest:
"""
:return: The parsed Manifest object.
"""
return self._init_manifest()
@property
def manifest_file(self) -> str:
"""
:return: Path of the manifest file for the integration.
"""
return os.path.join(
os.path.dirname(inspect.getfile(self.type)), "manifest.yaml"
)
@property
def description(self) -> Optional[str]:
"""
:return: The description of the integration.
"""
return self.manifest.description
@property
def events(self) -> Set[Type]:
"""
:return: Events triggered by the integration.
"""
return set(self.manifest.events)
@property
def deps(self) -> Dependencies:
"""
:return: Dependencies of the integration.
"""
return self.manifest.install
@classmethod
def _indent_yaml_comment(cls, s: str) -> str:
return tw.indent(
"\n".join(
[
line if line.startswith("#") else f"# {line}"
for line in s.split("\n")
]
),
" ",
)
@property
def config_snippet(self) -> str:
"""
:return: A YAML snippet with the configuration parameters of the integration.
"""
return tw.dedent(
self.name
+ ":\n"
+ (
"\n".join(
f' # [{"Required" if param.required else "Optional"}]\n'
+ (f"{self._indent_yaml_comment(param.doc)}" if param.doc else "")
+ "\n "
+ ("# " if not param.required else "")
+ f"{name}: "
+ (str(param.default) if param.default is not None else "")
+ (
self._indent_yaml_comment(f"type={self._type_str(param.type)}")
if param.type
else ""
)
+ "\n"
for name, param in self.constructor.args.items()
)
if self.constructor and self.constructor.args
else " # No configuration required\n"
)
)
@property
def doc_url(self) -> str:
"""
:return: URL of the documentation for the integration.
"""
from platypush.backend import Backend
from platypush.plugins import Plugin
if issubclass(self.type, Plugin):
section = 'plugins'
elif issubclass(self.type, Backend):
section = 'backend'
else:
raise AssertionError(f'Unknown integration type {self.type}')
return f"{doc_base_url}/{section}/{self.name}.html"