Compare commits

..

12 Commits

Author SHA1 Message Date
Fabio Manganiello 1e93af86f4
Fixed some broken docstring references.
continuous-integration/drone/push Build is passing Details
2023-10-09 01:33:45 +02:00
Fabio Manganiello 53bdcb9604
A major rewrite of the `inspect` plugin.
- The `inspect` plugin and the Sphinx inspection extensions now use the
  same underlying logic.

- Moved all the common inspection logic under
  `platypush.common.reflection`.

- Faster scanning of the available integrations and components through a
  pool of threads.

- Added `doc_url` parameters.

- Migrated events and responses metadata scanning logic.

- Now expanding some custom Sphinx tag instead of returning errors when
  running outside of the Sphinx context - it includes `:class:`,
  `:meth:` and `.. schema::`.
2023-10-09 01:33:45 +02:00
Fabio Manganiello 9acd71944c
Skip numpy types serialization errors on Message.Encoder. 2023-10-09 01:33:45 +02:00
Fabio Manganiello e5a5ac5ffb
Added `doc_url` parameter to integration metadata. 2023-10-09 01:33:45 +02:00
Fabio Manganiello d872835093
New API to check if a table class exists before defining it.
- Check if it's part of the metadata through a function call rather than
  checking `Base.metadata` in every single module.

- Make it possible to override them (mostly for doc generation logic
  that needs to be able to import those classes).

- Make it possible to extend them.
2023-10-09 01:33:44 +02:00
Fabio Manganiello 608844ca0c
[WIP] Large refactor of the inspection plugin and models. 2023-10-09 01:33:44 +02:00
Fabio Manganiello 841643f3ff
Added `cachedir` to configuration. 2023-10-09 01:33:44 +02:00
Fabio Manganiello 40557f5d5d
Replaced one more occurrence of `<type> | None` syntax. 2023-10-09 01:33:44 +02:00
Fabio Manganiello 4da3c13976
First WIP commit for the new Integrations panel. 2023-10-09 01:33:44 +02:00
Fabio Manganiello 4d52fd35b9
Skip `None` responses in the RSS plugin.
continuous-integration/drone/push Build is passing Details
2023-10-04 22:08:11 +02:00
Fabio Manganiello 40d3ad1150
Removed `<type> | None` type hints.
continuous-integration/drone/push Build is passing Details
They break on Python < 3.10.
2023-10-03 01:15:13 +02:00
Fabio Manganiello fd7037d048
Added git checkout and rebase commands to sync-stable-branch step.
continuous-integration/drone/push Build is passing Details
2023-10-01 23:31:46 +02:00
88 changed files with 1728 additions and 1412 deletions

View File

@ -84,8 +84,11 @@ steps:
- git remote rm origin - git remote rm origin
- git remote add origin git@git.platypush.tech:platypush/platypush.git - git remote add origin git@git.platypush.tech:platypush/platypush.git
# Push to the `stable` branch # Merge and push to the `stable` branch
- git checkout stable
- git rebase master
- git push -u origin stable - git push -u origin stable
- git checkout master
# Restore the original git configuration # Restore the original git configuration
- mv /tmp/git.config.orig $GIT_CONF - mv /tmp/git.config.orig $GIT_CONF

View File

@ -3,7 +3,6 @@ import os
import re import re
import sys import sys
import textwrap as tw import textwrap as tw
from contextlib import contextmanager
from sphinx.application import Sphinx from sphinx.application import Sphinx
@ -13,14 +12,15 @@ base_path = os.path.abspath(
sys.path.insert(0, base_path) sys.path.insert(0, base_path)
from platypush.utils import get_plugin_name_by_class # noqa from platypush.common.reflection import Integration # noqa
from platypush.utils.mock import mock # noqa from platypush.utils import get_plugin_name_by_class, import_file # noqa
from platypush.utils.reflection import IntegrationMetadata, import_file # noqa from platypush.utils.mock import auto_mocks # noqa
from platypush.utils.mock.modules import mock_imports # noqa
class IntegrationEnricher: class IntegrationEnricher:
@staticmethod @staticmethod
def add_events(source: list[str], manifest: IntegrationMetadata, idx: int) -> int: def add_events(source: list[str], manifest: Integration, idx: int) -> int:
if not manifest.events: if not manifest.events:
return idx return idx
@ -37,7 +37,7 @@ class IntegrationEnricher:
return idx + 1 return idx + 1
@staticmethod @staticmethod
def add_actions(source: list[str], manifest: IntegrationMetadata, idx: int) -> int: def add_actions(source: list[str], manifest: Integration, idx: int) -> int:
if not (manifest.actions and manifest.cls): if not (manifest.actions and manifest.cls):
return idx return idx
@ -60,7 +60,7 @@ class IntegrationEnricher:
@classmethod @classmethod
def add_install_deps( def add_install_deps(
cls, source: list[str], manifest: IntegrationMetadata, idx: int cls, source: list[str], manifest: Integration, idx: int
) -> int: ) -> int:
deps = manifest.deps deps = manifest.deps
parsed_deps = { parsed_deps = {
@ -106,9 +106,7 @@ class IntegrationEnricher:
return idx return idx
@classmethod @classmethod
def add_description( def add_description(cls, source: list[str], manifest: Integration, idx: int) -> int:
cls, source: list[str], manifest: IntegrationMetadata, idx: int
) -> int:
docs = ( docs = (
doc doc
for doc in ( for doc in (
@ -127,7 +125,7 @@ class IntegrationEnricher:
@classmethod @classmethod
def add_conf_snippet( def add_conf_snippet(
cls, source: list[str], manifest: IntegrationMetadata, idx: int cls, source: list[str], manifest: Integration, idx: int
) -> int: ) -> int:
source.insert( source.insert(
idx, idx,
@ -163,8 +161,8 @@ class IntegrationEnricher:
if not os.path.isfile(manifest_file): if not os.path.isfile(manifest_file):
return return
with mock_imports(): with auto_mocks():
manifest = IntegrationMetadata.from_manifest(manifest_file) manifest = Integration.from_manifest(manifest_file)
idx = self.add_description(src, manifest, idx=3) idx = self.add_description(src, manifest, idx=3)
idx = self.add_conf_snippet(src, manifest, idx=idx) idx = self.add_conf_snippet(src, manifest, idx=idx)
idx = self.add_install_deps(src, manifest, idx=idx) idx = self.add_install_deps(src, manifest, idx=idx)
@ -175,14 +173,6 @@ class IntegrationEnricher:
source[0] = '\n'.join(src) source[0] = '\n'.join(src)
@contextmanager
def mock_imports():
conf_mod = import_file(os.path.join(base_path, 'docs', 'source', 'conf.py'))
mock_mods = getattr(conf_mod, 'autodoc_mock_imports', [])
with mock(*mock_mods):
yield
def setup(app: Sphinx): def setup(app: Sphinx):
app.connect('source-read', IntegrationEnricher()) app.connect('source-read', IntegrationEnricher())
return { return {

View File

@ -163,9 +163,9 @@ latex_documents = [
man_pages = [(master_doc, 'platypush', 'platypush Documentation', [author], 1)] man_pages = [(master_doc, 'platypush', 'platypush Documentation', [author], 1)]
# -- Options for Texinfo output ---------------------------------------------- # -- Options for TexInfo output ----------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples # Grouping the document tree into TexInfo files. List of tuples
# (source start file, target name, title, author, # (source start file, target name, title, author,
# dir menu entry, description, category) # dir menu entry, description, category)
texinfo_documents = [ texinfo_documents = [
@ -193,126 +193,25 @@ autodoc_default_options = {
'show-inheritance': True, 'show-inheritance': True,
} }
autodoc_mock_imports = [
'gunicorn',
'googlesamples.assistant.grpc.audio_helpers',
'google.assistant.embedded',
'google.assistant.library',
'google.assistant.library.event',
'google.assistant.library.file_helpers',
'google.oauth2.credentials',
'oauth2client',
'apiclient',
'tenacity',
'smartcard',
'Leap',
'oauth2client',
'rtmidi',
'bluetooth',
'gevent.wsgi',
'Adafruit_IO',
'pyclip',
'pydbus',
'inputs',
'inotify',
'omxplayer',
'plexapi',
'cwiid',
'sounddevice',
'soundfile',
'numpy',
'cv2',
'nfc',
'ndef',
'bcrypt',
'google',
'feedparser',
'kafka',
'googlesamples',
'icalendar',
'httplib2',
'mpd',
'serial',
'pyHS100',
'grpc',
'envirophat',
'gps',
'picamera',
'pmw3901',
'PIL',
'croniter',
'pyaudio',
'avs',
'PyOBEX',
'PyOBEX.client',
'todoist',
'trello',
'telegram',
'telegram.ext',
'pyfirmata2',
'cups',
'graphyte',
'cpuinfo',
'psutil',
'openzwave',
'deepspeech',
'wave',
'pvporcupine ',
'pvcheetah',
'pyotp',
'linode_api4',
'pyzbar',
'tensorflow',
'keras',
'pandas',
'samsungtvws',
'paramiko',
'luma',
'zeroconf',
'dbus',
'gi',
'gi.repository',
'twilio',
'Adafruit_Python_DHT',
'RPi.GPIO',
'RPLCD',
'imapclient',
'pysmartthings',
'aiohttp',
'watchdog',
'pyngrok',
'irc',
'irc.bot',
'irc.strings',
'irc.client',
'irc.connection',
'irc.events',
'defusedxml',
'nio',
'aiofiles',
'aiofiles.os',
'async_lru',
'bleak',
'bluetooth_numbers',
'TheengsDecoder',
'simple_websocket',
'uvicorn',
'websockets',
'docutils',
'aioxmpp',
]
sys.path.insert(0, os.path.abspath('../..')) sys.path.insert(0, os.path.abspath('../..'))
from platypush.utils.mock.modules import mock_imports # noqa
def skip(app, what, name, obj, skip, options): autodoc_mock_imports = [*mock_imports]
# _ = app
# __ = what
# ___ = obj
# ____ = options
def _skip(_, __, name, ___, skip, ____):
if name == "__init__": if name == "__init__":
return False return False
return skip return skip
def setup(app): def setup(app):
app.connect("autodoc-skip-member", skip) app.connect("autodoc-skip-member", _skip)
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View File

@ -1,18 +1,18 @@
import importlib
import inspect
import os import os
import sys
from typing import Iterable, Optional from typing import Iterable, Optional
import pkgutil
from platypush.backend import Backend from platypush.backend import Backend
from platypush.context import get_plugin from platypush.message.event import Event
from platypush.message.response import Response
from platypush.plugins import Plugin from platypush.plugins import Plugin
from platypush.utils.manifest import Manifests from platypush.utils.manifest import Manifests
def _get_inspect_plugin():
p = get_plugin('inspect')
assert p, 'Could not load the `inspect` plugin'
return p
def get_all_plugins(): def get_all_plugins():
return sorted([mf.component_name for mf in Manifests.by_base_class(Plugin)]) return sorted([mf.component_name for mf in Manifests.by_base_class(Plugin)])
@ -22,11 +22,35 @@ def get_all_backends():
def get_all_events(): def get_all_events():
return _get_inspect_plugin().get_all_events().output return _get_modules(Event)
def get_all_responses(): def get_all_responses():
return _get_inspect_plugin().get_all_responses().output return _get_modules(Response)
def _get_modules(base_type: type):
ret = set()
base_dir = os.path.dirname(inspect.getfile(base_type))
package = base_type.__module__
for _, mod_name, _ in pkgutil.walk_packages([base_dir], prefix=package + '.'):
try:
module = importlib.import_module(mod_name)
except Exception:
print('Could not import module', mod_name, file=sys.stderr)
continue
for _, obj_type in inspect.getmembers(module):
if (
inspect.isclass(obj_type)
and issubclass(obj_type, base_type)
# Exclude the base_type itself
and obj_type != base_type
):
ret.add(obj_type.__module__.replace(package + '.', '', 1))
return list(ret)
def _generate_components_doc( def _generate_components_doc(
@ -122,7 +146,7 @@ def generate_events_doc():
_generate_components_doc( _generate_components_doc(
index_name='events', index_name='events',
package_name='message.event', package_name='message.event',
components=sorted(event for event in get_all_events().keys() if event), components=sorted(event for event in get_all_events() if event),
) )
@ -130,9 +154,7 @@ def generate_responses_doc():
_generate_components_doc( _generate_components_doc(
index_name='responses', index_name='responses',
package_name='message.response', package_name='message.response',
components=sorted( components=sorted(response for response in get_all_responses() if response),
response for response in get_all_responses().keys() if response
),
) )

View File

@ -42,6 +42,7 @@ class Application:
config_file: Optional[str] = None, config_file: Optional[str] = None,
workdir: Optional[str] = None, workdir: Optional[str] = None,
logsdir: Optional[str] = None, logsdir: Optional[str] = None,
cachedir: Optional[str] = None,
device_id: Optional[str] = None, device_id: Optional[str] = None,
pidfile: Optional[str] = None, pidfile: Optional[str] = None,
requests_to_process: Optional[int] = None, requests_to_process: Optional[int] = None,
@ -62,6 +63,8 @@ class Application:
``filename`` setting under the ``logging`` section of the ``filename`` setting under the ``logging`` section of the
configuration file is used. If not set, logging will be sent to configuration file is used. If not set, logging will be sent to
stdout and stderr. stdout and stderr.
:param cachedir: Overrides the ``cachedir`` setting in the configuration
file (default: None).
:param device_id: Override the device ID used to identify this :param device_id: Override the device ID used to identify this
instance. If not passed here, it is inferred from the configuration instance. If not passed here, it is inferred from the configuration
(device_id field). If not present there either, it is inferred from (device_id field). If not present there either, it is inferred from
@ -106,6 +109,9 @@ class Application:
self.config_file, self.config_file,
device_id=device_id, device_id=device_id,
workdir=os.path.abspath(os.path.expanduser(workdir)) if workdir else None, workdir=os.path.abspath(os.path.expanduser(workdir)) if workdir else None,
cachedir=os.path.abspath(os.path.expanduser(cachedir))
if cachedir
else None,
ctrl_sock=os.path.abspath(os.path.expanduser(ctrl_sock)) ctrl_sock=os.path.abspath(os.path.expanduser(ctrl_sock))
if ctrl_sock if ctrl_sock
else None, else None,
@ -206,6 +212,7 @@ class Application:
return cls( return cls(
config_file=opts.config, config_file=opts.config,
workdir=opts.workdir, workdir=opts.workdir,
cachedir=opts.cachedir,
logsdir=opts.logsdir, logsdir=opts.logsdir,
device_id=opts.device_id, device_id=opts.device_id,
pidfile=opts.pidfile, pidfile=opts.pidfile,

View File

@ -34,7 +34,8 @@ class GoogleFitBackend(Backend):
""" """
:param data_sources: Google Fit data source IDs to monitor. You can :param data_sources: Google Fit data source IDs to monitor. You can
get a list of the available data sources through the get a list of the available data sources through the
:meth:`platypush.plugins.google.fit.get_data_sources` action :meth:`platypush.plugins.google.fit.GoogleFitPlugin.get_data_sources`
action
:type data_sources: list[str] :type data_sources: list[str]
:param user_id: Google user ID to track (default: 'me') :param user_id: Google user ID to track (default: 'me')

View File

@ -23,7 +23,7 @@ class MidiBackend(Backend):
""" """
:param device_name: Name of the MIDI device. *N.B.* either :param device_name: Name of the MIDI device. *N.B.* either
`device_name` or `port_number` must be set. `device_name` or `port_number` must be set.
Use :meth:`platypush.plugins.midi.query_ports` to get the Use :meth:`platypush.plugins.midi.MidiPlugin.query_ports` to get the
available ports indices and names available ports indices and names
:type device_name: str :type device_name: str

View File

@ -32,6 +32,14 @@ def parse_cmdline(args: Sequence[str]) -> argparse.Namespace:
help='Custom working directory to be used for the application', help='Custom working directory to be used for the application',
) )
parser.add_argument(
'--cachedir',
dest='cachedir',
required=False,
default=None,
help='Custom cache directory',
)
parser.add_argument( parser.add_argument(
'--device-id', '--device-id',
'-d', '-d',

View File

@ -1,3 +1,6 @@
from contextlib import contextmanager
from dataclasses import dataclass
from sqlalchemy import __version__ from sqlalchemy import __version__
sa_version = tuple(map(int, __version__.split('.'))) sa_version = tuple(map(int, __version__.split('.')))
@ -8,3 +11,38 @@ else:
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base() Base = declarative_base()
@dataclass
class DbContext:
"""
Context flags for the database session.
"""
override_definitions: bool = False
_ctx = DbContext()
@contextmanager
def override_definitions():
"""
Temporarily override the definitions of the entities in the entities
registry.
This is useful when the entities are being imported off-context, like
e.g. in the `inspect` or `alembic` modules.
"""
_ctx.override_definitions = True
yield
_ctx.override_definitions = False
def is_defined(table_name: str) -> bool:
"""
Check if the given entity class is defined in the entities registry.
:param table_name: Name of the table associated to the entity class.
"""
return not _ctx.override_definitions and table_name in Base.metadata

View File

@ -0,0 +1,7 @@
from ._model import Integration, Message
__all__ = [
"Integration",
"Message",
]

View File

@ -0,0 +1,16 @@
from .action import Action
from .argument import Argument
from .constructor import Constructor
from .integration import Integration
from .message import Message
from .returns import ReturnValue
__all__ = [
"Action",
"Argument",
"Constructor",
"Integration",
"Message",
"ReturnValue",
]

View File

@ -0,0 +1,7 @@
from .._parser import DocstringParser
class Action(DocstringParser):
"""
Represents an integration action.
"""

View File

@ -0,0 +1,27 @@
from dataclasses import dataclass
from typing import Optional, Type
from .._serialize import Serializable
from .._utils import type_str
@dataclass
class Argument(Serializable):
"""
Represents an integration constructor/action parameter.
"""
name: str
required: bool = False
doc: Optional[str] = None
type: Optional[Type] = None
default: Optional[str] = None
def to_dict(self) -> dict:
return {
"name": self.name,
"required": self.required,
"doc": self.doc,
"type": type_str(self.type),
"default": self.default,
}

View File

@ -0,0 +1,68 @@
from abc import ABC, abstractmethod
from typing import Dict, Type
from .argument import Argument
class Component(ABC):
"""
Abstract interface for all the application components exposed through the
`inspect` plugin.
It includes integrations (plugins and backends) and messages (events and
responses).
"""
@staticmethod
def _merge_params(params: Dict[str, Argument], new_params: Dict[str, Argument]):
"""
Utility function to merge a new mapping of parameters into an existing one.
"""
for param_name, param in new_params.items():
# Set the parameter if it doesn't exist
if param_name not in params:
params[param_name] = param
# Set the parameter documentation if it's not set
if param.doc and not params[param_name].doc:
params[param_name].doc = param.doc
# If the new parameter has required=False,
# then that should also be the value for the current ones
if param.required is False:
params[param_name].required = False
# If the new parameter has a default value, and the current
# one doesn't, then the default value should be set as the new one.
if param.default is not None and params[param_name].default is None:
params[param_name].default = param.default
@classmethod
@abstractmethod
def by_name(cls, name: str) -> "Component":
"""
:param name: Component type name.
:return: A parsed component class given its name/type name.
"""
@classmethod
@abstractmethod
def by_type(cls, type: Type) -> "Component":
"""
:param type: Component type.
:return: A parsed component class given its type.
"""
@property
@abstractmethod
def cls(self) -> Type:
"""
:return: The class of a component.
"""
@property
@abstractmethod
def doc_url(self) -> str:
"""
:return: The URL of the documentation of the component.
"""

View File

@ -0,0 +1 @@
doc_base_url = 'https://docs.platypush.tech/platypush'

View File

@ -0,0 +1,23 @@
from typing import Union, Type, Callable
from .._parser import DocstringParser
class Constructor(DocstringParser):
"""
Represents an integration constructor.
"""
@classmethod
def parse(cls, obj: Union[Type, Callable]) -> "Constructor":
"""
Parse the parameters of a class constructor or action method.
:param obj: Base type of the object.
:return: The parsed parameters.
"""
init = getattr(obj, "__init__", None)
if init and callable(init):
return super().parse(init)
return super().parse(obj)

View File

@ -4,49 +4,25 @@ import os
import re import re
import textwrap as tw import textwrap as tw
from dataclasses import dataclass, field from dataclasses import dataclass, field
from importlib.machinery import SourceFileLoader from typing import Type, Optional, Dict, Set
from importlib.util import spec_from_loader, module_from_spec
from typing import Optional, Type, Union, Callable, Dict, Set
from platypush.utils import ( from platypush.utils import (
get_backend_class_by_name, get_backend_class_by_name,
get_backend_name_by_class,
get_plugin_class_by_name, get_plugin_class_by_name,
get_plugin_name_by_class, get_plugin_name_by_class,
get_backend_name_by_class,
get_decorators, get_decorators,
) )
from platypush.utils.manifest import Manifest, ManifestType, Dependencies from platypush.utils.manifest import Manifest, ManifestType, Dependencies
from platypush.utils.reflection._parser import DocstringParser, Parameter
from .._serialize import Serializable
class Action(DocstringParser): from . import Constructor, Action
""" from .component import Component
Represents an integration action. from .constants import doc_base_url
"""
class Constructor(DocstringParser):
"""
Represents an integration constructor.
"""
@classmethod
def parse(cls, obj: Union[Type, Callable]) -> "Constructor":
"""
Parse the parameters of a class constructor or action method.
:param obj: Base type of the object.
:return: The parsed parameters.
"""
init = getattr(obj, "__init__", None)
if init and callable(init):
return super().parse(init)
return super().parse(obj)
@dataclass @dataclass
class IntegrationMetadata: class Integration(Component, Serializable):
""" """
Represents the metadata of an integration (plugin or backend). Represents the metadata of an integration (plugin or backend).
""" """
@ -62,32 +38,44 @@ class IntegrationMetadata:
_skip_manifest: bool = False _skip_manifest: bool = False
def __post_init__(self): def __post_init__(self):
"""
Initialize the manifest object.
"""
if not self._skip_manifest: if not self._skip_manifest:
self._init_manifest() self._init_manifest()
@staticmethod def to_dict(self) -> dict:
def _merge_params(params: Dict[str, Parameter], new_params: Dict[str, Parameter]): return {
""" "name": self.name,
Utility function to merge a new mapping of parameters into an existing one. "type": f"{self.type.__module__}.{self.type.__qualname__}",
""" "doc": self.doc,
for param_name, param in new_params.items(): "doc_url": self.doc_url,
# Set the parameter if it doesn't exist "args": {
if param_name not in params: **(
params[param_name] = param {name: arg.to_dict() for name, arg in self.constructor.args.items()}
if self.constructor
# Set the parameter documentation if it's not set else {}
if param.doc and not params[param_name].doc: ),
params[param_name].doc = param.doc },
"actions": {
# If the new parameter has required=False, k: {
# then that should also be the value for the current ones "doc_url": f"{self.doc_url}#{self.cls.__module__}.{self.cls.__qualname__}.{k}",
if param.required is False: **v.to_dict(),
params[param_name].required = False }
for k, v in self.actions.items()
# If the new parameter has a default value, and the current if self.cls
# one doesn't, then the default value should be set as the new one. },
if param.default is not None and params[param_name].default is None: "events": {
params[param_name].default = param.default 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 @classmethod
def _merge_actions(cls, actions: Dict[str, Action], new_actions: Dict[str, Action]): def _merge_actions(cls, actions: Dict[str, Action], new_actions: Dict[str, Action]):
@ -104,7 +92,7 @@ class IntegrationMetadata:
actions[action_name].doc = action.doc actions[action_name].doc = action.doc
# Merge the parameters # Merge the parameters
cls._merge_params(actions[action_name].params, action.params) cls._merge_params(actions[action_name].args, action.args)
@classmethod @classmethod
def _merge_events(cls, events: Set[Type], new_events: Set[Type]): def _merge_events(cls, events: Set[Type], new_events: Set[Type]):
@ -114,7 +102,7 @@ class IntegrationMetadata:
events.update(new_events) events.update(new_events)
@classmethod @classmethod
def by_name(cls, name: str) -> "IntegrationMetadata": def by_name(cls, name: str) -> "Integration":
""" """
:param name: Integration name. :param name: Integration name.
:return: A parsed Integration class given its type. :return: A parsed Integration class given its type.
@ -127,7 +115,7 @@ class IntegrationMetadata:
return cls.by_type(type) return cls.by_type(type)
@classmethod @classmethod
def by_type(cls, type: Type, _skip_manifest: bool = False) -> "IntegrationMetadata": def by_type(cls, type: Type, _skip_manifest: bool = False) -> "Integration":
""" """
:param type: Integration type (plugin or backend). :param type: Integration type (plugin or backend).
:param _skip_manifest: Whether we should skip parsing the manifest file for this integration :param _skip_manifest: Whether we should skip parsing the manifest file for this integration
@ -167,7 +155,7 @@ class IntegrationMetadata:
p_obj = cls.by_type(p_type, _skip_manifest=True) p_obj = cls.by_type(p_type, _skip_manifest=True)
# Merge constructor parameters # Merge constructor parameters
if obj.constructor and p_obj.constructor: if obj.constructor and p_obj.constructor:
cls._merge_params(obj.constructor.params, p_obj.constructor.params) cls._merge_params(obj.constructor.args, p_obj.constructor.args)
# Merge actions # Merge actions
cls._merge_actions(obj.actions, p_obj.actions) cls._merge_actions(obj.actions, p_obj.actions)
@ -194,8 +182,25 @@ class IntegrationMetadata:
return getter(".".join(self.manifest.package.split(".")[2:])) 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 @classmethod
def from_manifest(cls, manifest_file: str) -> "IntegrationMetadata": def from_manifest(cls, manifest_file: str) -> "Integration":
""" """
Create an `IntegrationMetadata` object from a manifest file. Create an `IntegrationMetadata` object from a manifest file.
@ -302,27 +307,26 @@ class IntegrationMetadata:
else "" else ""
) )
+ "\n" + "\n"
for name, param in self.constructor.params.items() for name, param in self.constructor.args.items()
) )
if self.constructor and self.constructor.params if self.constructor and self.constructor.args
else " # No configuration required\n" 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
def import_file(path: str, name: Optional[str] = None): if issubclass(self.type, Plugin):
""" section = 'plugins'
Import a Python file as a module, even if no __init__.py is elif issubclass(self.type, Backend):
defined in the directory. section = 'backend'
else:
raise AssertionError(f'Unknown integration type {self.type}')
:param path: Path of the file to import. return f"{doc_base_url}/{section}/{self.name}.html"
:param name: Custom name for the imported module (default: same as the file's basename).
:return: The imported module.
"""
name = name or re.split(r"\.py$", os.path.basename(path))[0]
loader = SourceFileLoader(name, os.path.expanduser(path))
mod_spec = spec_from_loader(name, loader)
assert mod_spec, f"Cannot create module specification for {path}"
mod = module_from_spec(mod_spec)
loader.exec_module(mod)
return mod

View File

@ -0,0 +1,109 @@
import contextlib
import importlib
import inspect
from dataclasses import dataclass
from typing import Type, Optional
from .._serialize import Serializable
from . import Constructor
from .component import Component
from .constants import doc_base_url
@dataclass
class Message(Component, Serializable):
"""
Represents the metadata of a message type (event or response).
"""
name: str
type: Type
doc: Optional[str] = None
constructor: Optional[Constructor] = None
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 {}
),
},
}
@classmethod
def by_name(cls, name: str) -> "Message":
"""
:param name: Message type name.
:return: A parsed message class given its type.
"""
return cls.by_type(cls._get_cls(name))
@classmethod
def by_type(cls, type: Type) -> "Message":
"""
:param type: Message type.
:return: A parsed message class given its type.
"""
from platypush.message import Message as MessageClass
assert issubclass(type, MessageClass), f"Expected a Message class, got {type}"
obj = cls(
name=f'{type.__module__}.{type.__qualname__}',
type=type,
doc=inspect.getdoc(type),
constructor=Constructor.parse(type),
)
for p_type in inspect.getmro(type)[1:]:
# Don't go upper in the hierarchy.
if p_type == type:
break
with contextlib.suppress(AssertionError):
p_obj = cls.by_type(p_type)
# Merge constructor parameters
if obj.constructor and p_obj.constructor:
cls._merge_params(obj.constructor.args, p_obj.constructor.args)
return obj
@property
def cls(self) -> Type:
"""
:return: The class of a message.
"""
return self._get_cls(self.name)
@staticmethod
def _get_cls(name: str) -> Type:
"""
:param name: Full qualified type name, module included.
:return: The associated class.
"""
tokens = name.split(".")
module = importlib.import_module(".".join(tokens[:-1]))
return getattr(module, tokens[-1])
@property
def doc_url(self) -> str:
"""
:return: URL of the documentation for the message.
"""
from platypush.message.event import Event
from platypush.message.response import Response
if issubclass(self.type, Event):
section = 'events'
elif issubclass(self.type, Response):
section = 'responses'
else:
raise AssertionError(f'Unknown message type {self.type}')
mod_name = '.'.join(self.name.split('.')[3:-1])
return f"{doc_base_url}/{section}/{mod_name}.html#{self.name}"

View File

@ -0,0 +1,21 @@
from dataclasses import dataclass
from typing import Optional, Type
from .._serialize import Serializable
from .._utils import type_str
@dataclass
class ReturnValue(Serializable):
"""
Represents the return value of an action.
"""
doc: Optional[str] = None
type: Optional[Type] = None
def to_dict(self) -> dict:
return {
"doc": self.doc,
"type": type_str(self.type),
}

View File

@ -0,0 +1,6 @@
from .docstring import DocstringParser
__all__ = [
"DocstringParser",
]

View File

@ -0,0 +1,76 @@
import inspect
import textwrap as tw
from dataclasses import dataclass, field
from typing import (
Any,
Callable,
Iterable,
List,
Optional,
Tuple,
Type,
get_type_hints,
)
from .._model.argument import Argument
from .._model.returns import ReturnValue
from .state import ParseState
@dataclass
class ParseContext:
"""
Runtime parsing context.
"""
obj: Callable
state: ParseState = ParseState.DOC
cur_param: Optional[str] = None
doc: Optional[str] = None
returns: ReturnValue = field(default_factory=ReturnValue)
parsed_params: dict[str, Argument] = field(default_factory=dict)
def __post_init__(self):
"""
Initialize the return type and parameters from the function annotations.
"""
# Initialize the return type from the annotations
annotations = getattr(self.obj, "__annotations__", {})
if annotations:
self.returns.type = annotations.get("return")
# Initialize the parameters from the signature
spec = inspect.getfullargspec(self.obj)
defaults = spec.defaults or ()
defaults = defaults + ((Any,) * (len(self.param_names) - len(defaults or ())))
self.parsed_params = {
name: Argument(
name=name,
type=self.param_types.get(name),
default=default if default is not Any else None,
required=default is Any,
)
for name, default in zip(self.param_names, defaults)
}
@property
def spec(self) -> inspect.FullArgSpec:
return inspect.getfullargspec(self.obj)
@property
def param_names(self) -> List[str]:
return list(self.spec.args[1:])
@property
def param_defaults(self) -> Tuple[Any]:
defaults = self.spec.defaults or ()
return ((Any,) * (len(self.spec.args[1:]) - len(defaults))) + defaults
@property
def param_types(self) -> dict[str, Type]:
return get_type_hints(self.obj)
@property
def doc_lines(self) -> Iterable[str]:
return tw.dedent(inspect.getdoc(self.obj) or "").split("\n")

View File

@ -1,97 +1,17 @@
import inspect
import re import re
import textwrap as tw import textwrap as tw
from contextlib import contextmanager from contextlib import contextmanager
from dataclasses import dataclass, field from typing import Callable, Dict, Generator, Optional
from enum import IntEnum
from typing import ( from .._model.argument import Argument
Any, from .._model.returns import ReturnValue
Optional, from .._serialize import Serializable
Iterable, from .context import ParseContext
Type, from .rst import RstExtensionsMixin
get_type_hints, from .state import ParseState
Callable,
Tuple,
Generator,
Dict,
)
@dataclass class DocstringParser(Serializable, RstExtensionsMixin):
class ReturnValue:
"""
Represents the return value of an action.
"""
doc: Optional[str] = None
type: Optional[Type] = None
@dataclass
class Parameter:
"""
Represents an integration constructor/action parameter.
"""
name: str
required: bool = False
doc: Optional[str] = None
type: Optional[Type] = None
default: Optional[str] = None
class ParseState(IntEnum):
"""
Parse state.
"""
DOC = 0
PARAM = 1
TYPE = 2
RETURN = 3
@dataclass
class ParseContext:
"""
Runtime parsing context.
"""
obj: Callable
state: ParseState = ParseState.DOC
cur_param: Optional[str] = None
doc: Optional[str] = None
returns: ReturnValue = field(default_factory=ReturnValue)
parsed_params: dict[str, Parameter] = field(default_factory=dict)
def __post_init__(self):
annotations = getattr(self.obj, "__annotations__", {})
if annotations:
self.returns.type = annotations.get("return")
@property
def spec(self) -> inspect.FullArgSpec:
return inspect.getfullargspec(self.obj)
@property
def param_names(self) -> Iterable[str]:
return self.spec.args[1:]
@property
def param_defaults(self) -> Tuple[Any]:
defaults = self.spec.defaults or ()
return ((Any,) * (len(self.spec.args[1:]) - len(defaults))) + defaults
@property
def param_types(self) -> dict[str, Type]:
return get_type_hints(self.obj)
@property
def doc_lines(self) -> Iterable[str]:
return tw.dedent(inspect.getdoc(self.obj) or "").split("\n")
class DocstringParser:
""" """
Mixin for objects that can parse docstrings. Mixin for objects that can parse docstrings.
""" """
@ -105,14 +25,42 @@ class DocstringParser:
self, self,
name: str, name: str,
doc: Optional[str] = None, doc: Optional[str] = None,
params: Optional[Dict[str, Parameter]] = None, args: Optional[Dict[str, Argument]] = None,
has_varargs: bool = False,
has_kwargs: bool = False,
returns: Optional[ReturnValue] = None, returns: Optional[ReturnValue] = None,
): ):
self.name = name self.name = name
self.doc = doc self.doc = doc
self.params = params or {} self.args = args or {}
self.has_varargs = has_varargs
self.has_kwargs = has_kwargs
self.returns = returns self.returns = returns
def to_dict(self) -> dict:
return {
"name": self.name,
"doc": self.doc,
"args": {k: v.to_dict() for k, v in self.args.items()},
"has_varargs": self.has_varargs,
"has_kwargs": self.has_kwargs,
"returns": self.returns.to_dict() if self.returns else None,
}
@staticmethod
def _norm_indent(text: Optional[str]) -> Optional[str]:
"""
Normalize the indentation of a docstring.
:param text: Input docstring
:return: A representation of the docstring where all the leading spaces have been removed.
"""
if not text:
return None
lines = text.split("\n")
return (lines[0] + "\n" + tw.dedent("\n".join(lines[1:]) or "")).strip()
@classmethod @classmethod
@contextmanager @contextmanager
def _parser(cls, obj: Callable) -> Generator[ParseContext, None, None]: def _parser(cls, obj: Callable) -> Generator[ParseContext, None, None]:
@ -123,28 +71,15 @@ class DocstringParser:
:return: The parsing context. :return: The parsing context.
""" """
def norm_indent(text: Optional[str]) -> Optional[str]:
"""
Normalize the indentation of a docstring.
:param text: Input docstring
:return: A representation of the docstring where all the leading spaces have been removed.
"""
if not text:
return None
lines = text.split("\n")
return (lines[0] + "\n" + tw.dedent("\n".join(lines[1:]) or "")).strip()
ctx = ParseContext(obj) ctx = ParseContext(obj)
yield ctx yield ctx
# Normalize the parameters docstring indentation # Normalize the parameters docstring indentation
for param in ctx.parsed_params.values(): for param in ctx.parsed_params.values():
param.doc = norm_indent(param.doc) param.doc = cls._norm_indent(param.doc)
# Normalize the return docstring indentation # Normalize the return docstring indentation
ctx.returns.doc = norm_indent(ctx.returns.doc) ctx.returns.doc = cls._norm_indent(ctx.returns.doc)
@staticmethod @staticmethod
def _is_continuation_line(line: str) -> bool: def _is_continuation_line(line: str) -> bool:
@ -169,6 +104,9 @@ class DocstringParser:
if cls._default_docstring.match(line): if cls._default_docstring.match(line):
return return
# Expand any custom RST extensions
line = cls._expand_rst_extensions(line, ctx)
# Update the return type docstring if required # Update the return type docstring if required
m = cls._return_doc_re.match(line) m = cls._return_doc_re.match(line)
if m or (ctx.state == ParseState.RETURN and cls._is_continuation_line(line)): if m or (ctx.state == ParseState.RETURN and cls._is_continuation_line(line)):
@ -178,28 +116,17 @@ class DocstringParser:
).rstrip() ).rstrip()
return return
# Create a new parameter entry if the docstring says so # Initialize the documentation of a parameter on :param: docstring lines
m = cls._param_doc_re.match(line) m = cls._param_doc_re.match(line)
if m: if m and ctx.parsed_params.get(m.group("name")):
ctx.state = ParseState.PARAM ctx.state = ParseState.PARAM
idx = len(ctx.parsed_params)
ctx.cur_param = m.group("name") ctx.cur_param = m.group("name")
# Skip vararg/var keyword parameters # Skip vararg/var keyword parameters
if ctx.cur_param in {ctx.spec.varkw, ctx.spec.varargs}: if ctx.cur_param in {ctx.spec.varkw, ctx.spec.varargs}:
return return
ctx.parsed_params[ctx.cur_param] = Parameter( ctx.parsed_params[ctx.cur_param].doc = m.group("doc")
name=ctx.cur_param,
required=(
idx >= len(ctx.param_defaults) or ctx.param_defaults[idx] is Any
),
doc=m.group("doc"),
type=ctx.param_types.get(ctx.cur_param),
default=ctx.param_defaults[idx]
if idx < len(ctx.param_defaults) and ctx.param_defaults[idx] is not Any
else None,
)
return return
# Update the current parameter docstring if required # Update the current parameter docstring if required
@ -236,6 +163,8 @@ class DocstringParser:
return cls( return cls(
name=obj.__name__, name=obj.__name__,
doc=ctx.doc, doc=ctx.doc,
params=ctx.parsed_params, args=ctx.parsed_params,
has_varargs=ctx.spec.varargs is not None,
has_kwargs=ctx.spec.varkw is not None,
returns=ctx.returns, returns=ctx.returns,
) )

View File

@ -0,0 +1,162 @@
import importlib
import logging
import re
import textwrap as tw
from .._model.constants import doc_base_url
from .context import ParseContext
# pylint: disable=too-few-public-methods
class RstExtensionsMixin:
"""
Mixin class for handling non-standard reStructuredText extensions.
"""
_rst_extensions = {
name: re.compile(regex)
for name, regex in {
"class": "(:class:`(?P<name>[^`]+)`)",
"method": "(:meth:`(?P<name>[^`]+)`)",
"function": "(:func:`(?P<name>[^`]+)`)",
"schema": r"^((?P<indent>\s*)(?P<before>.*)"
r"(\.\. schema:: (?P<name>[\w.]+)\s*"
r"(\((?P<args>.+?)\))?)(?P<after>.*))$",
}.items()
}
logger = logging.getLogger(__name__)
@classmethod
def _expand_rst_extensions(cls, docstr: str, ctx: ParseContext) -> str:
"""
Expand the known reStructuredText extensions in a docstring.
"""
for ex_name, regex in cls._rst_extensions.items():
match = regex.search(docstr)
if not match:
continue
try:
docstr = (
cls._expand_schema(docstr, match)
if ex_name == "schema"
else cls._expand_module(docstr, ex_name, match, ctx)
)
except Exception as e:
cls.logger.warning(
"Could not import module %s: %s", match.group("name"), e
)
continue
return docstr
@classmethod
def _expand_schema(cls, docstr: str, match: re.Match) -> str:
from marshmallow import missing
from marshmallow.validate import OneOf
value = match.group("name")
mod = importlib.import_module(
"platypush.schemas." + ".".join(value.split(".")[:-1])
)
obj_cls = getattr(mod, value.split(".")[-1])
schema_args = (
eval(f'dict({match.group("args")})') if match.group("args") else {}
)
obj = obj_cls(**schema_args)
schema_doc = tw.indent(
".. code-block:: python\n\n"
+ tw.indent(
("[" if obj.many else "")
+ "{\n"
+ tw.indent(
"\n".join(
(
(
"# " + field.metadata["description"] + "\n"
if field.metadata.get("description")
else ""
)
+ (
"# Possible values: "
+ str(field.validate.choices)
+ "\n"
if isinstance(field.validate, OneOf)
else ""
)
+ f'"{field_name}": '
+ (
(
'"'
+ field.metadata.get("example", field.default)
+ '"'
if isinstance(
field.metadata.get("example", field.default),
str,
)
else str(
field.metadata.get("example", field.default)
)
)
if not (
field.metadata.get("example") is None
and field.default is missing
)
else "..."
)
)
for field_name, field in obj.fields.items()
),
prefix=" ",
)
+ "\n}"
+ ("]" if obj.many else ""),
prefix=" ",
),
prefix=match.group("indent") + " ",
)
docstr = docstr.replace(
match.group(0),
match.group("before") + "\n\n" + schema_doc + "\n\n" + match.group("after"),
)
return docstr
@classmethod
def _expand_module(
cls, docstr: str, ex_name: str, match: re.Match, ctx: ParseContext
) -> str:
value = match.group("name")
if value.startswith("."):
modname = ctx.obj.__module__ # noqa
obj_name = ctx.obj.__qualname__
elif ex_name == "method":
modname = ".".join(value.split(".")[:-2])
obj_name = ".".join(value.split(".")[-2:])
else:
modname = ".".join(value.split(".")[:-1])
obj_name = value.split(".")[-1]
url_path = None
if modname.startswith("platypush.plugins"):
url_path = "plugins/" + ".".join(modname.split(".")[2:])
elif modname.startswith("platypush.backend"):
url_path = "backends/" + ".".join(modname.split(".")[2:])
elif modname.startswith("platypush.message.event"):
url_path = "events/" + ".".join(modname.split(".")[3:])
elif modname.startswith("platypush.message.response"):
url_path = "responses/" + ".".join(modname.split(".")[3:])
if url_path:
docstr = docstr.replace(
match.group(0),
f"`{obj_name} <{doc_base_url}/{url_path}.html#{modname}.{obj_name}>`_",
)
else:
docstr = docstr.replace(match.group(0), f"``{value}``")
return docstr

View File

@ -0,0 +1,12 @@
from enum import IntEnum
class ParseState(IntEnum):
"""
Parse state.
"""
DOC = 0
PARAM = 1
TYPE = 2
RETURN = 3

View File

@ -0,0 +1,14 @@
from abc import ABC, abstractmethod
class Serializable(ABC):
"""
Base class for reflection entities that can be serialized to JSON/YAML.
"""
@abstractmethod
def to_dict(self) -> dict:
"""
Serialize the entity to a string.
"""
raise NotImplementedError()

View File

@ -0,0 +1,12 @@
import re
from typing import Optional, Type
def type_str(t: Optional[Type]) -> Optional[str]:
"""
:return: A human-readable representation of a type.
"""
if not t:
return None
return re.sub(r"<class '(.*)'>", r'\1', str(t).replace('typing.', ''))

View File

@ -56,17 +56,48 @@ class Config:
'now': datetime.datetime.now, 'now': datetime.datetime.now,
} }
# Default working directory:
# - $XDG_DATA_HOME/platypush if XDG_DATA_HOME is set
# - /var/lib/platypush if the user is root
# - $HOME/.local/share/platypush otherwise
_workdir_location = os.path.join( _workdir_location = os.path.join(
*( *(
(os.environ['XDG_DATA_HOME'], 'platypush') (os.environ['XDG_DATA_HOME'],)
if os.environ.get('XDG_DATA_HOME') if os.environ.get('XDG_DATA_HOME')
else (os.path.expanduser('~'), '.local', 'share', 'platypush') else (
) (os.sep, 'var', 'lib')
if os.geteuid() == 0
else (os.path.expanduser('~'), '.local', 'share')
)
),
'platypush',
)
# Default cache directory:
# - $XDG_CACHE_DIR/platypush if XDG_CACHE_DIR is set
# - /var/cache/platypush if the user is root
# - $HOME/.cache/platypush otherwise
_cachedir_location = os.path.join(
*(
(os.environ['XDG_CACHE_DIR'],)
if os.environ.get('XDG_CACHE_DIR')
else (
(os.sep, 'var', 'cache')
if os.geteuid() == 0
else (os.path.expanduser('~'), '.cache')
)
),
'platypush',
) )
_included_files: Set[str] = set() _included_files: Set[str] = set()
def __init__(self, cfgfile: Optional[str] = None, workdir: Optional[str] = None): def __init__(
self,
cfgfile: Optional[str] = None,
workdir: Optional[str] = None,
cachedir: Optional[str] = None,
):
""" """
Constructor. Always use the class as a singleton (i.e. through Constructor. Always use the class as a singleton (i.e. through
Config.init), you won't probably need to call the constructor directly Config.init), you won't probably need to call the constructor directly
@ -74,6 +105,7 @@ class Config:
:param cfgfile: Config file path (default: retrieve the first available :param cfgfile: Config file path (default: retrieve the first available
location in _cfgfile_locations). location in _cfgfile_locations).
:param workdir: Overrides the default working directory. :param workdir: Overrides the default working directory.
:param cachedir: Overrides the default cache directory.
""" """
self.backends = {} self.backends = {}
@ -91,7 +123,7 @@ class Config:
self._config = self._read_config_file(self.config_file) self._config = self._read_config_file(self.config_file)
self._init_secrets() self._init_secrets()
self._init_dirs(workdir=workdir) self._init_dirs(workdir=workdir, cachedir=cachedir)
self._init_db() self._init_db()
self._init_logging() self._init_logging()
self._init_device_id() self._init_device_id()
@ -168,29 +200,32 @@ class Config:
for k, v in self._config['environment'].items(): for k, v in self._config['environment'].items():
os.environ[k] = str(v) os.environ[k] = str(v)
def _init_dirs(self, workdir: Optional[str] = None): def _init_workdir(self, workdir: Optional[str] = None):
if workdir: if workdir:
self._config['workdir'] = workdir self._config['workdir'] = workdir
if not self._config.get('workdir'): if not self._config.get('workdir'):
self._config['workdir'] = self._workdir_location self._config['workdir'] = self._workdir_location
self._config['workdir'] = os.path.expanduser( self._config['workdir'] = os.path.expanduser(self._config['workdir'])
os.path.expanduser(self._config['workdir'])
)
pathlib.Path(self._config['workdir']).mkdir(parents=True, exist_ok=True) pathlib.Path(self._config['workdir']).mkdir(parents=True, exist_ok=True)
def _init_cachedir(self, cachedir: Optional[str] = None):
if cachedir:
self._config['cachedir'] = cachedir
if not self._config.get('cachedir'):
self._config['cachedir'] = self._cachedir_location
self._config['cachedir'] = os.path.expanduser(self._config['cachedir'])
pathlib.Path(self._config['cachedir']).mkdir(parents=True, exist_ok=True)
def _init_scripts_dir(self):
# Create the scripts directory if it doesn't exist
if 'scripts_dir' not in self._config: if 'scripts_dir' not in self._config:
self._config['scripts_dir'] = os.path.join( self._config['scripts_dir'] = os.path.join(
os.path.dirname(self.config_file), 'scripts' os.path.dirname(self.config_file), 'scripts'
) )
os.makedirs(self._config['scripts_dir'], mode=0o755, exist_ok=True) os.makedirs(self._config['scripts_dir'], mode=0o755, exist_ok=True)
if 'dashboards_dir' not in self._config:
self._config['dashboards_dir'] = os.path.join(
os.path.dirname(self.config_file), 'dashboards'
)
os.makedirs(self._config['dashboards_dir'], mode=0o755, exist_ok=True)
# Create a default (empty) __init__.py in the scripts folder # Create a default (empty) __init__.py in the scripts folder
init_py = os.path.join(self._config['scripts_dir'], '__init__.py') init_py = os.path.join(self._config['scripts_dir'], '__init__.py')
if not os.path.isfile(init_py): if not os.path.isfile(init_py):
@ -204,6 +239,19 @@ class Config:
) )
sys.path = [scripts_parent_dir] + sys.path sys.path = [scripts_parent_dir] + sys.path
def _init_dashboards_dir(self):
if 'dashboards_dir' not in self._config:
self._config['dashboards_dir'] = os.path.join(
os.path.dirname(self.config_file), 'dashboards'
)
os.makedirs(self._config['dashboards_dir'], mode=0o755, exist_ok=True)
def _init_dirs(self, workdir: Optional[str] = None, cachedir: Optional[str] = None):
self._init_workdir(workdir=workdir)
self._init_cachedir(cachedir=cachedir)
self._init_scripts_dir()
self._init_dashboards_dir()
def _init_secrets(self): def _init_secrets(self):
if 'token' in self._config: if 'token' in self._config:
self._config['token_hash'] = get_hash(self._config['token']) self._config['token_hash'] = get_hash(self._config['token'])
@ -425,6 +473,7 @@ class Config:
cls, cls,
cfgfile: Optional[str] = None, cfgfile: Optional[str] = None,
workdir: Optional[str] = None, workdir: Optional[str] = None,
cachedir: Optional[str] = None,
force_reload: bool = False, force_reload: bool = False,
) -> "Config": ) -> "Config":
""" """
@ -432,7 +481,7 @@ class Config:
""" """
if force_reload or cls._instance is None: if force_reload or cls._instance is None:
cfg_args = [cfgfile] if cfgfile else [] cfg_args = [cfgfile] if cfgfile else []
cls._instance = Config(*cfg_args, workdir=workdir) cls._instance = Config(*cfg_args, workdir=workdir, cachedir=cachedir)
return cls._instance return cls._instance
@classmethod @classmethod
@ -496,6 +545,7 @@ class Config:
cfgfile: Optional[str] = None, cfgfile: Optional[str] = None,
device_id: Optional[str] = None, device_id: Optional[str] = None,
workdir: Optional[str] = None, workdir: Optional[str] = None,
cachedir: Optional[str] = None,
ctrl_sock: Optional[str] = None, ctrl_sock: Optional[str] = None,
**_, **_,
): ):
@ -505,13 +555,18 @@ class Config:
:param cfgfile: Path to the config file (default: _cfgfile_locations) :param cfgfile: Path to the config file (default: _cfgfile_locations)
:param device_id: Override the configured device_id. :param device_id: Override the configured device_id.
:param workdir: Override the configured working directory. :param workdir: Override the configured working directory.
:param cachedir: Override the configured cache directory.
:param ctrl_sock: Override the configured control socket. :param ctrl_sock: Override the configured control socket.
""" """
cfg = cls._get_instance(cfgfile, workdir=workdir, force_reload=True) cfg = cls._get_instance(
cfgfile, workdir=workdir, cachedir=cachedir, force_reload=True
)
if device_id: if device_id:
cfg.set('device_id', device_id) cfg.set('device_id', device_id)
if workdir: if workdir:
cfg.set('workdir', workdir) cfg.set('workdir', workdir)
if cachedir:
cfg.set('cachedir', cachedir)
if ctrl_sock: if ctrl_sock:
cfg.set('ctrl_sock', ctrl_sock) cfg.set('ctrl_sock', ctrl_sock)
@ -526,6 +581,15 @@ class Config:
assert workdir assert workdir
return workdir # type: ignore return workdir # type: ignore
@classmethod
def get_cachedir(cls) -> str:
"""
:return: The path of the configured cache directory.
"""
workdir = cls._get_instance().get('cachedir')
assert workdir
return workdir # type: ignore
@classmethod @classmethod
def get(cls, key: Optional[str] = None, default: Optional[Any] = None): def get(cls, key: Optional[str] = None, default: Optional[Any] = None):
""" """

View File

@ -55,11 +55,29 @@
# # If not specified, then one of the following will be used: # # If not specified, then one of the following will be used:
# # # #
# # - $XDG_DATA_HOME/platypush if the XDG_DATA_HOME environment variable is set. # # - $XDG_DATA_HOME/platypush if the XDG_DATA_HOME environment variable is set.
# # - /var/lib/platypush if the user is root.
# # - $HOME/.local/share/platypush otherwise. # # - $HOME/.local/share/platypush otherwise.
# #
# workdir: ~/.local/share/platypush # workdir: ~/.local/share/platypush
### ###
### -----------------
### Cache directory
### -----------------
###
# # Note that the cache directory can also be specified at runtime using the
# # --cachedir option.
# #
# # If not specified, then one of the following will be used:
# #
# # - $XDG_CACHE_DIR/platypush if the XDG_CACHE_DIR environment variable is set.
# # - /var/cache/platypush if the user is root.
# # - $HOME/.cache/platypush otherwise.
#
# cachedir: ~/.cache/platypush
###
### ---------------------- ### ----------------------
### Database configuration ### Database configuration
### ---------------------- ### ----------------------

View File

@ -30,7 +30,7 @@ from sqlalchemy.orm.exc import ObjectDeletedError
import platypush import platypush
from platypush.config import Config from platypush.config import Config
from platypush.common.db import Base from platypush.common.db import Base, is_defined
from platypush.message import JSONAble, Message from platypush.message import JSONAble, Message
EntityRegistryType = Dict[str, Type['Entity']] EntityRegistryType = Dict[str, Type['Entity']]
@ -52,7 +52,7 @@ fail.
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
if 'entity' not in Base.metadata: if not is_defined('entity'):
class Entity(Base): class Entity(Base):
""" """

View File

@ -1,11 +1,11 @@
from sqlalchemy import Column, Integer, ForeignKey from sqlalchemy import Column, Integer, ForeignKey
from platypush.common.db import Base from platypush.common.db import is_defined
from .three_axis import ThreeAxisSensor from .three_axis import ThreeAxisSensor
if 'accelerometer' not in Base.metadata: if not is_defined('accelerometer'):
class Accelerometer(ThreeAxisSensor): class Accelerometer(ThreeAxisSensor):
""" """
@ -20,6 +20,7 @@ if 'accelerometer' not in Base.metadata:
primary_key=True, primary_key=True,
) )
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }

View File

@ -1,12 +1,12 @@
from sqlalchemy import Column, Integer, ForeignKey from sqlalchemy import Column, Integer, ForeignKey
from platypush.common.db import Base from platypush.common.db import is_defined
from .dimmers import Dimmer from .dimmers import Dimmer
from .switches import Switch from .switches import Switch
if 'volume' not in Base.metadata: if not is_defined('volume'):
class Volume(Dimmer): class Volume(Dimmer):
__tablename__ = 'volume' __tablename__ = 'volume'
@ -15,12 +15,13 @@ if 'volume' not in Base.metadata:
Integer, ForeignKey(Dimmer.id, ondelete='CASCADE'), primary_key=True Integer, ForeignKey(Dimmer.id, ondelete='CASCADE'), primary_key=True
) )
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }
if 'muted' not in Base.metadata: if not is_defined('muted'):
class Muted(Switch): class Muted(Switch):
__tablename__ = 'muted' __tablename__ = 'muted'
@ -29,6 +30,7 @@ if 'muted' not in Base.metadata:
Integer, ForeignKey(Switch.id, ondelete='CASCADE'), primary_key=True Integer, ForeignKey(Switch.id, ondelete='CASCADE'), primary_key=True
) )
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }

View File

@ -1,11 +1,11 @@
from sqlalchemy import Column, Integer, ForeignKey from sqlalchemy import Column, Integer, ForeignKey
from platypush.common.db import Base from platypush.common.db import is_defined
from .sensors import NumericSensor from .sensors import NumericSensor
if 'battery' not in Base.metadata: if not is_defined('battery'):
class Battery(NumericSensor): class Battery(NumericSensor):
__tablename__ = 'battery' __tablename__ = 'battery'
@ -19,6 +19,7 @@ if 'battery' not in Base.metadata:
Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True
) )
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }

View File

@ -9,13 +9,13 @@ from sqlalchemy import (
String, String,
) )
from platypush.common.db import Base from platypush.common.db import is_defined
from ..devices import Device from ..devices import Device
from ._service import BluetoothService from ._service import BluetoothService
if 'bluetooth_device' not in Base.metadata: if not is_defined('bluetooth_device'):
class BluetoothDevice(Device): class BluetoothDevice(Device):
""" """
@ -68,6 +68,7 @@ if 'bluetooth_device' not in Base.metadata:
model_id = Column(String, default=None) model_id = Column(String, default=None)
""" Device model ID. """ """ Device model ID. """
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }

View File

@ -8,10 +8,10 @@ from sqlalchemy import (
String, String,
) )
from platypush.common.db import Base from platypush.common.db import is_defined
from platypush.entities import Entity from platypush.entities import Entity
if 'bluetooth_service' not in Base.metadata: if not is_defined('bluetooth_service'):
class BluetoothService(Entity): class BluetoothService(Entity):
""" """
@ -44,6 +44,7 @@ if 'bluetooth_service' not in Base.metadata:
connected = Column(Boolean, default=False) connected = Column(Boolean, default=False)
""" Whether an active connection exists to this service. """ """ Whether an active connection exists to this service. """
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }

View File

@ -6,14 +6,14 @@ from sqlalchemy import (
Integer, Integer,
) )
from platypush.common.db import Base from platypush.common.db import is_defined
from .sensors import EnumSensor from .sensors import EnumSensor
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
if 'button' not in Base.metadata: if not is_defined('button'):
class Button(EnumSensor): class Button(EnumSensor):
__tablename__ = 'button' __tablename__ = 'button'
@ -22,6 +22,7 @@ if 'button' not in Base.metadata:
Integer, ForeignKey(EnumSensor.id, ondelete='CASCADE'), primary_key=True Integer, ForeignKey(EnumSensor.id, ondelete='CASCADE'), primary_key=True
) )
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }

View File

@ -6,12 +6,12 @@ from sqlalchemy import (
String, String,
) )
from platypush.common.db import Base from platypush.common.db import is_defined
from .devices import Device from .devices import Device
if 'cloud_instance' not in Base.metadata: if not is_defined('cloud_instance'):
class CloudInstance(Device): class CloudInstance(Device):
""" """
@ -38,6 +38,7 @@ if 'cloud_instance' not in Base.metadata:
alerts = Column(JSON) alerts = Column(JSON)
backups = Column(JSON) backups = Column(JSON)
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }

View File

@ -1,11 +1,11 @@
from sqlalchemy import Column, Integer, ForeignKey from sqlalchemy import Column, Integer, ForeignKey
from platypush.common.db import Base from platypush.common.db import is_defined
from .sensors import BinarySensor from .sensors import BinarySensor
if 'contact_sensor' not in Base.metadata: if not is_defined('contact_sensor'):
class ContactSensor(BinarySensor): class ContactSensor(BinarySensor):
""" """
@ -18,6 +18,7 @@ if 'contact_sensor' not in Base.metadata:
Integer, ForeignKey(BinarySensor.id, ondelete='CASCADE'), primary_key=True Integer, ForeignKey(BinarySensor.id, ondelete='CASCADE'), primary_key=True
) )
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }

View File

@ -1,11 +1,11 @@
from sqlalchemy import Column, Integer, Boolean, ForeignKey from sqlalchemy import Column, Integer, Boolean, ForeignKey
from platypush.common.db import Base from platypush.common.db import is_defined
from ._base import Entity from ._base import Entity
if 'device' not in Base.metadata: if not is_defined('device'):
class Device(Entity): class Device(Entity):
""" """
@ -19,6 +19,7 @@ if 'device' not in Base.metadata:
) )
reachable = Column(Boolean, default=True) reachable = Column(Boolean, default=True)
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }

View File

@ -1,11 +1,11 @@
from sqlalchemy import Column, Integer, ForeignKey, Float, String from sqlalchemy import Column, Integer, ForeignKey, Float, String
from platypush.common.db import Base from platypush.common.db import is_defined
from .devices import Device from .devices import Device
if 'dimmer' not in Base.metadata: if not is_defined('dimmer'):
class Dimmer(Device): class Dimmer(Device):
""" """
@ -24,6 +24,7 @@ if 'dimmer' not in Base.metadata:
value = Column(Float) value = Column(Float)
unit = Column(String) unit = Column(String)
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }

View File

@ -1,11 +1,11 @@
from sqlalchemy import Column, Integer, ForeignKey from sqlalchemy import Column, Integer, ForeignKey
from platypush.common.db import Base from platypush.common.db import is_defined
from .sensors import NumericSensor from .sensors import NumericSensor
if 'distance_sensor' not in Base.metadata: if not is_defined('distance_sensor'):
class DistanceSensor(NumericSensor): class DistanceSensor(NumericSensor):
""" """
@ -18,6 +18,7 @@ if 'distance_sensor' not in Base.metadata:
Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True
) )
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }

View File

@ -1,11 +1,11 @@
from sqlalchemy import Column, Integer, ForeignKey from sqlalchemy import Column, Integer, ForeignKey
from platypush.common.db import Base from platypush.common.db import is_defined
from .sensors import NumericSensor from .sensors import NumericSensor
if 'power_sensor' not in Base.metadata: if not is_defined('power_sensor'):
class PowerSensor(NumericSensor): class PowerSensor(NumericSensor):
__tablename__ = 'power_sensor' __tablename__ = 'power_sensor'
@ -14,12 +14,13 @@ if 'power_sensor' not in Base.metadata:
Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True
) )
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }
if 'current_sensor' not in Base.metadata: if not is_defined('current_sensor'):
class CurrentSensor(NumericSensor): class CurrentSensor(NumericSensor):
__tablename__ = 'current_sensor' __tablename__ = 'current_sensor'
@ -28,12 +29,13 @@ if 'current_sensor' not in Base.metadata:
Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True
) )
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }
if 'voltage_sensor' not in Base.metadata: if not is_defined('voltage_sensor'):
class VoltageSensor(NumericSensor): class VoltageSensor(NumericSensor):
__tablename__ = 'voltage_sensor' __tablename__ = 'voltage_sensor'
@ -42,12 +44,13 @@ if 'voltage_sensor' not in Base.metadata:
Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True
) )
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }
if 'energy_sensor' not in Base.metadata: if not is_defined('energy_sensor'):
class EnergySensor(NumericSensor): class EnergySensor(NumericSensor):
__tablename__ = 'energy_sensor' __tablename__ = 'energy_sensor'
@ -56,6 +59,7 @@ if 'energy_sensor' not in Base.metadata:
Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True
) )
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }

View File

@ -1,11 +1,11 @@
from sqlalchemy import Column, Integer, ForeignKey from sqlalchemy import Column, Integer, ForeignKey
from platypush.common.db import Base from platypush.common.db import is_defined
from .sensors import NumericSensor from .sensors import NumericSensor
if 'heart_rate_sensor' not in Base.metadata: if not is_defined('heart_rate_sensor'):
class HeartRateSensor(NumericSensor): class HeartRateSensor(NumericSensor):
""" """
@ -18,6 +18,7 @@ if 'heart_rate_sensor' not in Base.metadata:
Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True
) )
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }

View File

@ -1,11 +1,11 @@
from sqlalchemy import Column, Integer, ForeignKey from sqlalchemy import Column, Integer, ForeignKey
from platypush.common.db import Base from platypush.common.db import is_defined
from .sensors import NumericSensor from .sensors import NumericSensor
if 'humidity_sensor' not in Base.metadata: if not is_defined('humidity_sensor'):
class HumiditySensor(NumericSensor): class HumiditySensor(NumericSensor):
""" """
@ -18,12 +18,13 @@ if 'humidity_sensor' not in Base.metadata:
Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True
) )
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }
if 'dew_point_sensor' not in Base.metadata: if not is_defined('dew_point_sensor'):
class DewPointSensor(NumericSensor): class DewPointSensor(NumericSensor):
""" """
@ -36,6 +37,7 @@ if 'dew_point_sensor' not in Base.metadata:
Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True
) )
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }

View File

@ -1,11 +1,11 @@
from sqlalchemy import Column, Integer, ForeignKey from sqlalchemy import Column, Integer, ForeignKey
from platypush.common.db import Base from platypush.common.db import is_defined
from .sensors import NumericSensor from .sensors import NumericSensor
if 'illuminance_sensor' not in Base.metadata: if not is_defined('illuminance_sensor'):
class IlluminanceSensor(NumericSensor): class IlluminanceSensor(NumericSensor):
__tablename__ = 'illuminance_sensor' __tablename__ = 'illuminance_sensor'
@ -14,6 +14,7 @@ if 'illuminance_sensor' not in Base.metadata:
Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True
) )
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }

View File

@ -1,11 +1,11 @@
from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, Float from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, Float
from platypush.common.db import Base from platypush.common.db import is_defined
from .devices import Device from .devices import Device
if 'light' not in Base.metadata: if not is_defined('light'):
class Light(Device): class Light(Device):
__tablename__ = 'light' __tablename__ = 'light'
@ -34,6 +34,7 @@ if 'light' not in Base.metadata:
temperature_min = Column(Float) temperature_min = Column(Float)
temperature_max = Column(Float) temperature_max = Column(Float)
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }

View File

@ -1,11 +1,11 @@
from sqlalchemy import Column, Integer, ForeignKey from sqlalchemy import Column, Integer, ForeignKey
from platypush.common.db import Base from platypush.common.db import is_defined
from .sensors import NumericSensor from .sensors import NumericSensor
if 'link_quality' not in Base.metadata: if not is_defined('link_quality'):
class LinkQuality(NumericSensor): class LinkQuality(NumericSensor):
__tablename__ = 'link_quality' __tablename__ = 'link_quality'
@ -19,6 +19,7 @@ if 'link_quality' not in Base.metadata:
Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True
) )
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }

View File

@ -1,11 +1,11 @@
from sqlalchemy import Column, Integer, ForeignKey from sqlalchemy import Column, Integer, ForeignKey
from platypush.common.db import Base from platypush.common.db import is_defined
from .three_axis import ThreeAxisSensor from .three_axis import ThreeAxisSensor
if 'magnetometer' not in Base.metadata: if not is_defined('magnetometer'):
class Magnetometer(ThreeAxisSensor): class Magnetometer(ThreeAxisSensor):
""" """
@ -20,6 +20,7 @@ if 'magnetometer' not in Base.metadata:
primary_key=True, primary_key=True,
) )
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }

View File

@ -1,11 +1,11 @@
from sqlalchemy import Column, Integer, ForeignKey from sqlalchemy import Column, Integer, ForeignKey
from platypush.common.db import Base from platypush.common.db import is_defined
from .sensors import BinarySensor from .sensors import BinarySensor
if 'motion_sensor' not in Base.metadata: if not is_defined('motion_sensor'):
class MotionSensor(BinarySensor): class MotionSensor(BinarySensor):
__tablename__ = 'motion_sensor' __tablename__ = 'motion_sensor'
@ -14,6 +14,7 @@ if 'motion_sensor' not in Base.metadata:
Integer, ForeignKey(BinarySensor.id, ondelete='CASCADE'), primary_key=True Integer, ForeignKey(BinarySensor.id, ondelete='CASCADE'), primary_key=True
) )
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }

View File

@ -1,11 +1,11 @@
from sqlalchemy import Column, Integer, ForeignKey from sqlalchemy import Column, Integer, ForeignKey
from platypush.common.db import Base from platypush.common.db import is_defined
from .sensors import BinarySensor from .sensors import BinarySensor
if 'presence_sensor' not in Base.metadata: if not is_defined('presence_sensor'):
class PresenceSensor(BinarySensor): class PresenceSensor(BinarySensor):
""" """
@ -18,6 +18,7 @@ if 'presence_sensor' not in Base.metadata:
Integer, ForeignKey(BinarySensor.id, ondelete='CASCADE'), primary_key=True Integer, ForeignKey(BinarySensor.id, ondelete='CASCADE'), primary_key=True
) )
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }

View File

@ -1,11 +1,11 @@
from sqlalchemy import Column, Integer, ForeignKey from sqlalchemy import Column, Integer, ForeignKey
from platypush.common.db import Base from platypush.common.db import is_defined
from .sensors import NumericSensor from .sensors import NumericSensor
if 'pressure_sensor' not in Base.metadata: if not is_defined('pressure_sensor'):
class PressureSensor(NumericSensor): class PressureSensor(NumericSensor):
""" """
@ -18,6 +18,7 @@ if 'pressure_sensor' not in Base.metadata:
Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True
) )
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }

View File

@ -12,7 +12,7 @@ from sqlalchemy import (
String, String,
) )
from platypush.common.db import Base from platypush.common.db import is_defined
from .devices import Device from .devices import Device
@ -32,7 +32,7 @@ class Sensor(Device):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if 'raw_sensor' not in Base.metadata: if not is_defined('raw_sensor'):
class RawSensor(Sensor): class RawSensor(Sensor):
""" """
@ -86,12 +86,13 @@ if 'raw_sensor' not in Base.metadata:
self.is_binary = False self.is_binary = False
self.is_json = False self.is_json = False
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }
if 'numeric_sensor' not in Base.metadata and 'percent_sensor' not in Base.metadata: if not is_defined('numeric_sensor') and not is_defined('percent_sensor'):
class NumericSensor(Sensor): class NumericSensor(Sensor):
""" """
@ -109,6 +110,7 @@ if 'numeric_sensor' not in Base.metadata and 'percent_sensor' not in Base.metada
max = Column(Float) max = Column(Float)
unit = Column(String) unit = Column(String)
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }
@ -124,6 +126,7 @@ if 'numeric_sensor' not in Base.metadata and 'percent_sensor' not in Base.metada
Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True
) )
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }
@ -135,7 +138,7 @@ if 'numeric_sensor' not in Base.metadata and 'percent_sensor' not in Base.metada
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if 'binary_sensor' not in Base.metadata: if not is_defined('binary_sensor'):
class BinarySensor(Sensor): class BinarySensor(Sensor):
""" """
@ -163,12 +166,13 @@ if 'binary_sensor' not in Base.metadata:
) )
value = Column(Boolean) value = Column(Boolean)
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }
if 'enum_sensor' not in Base.metadata: if not is_defined('enum_sensor'):
class EnumSensor(Sensor): class EnumSensor(Sensor):
""" """
@ -184,12 +188,13 @@ if 'enum_sensor' not in Base.metadata:
values = Column(JSON) values = Column(JSON)
""" Possible values for the sensor, as a JSON array. """ """ Possible values for the sensor, as a JSON array. """
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }
if 'composite_sensor' not in Base.metadata: if not is_defined('composite_sensor'):
class CompositeSensor(Sensor): class CompositeSensor(Sensor):
""" """
@ -204,6 +209,7 @@ if 'composite_sensor' not in Base.metadata:
) )
value = Column(JSON) value = Column(JSON)
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }

View File

@ -1,11 +1,11 @@
from sqlalchemy import Column, Integer, ForeignKey from sqlalchemy import Column, Integer, ForeignKey
from platypush.common.db import Base from platypush.common.db import is_defined
from .sensors import NumericSensor from .sensors import NumericSensor
if 'steps_sensor' not in Base.metadata: if not is_defined('steps_sensor'):
class StepsSensor(NumericSensor): class StepsSensor(NumericSensor):
""" """
@ -18,6 +18,7 @@ if 'steps_sensor' not in Base.metadata:
Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True
) )
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }

View File

@ -1,11 +1,11 @@
from sqlalchemy import Column, Integer, ForeignKey, Boolean, String, JSON from sqlalchemy import Column, Integer, ForeignKey, Boolean, String, JSON
from platypush.common.db import Base from platypush.common.db import is_defined
from .devices import Device from .devices import Device
if 'switch' not in Base.metadata: if not is_defined('switch'):
class Switch(Device): class Switch(Device):
__tablename__ = 'switch' __tablename__ = 'switch'
@ -15,12 +15,13 @@ if 'switch' not in Base.metadata:
) )
state = Column(Boolean) state = Column(Boolean)
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }
if 'enum_switch' not in Base.metadata: if not is_defined('enum_switch'):
class EnumSwitch(Device): class EnumSwitch(Device):
__tablename__ = 'enum_switch' __tablename__ = 'enum_switch'
@ -31,6 +32,7 @@ if 'enum_switch' not in Base.metadata:
value = Column(String) value = Column(String)
values = Column(JSON) values = Column(JSON)
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }

View File

@ -1,6 +1,6 @@
from sqlalchemy import Boolean, Column, Float, ForeignKey, Integer, JSON, String from sqlalchemy import Boolean, Column, Float, ForeignKey, Integer, JSON, String
from platypush.common.db import Base from platypush.common.db import is_defined
from . import Entity from . import Entity
from .devices import Device from .devices import Device
@ -8,7 +8,7 @@ from .sensors import NumericSensor, PercentSensor
from .temperature import TemperatureSensor from .temperature import TemperatureSensor
if 'cpu' not in Base.metadata: if not is_defined('cpu'):
class Cpu(Entity): class Cpu(Entity):
""" """
@ -23,12 +23,13 @@ if 'cpu' not in Base.metadata:
percent = Column(Float) percent = Column(Float)
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }
if 'cpu_info' not in Base.metadata: if not is_defined('cpu_info'):
class CpuInfo(Entity): class CpuInfo(Entity):
""" """
@ -54,12 +55,13 @@ if 'cpu_info' not in Base.metadata:
l2_cache_size = Column(Integer) l2_cache_size = Column(Integer)
l3_cache_size = Column(Integer) l3_cache_size = Column(Integer)
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }
if 'cpu_times' not in Base.metadata: if not is_defined('cpu_times'):
class CpuTimes(Entity): class CpuTimes(Entity):
""" """
@ -72,12 +74,13 @@ if 'cpu_times' not in Base.metadata:
Integer, ForeignKey(Entity.id, ondelete='CASCADE'), primary_key=True Integer, ForeignKey(Entity.id, ondelete='CASCADE'), primary_key=True
) )
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }
if 'cpu_stats' not in Base.metadata: if not is_defined('cpu_stats'):
class CpuStats(Entity): class CpuStats(Entity):
""" """
@ -90,12 +93,13 @@ if 'cpu_stats' not in Base.metadata:
Integer, ForeignKey(Entity.id, ondelete='CASCADE'), primary_key=True Integer, ForeignKey(Entity.id, ondelete='CASCADE'), primary_key=True
) )
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }
if 'memory_stats' not in Base.metadata: if not is_defined('memory_stats'):
class MemoryStats(Entity): class MemoryStats(Entity):
""" """
@ -119,12 +123,13 @@ if 'memory_stats' not in Base.metadata:
shared = Column(Integer) shared = Column(Integer)
percent = Column(Float) percent = Column(Float)
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }
if 'swap_stats' not in Base.metadata: if not is_defined('swap_stats'):
class SwapStats(Entity): class SwapStats(Entity):
""" """
@ -142,12 +147,13 @@ if 'swap_stats' not in Base.metadata:
free = Column(Integer) free = Column(Integer)
percent = Column(Float) percent = Column(Float)
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }
if 'disk' not in Base.metadata: if not is_defined('disk'):
class Disk(Entity): class Disk(Entity):
""" """
@ -175,12 +181,13 @@ if 'disk' not in Base.metadata:
write_time = Column(Float) write_time = Column(Float)
busy_time = Column(Float) busy_time = Column(Float)
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }
if 'network_interface' not in Base.metadata: if not is_defined('network_interface'):
class NetworkInterface(Device): class NetworkInterface(Device):
""" """
@ -207,12 +214,13 @@ if 'network_interface' not in Base.metadata:
duplex = Column(String) duplex = Column(String)
flags = Column(JSON) flags = Column(JSON)
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }
if 'system_temperature' not in Base.metadata: if not is_defined('system_temperature'):
class SystemTemperature(TemperatureSensor): class SystemTemperature(TemperatureSensor):
""" """
@ -230,12 +238,13 @@ if 'system_temperature' not in Base.metadata:
high = Column(Float) high = Column(Float)
critical = Column(Float) critical = Column(Float)
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }
if 'system_fan' not in Base.metadata: if not is_defined('system_fan'):
class SystemFan(NumericSensor): class SystemFan(NumericSensor):
""" """
@ -250,12 +259,13 @@ if 'system_fan' not in Base.metadata:
primary_key=True, primary_key=True,
) )
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }
if 'system_battery' not in Base.metadata: if not is_defined('system_battery'):
class SystemBattery(PercentSensor): class SystemBattery(PercentSensor):
""" """
@ -273,6 +283,7 @@ if 'system_battery' not in Base.metadata:
seconds_left = Column(Float) seconds_left = Column(Float)
power_plugged = Column(Boolean) power_plugged = Column(Boolean)
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }

View File

@ -1,11 +1,11 @@
from sqlalchemy import Column, Integer, ForeignKey from sqlalchemy import Column, Integer, ForeignKey
from platypush.common.db import Base from platypush.common.db import is_defined
from .sensors import NumericSensor from .sensors import NumericSensor
if 'temperature_sensor' not in Base.metadata: if not is_defined('temperature_sensor'):
class TemperatureSensor(NumericSensor): class TemperatureSensor(NumericSensor):
__tablename__ = 'temperature_sensor' __tablename__ = 'temperature_sensor'
@ -14,6 +14,7 @@ if 'temperature_sensor' not in Base.metadata:
Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True
) )
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }

View File

@ -1,13 +1,13 @@
from typing import Iterable, Mapping, Optional, Union from typing import Iterable, Mapping, Optional, Union
from sqlalchemy import Column, Integer, ForeignKey from sqlalchemy import Column, Integer, ForeignKey
from platypush.common.db import Base from platypush.common.db import is_defined
from platypush.common.sensors import Numeric from platypush.common.sensors import Numeric
from .sensors import RawSensor from .sensors import RawSensor
if 'three_axis_sensor' not in Base.metadata: if not is_defined('three_axis_sensor'):
class ThreeAxisSensor(RawSensor): class ThreeAxisSensor(RawSensor):
""" """
@ -20,6 +20,7 @@ if 'three_axis_sensor' not in Base.metadata:
Integer, ForeignKey(RawSensor.id, ondelete='CASCADE'), primary_key=True Integer, ForeignKey(RawSensor.id, ondelete='CASCADE'), primary_key=True
) )
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }

View File

@ -1,11 +1,11 @@
from sqlalchemy import Column, Integer, ForeignKey from sqlalchemy import Column, Integer, ForeignKey
from platypush.common.db import Base from platypush.common.db import is_defined
from .sensors import NumericSensor from .sensors import NumericSensor
if 'time_duration' not in Base.metadata: if not is_defined('time_duration'):
class TimeDuration(NumericSensor): class TimeDuration(NumericSensor):
""" """
@ -18,6 +18,7 @@ if 'time_duration' not in Base.metadata:
Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True
) )
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }

View File

@ -2,14 +2,14 @@ import logging
from sqlalchemy import Column, ForeignKey, Integer, String from sqlalchemy import Column, ForeignKey, Integer, String
from platypush.common.db import Base from platypush.common.db import is_defined
from . import Entity from . import Entity
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
if 'variable' not in Base.metadata: if not is_defined('variable'):
class Variable(Entity): class Variable(Entity):
""" """

View File

@ -1,11 +1,11 @@
from sqlalchemy import Column, Integer, ForeignKey from sqlalchemy import Column, Integer, ForeignKey
from platypush.common.db import Base from platypush.common.db import is_defined
from .sensors import NumericSensor from .sensors import NumericSensor
if 'weight_sensor' not in Base.metadata: if not is_defined('weight_sensor'):
class WeightSensor(NumericSensor): class WeightSensor(NumericSensor):
""" """
@ -18,6 +18,7 @@ if 'weight_sensor' not in Base.metadata:
Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True
) )
__table_args__ = {'extend_existing': True}
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, 'polymorphic_identity': __tablename__,
} }

View File

@ -16,8 +16,10 @@ EXPOSE 8008
VOLUME /etc/platypush VOLUME /etc/platypush
VOLUME /var/lib/platypush VOLUME /var/lib/platypush
VOLUME /var/cache/platypush
CMD platypush \ CMD platypush \
--start-redis \ --start-redis \
--config /etc/platypush/config.yaml \ --config /etc/platypush/config.yaml \
--workdir /var/lib/platypush --workdir /var/lib/platypush \
--cachedir /var/cache/platypush

View File

@ -20,8 +20,10 @@ EXPOSE 8008
VOLUME /etc/platypush VOLUME /etc/platypush
VOLUME /var/lib/platypush VOLUME /var/lib/platypush
VOLUME /var/cache/platypush
CMD platypush \ CMD platypush \
--start-redis \ --start-redis \
--config /etc/platypush/config.yaml \ --config /etc/platypush/config.yaml \
--workdir /var/lib/platypush --workdir /var/lib/platypush \
--cachedir /var/cache/platypush

View File

@ -19,8 +19,10 @@ EXPOSE 8008
VOLUME /etc/platypush VOLUME /etc/platypush
VOLUME /var/lib/platypush VOLUME /var/lib/platypush
VOLUME /var/cache/platypush
CMD platypush \ CMD platypush \
--start-redis \ --start-redis \
--config /etc/platypush/config.yaml \ --config /etc/platypush/config.yaml \
--workdir /var/lib/platypush --workdir /var/lib/platypush \
--cachedir /var/cache/platypush

View File

@ -20,8 +20,10 @@ EXPOSE 8008
VOLUME /etc/platypush VOLUME /etc/platypush
VOLUME /var/lib/platypush VOLUME /var/lib/platypush
VOLUME /var/cache/platypush
CMD platypush \ CMD platypush \
--start-redis \ --start-redis \
--config /etc/platypush/config.yaml \ --config /etc/platypush/config.yaml \
--workdir /var/lib/platypush --workdir /var/lib/platypush \
--cachedir /var/cache/platypush

View File

@ -34,21 +34,21 @@ class Message:
def parse_numpy(obj): def parse_numpy(obj):
try: try:
import numpy as np import numpy as np
except ImportError:
return
if isinstance(obj, np.floating): if isinstance(obj, np.floating):
return float(obj) return float(obj)
if isinstance(obj, np.integer): if isinstance(obj, np.integer):
return int(obj) return int(obj)
if isinstance(obj, np.ndarray): if isinstance(obj, np.ndarray):
return obj.tolist() return obj.tolist()
if isinstance(obj, decimal.Decimal): if isinstance(obj, decimal.Decimal):
return float(obj) return float(obj)
if isinstance(obj, (bytes, bytearray)): if isinstance(obj, (bytes, bytearray)):
return '0x' + ''.join([f'{x:02x}' for x in obj]) return '0x' + ''.join([f'{x:02x}' for x in obj])
if callable(obj): if callable(obj):
return '<function at {}.{}>'.format(obj.__module__, obj.__name__) return '<function at {}.{}>'.format(obj.__module__, obj.__name__)
except (ImportError, TypeError):
pass
return return

View File

@ -1,5 +1,5 @@
from datetime import datetime from datetime import datetime
from typing import Dict, Any from typing import Any, Dict, Optional
from platypush.message.event import Event from platypush.message.event import Event
@ -13,13 +13,13 @@ class MatrixEvent(Event):
self, self,
*args, *args,
server_url: str, server_url: str,
sender_id: str | None = None, sender_id: Optional[str] = None,
sender_display_name: str | None = None, sender_display_name: Optional[str] = None,
sender_avatar_url: str | None = None, sender_avatar_url: Optional[str] = None,
room_id: str | None = None, room_id: Optional[str] = None,
room_name: str | None = None, room_name: Optional[str] = None,
room_topic: str | None = None, room_topic: Optional[str] = None,
server_timestamp: datetime | None = None, server_timestamp: Optional[datetime] = None,
**kwargs **kwargs
): ):
""" """
@ -70,11 +70,11 @@ class MatrixMessageEvent(MatrixEvent):
self, self,
*args, *args,
body: str = '', body: str = '',
url: str | None = None, url: Optional[str] = None,
thumbnail_url: str | None = None, thumbnail_url: Optional[str] = None,
mimetype: str | None = None, mimetype: Optional[str] = None,
formatted_body: str | None = None, formatted_body: Optional[str] = None,
format: str | None = None, format: Optional[str] = None,
**kwargs **kwargs
): ):
""" """
@ -148,7 +148,7 @@ class MatrixCallEvent(MatrixEvent):
""" """
def __init__( def __init__(
self, *args, call_id: str, version: int, sdp: str | None = None, **kwargs self, *args, call_id: str, version: int, sdp: Optional[str] = None, **kwargs
): ):
""" """
:param call_id: The unique ID of the call. :param call_id: The unique ID of the call.
@ -163,7 +163,7 @@ class MatrixCallInviteEvent(MatrixCallEvent):
Event triggered when the user is invited to a call. Event triggered when the user is invited to a call.
""" """
def __init__(self, *args, invite_validity: float | None = None, **kwargs): def __init__(self, *args, invite_validity: Optional[float] = None, **kwargs):
""" """
:param invite_validity: For how long the invite will be valid, in seconds. :param invite_validity: For how long the invite will be valid, in seconds.
:param sdp: SDP text of the session description. :param sdp: SDP text of the session description.
@ -242,7 +242,9 @@ class MatrixUserPresenceEvent(MatrixEvent):
Event triggered when a user comes online or goes offline. Event triggered when a user comes online or goes offline.
""" """
def __init__(self, *args, is_active: bool, last_active: datetime | None, **kwargs): def __init__(
self, *args, is_active: bool, last_active: Optional[datetime], **kwargs
):
""" """
:param is_active: True if the user is currently online. :param is_active: True if the user is currently online.
:param topic: When the user was last active. :param topic: When the user was last active.

View File

@ -73,7 +73,7 @@ class CalendarIcalPlugin(Plugin, CalendarInterface):
def get_upcoming_events(self, *_, only_participating=True, **__): def get_upcoming_events(self, *_, only_participating=True, **__):
""" """
Get the upcoming events. See Get the upcoming events. See
:func:`~platypush.plugins.calendar.CalendarPlugin.get_upcoming_events`. :meth:`platypush.plugins.calendar.CalendarPlugin.get_upcoming_events`.
""" """
from icalendar import Calendar from icalendar import Calendar

View File

@ -49,7 +49,7 @@ class GoogleCalendarPlugin(GooglePlugin, CalendarInterface):
def get_upcoming_events(self, max_results=10): def get_upcoming_events(self, max_results=10):
""" """
Get the upcoming events. See Get the upcoming events. See
:func:`~platypush.plugins.calendar.CalendarPlugin.get_upcoming_events`. :meth:`platypush.plugins.calendar.CalendarPlugin.get_upcoming_events`.
""" """
now = datetime.datetime.utcnow().isoformat() + 'Z' now = datetime.datetime.utcnow().isoformat() + 'Z'

View File

@ -1,36 +1,24 @@
from collections import defaultdict
import importlib import importlib
import inspect import inspect
import json import json
import os import os
import pathlib import pathlib
import pickle
import pkgutil import pkgutil
from types import ModuleType from concurrent.futures import Future, ThreadPoolExecutor
from typing import Callable, Dict, Generator, Optional, Type, Union from typing import List, Optional
from platypush.backend import Backend from platypush.backend import Backend
from platypush.common.db import override_definitions
from platypush.common.reflection import Integration, Message as MessageMetadata
from platypush.config import Config from platypush.config import Config
from platypush.plugins import Plugin, action from platypush.plugins import Plugin, action
from platypush.message import Message from platypush.message import Message
from platypush.message.event import Event from platypush.message.event import Event
from platypush.message.response import Response from platypush.message.response import Response
from platypush.utils import ( from platypush.utils.mock import auto_mocks
get_backend_class_by_name, from platypush.utils.manifest import Manifest, Manifests
get_backend_name_by_class,
get_plugin_class_by_name,
get_plugin_name_by_class,
)
from platypush.utils.manifest import Manifests
from ._context import ComponentContext from ._cache import Cache
from ._model import (
BackendModel,
EventModel,
Model,
PluginModel,
ResponseModel,
)
from ._serialize import ProcedureEncoder from ._serialize import ProcedureEncoder
@ -39,297 +27,211 @@ class InspectPlugin(Plugin):
This plugin can be used to inspect platypush plugins and backends This plugin can be used to inspect platypush plugins and backends
""" """
_num_workers = 8
"""Number of threads to use for the inspection."""
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self._components_cache_file = os.path.join( self._cache_file = os.path.join(Config.get_cachedir(), 'components.json')
Config.get('workdir'), # type: ignore self._cache = Cache()
'components.cache', # type: ignore self._load_cache()
)
self._components_context: Dict[type, ComponentContext] = defaultdict(
ComponentContext
)
self._components_cache: Dict[type, dict] = defaultdict(dict)
self._load_components_cache()
def _load_components_cache(self): def _load_cache(self):
""" """
Loads the components cache from disk. Loads the components cache from disk.
""" """
try: with self._cache.lock(), auto_mocks(), override_definitions():
with open(self._components_cache_file, 'rb') as f:
self._components_cache = pickle.load(f)
except Exception as e:
self.logger.warning('Could not initialize the components cache: %s', e)
self.logger.info(
'The plugin will initialize the cache by scanning '
'the integrations at the next run. This may take a while'
)
def _flush_components_cache(self):
"""
Flush the current components cache to disk.
"""
with open(self._components_cache_file, 'wb') as f:
pickle.dump(self._components_cache, f)
def _get_cached_component(
self, base_type: type, comp_type: type
) -> Optional[Model]:
"""
Retrieve a cached component's ``Model``.
:param base_type: The base type of the component (e.g. ``Plugin`` or
``Backend``).
:param comp_type: The specific type of the component (e.g.
``MusicMpdPlugin`` or ``HttpBackend``).
:return: The cached component's ``Model`` if it exists, otherwise null.
"""
return self._components_cache.get(base_type, {}).get(comp_type)
def _cache_component(
self,
base_type: type,
comp_type: type,
model: Model,
index_by_module: bool = False,
):
"""
Cache the ``Model`` object for a component.
:param base_type: The base type of the component (e.g. ``Plugin`` or
``Backend``).
:param comp_type: The specific type of the component (e.g.
``MusicMpdPlugin`` or ``HttpBackend``).
:param model: The ``Model`` object to cache.
:param index_by_module: If ``True``, the ``Model`` object will be
indexed according to the ``base_type -> module -> comp_type``
mapping, otherwise ``base_type -> comp_type``.
"""
if index_by_module:
if not self._components_cache.get(base_type, {}).get(model.package):
self._components_cache[base_type][model.package] = {}
self._components_cache[base_type][model.package][comp_type] = model
else:
self._components_cache[base_type][comp_type] = model
def _scan_integrations(self, base_type: type):
"""
A generator that scans the manifest files given a ``base_type``
(``Plugin`` or ``Backend``) and yields the parsed submodules.
"""
for manifest in Manifests.by_base_class(base_type):
try: try:
yield importlib.import_module(manifest.package) self._cache = Cache.load(self._cache_file)
except Exception as e: except Exception as e:
self.logger.debug( self.logger.warning(
'Could not import module %s: %s', 'Could not initialize the components cache from %s: %s',
manifest.package, self._cache_file,
e, e,
) )
continue self._cache = Cache()
def _scan_modules(self, base_type: type) -> Generator[ModuleType, None, None]: self._refresh_cache()
def _refresh_cache(self):
""" """
A generator that scan the modules given a ``base_type`` (e.g. ``Event``). Refreshes the components cache.
"""
cache_version_differs = self._cache.version != Cache.cur_version
Unlike :meth:`._scan_integrations`, this method recursively scans the with ThreadPoolExecutor(self._num_workers) as pool:
modules using ``pkgutil`` instead of using the information provided in futures = []
the integrations' manifest files.
for base_type in [Plugin, Backend]:
futures.append(
pool.submit(
self._scan_integrations,
base_type,
pool=pool,
force_refresh=cache_version_differs,
futures=futures,
)
)
for base_type in [Event, Response]:
futures.append(
pool.submit(
self._scan_modules,
base_type,
pool=pool,
force_refresh=cache_version_differs,
futures=futures,
)
)
while futures:
futures.pop().result()
if self._cache.has_changes:
self.logger.info('Saving new components cache to %s', self._cache_file)
self._cache.dump(self._cache_file)
self._cache.loaded_at = self._cache.saved_at
def _scan_integration(self, manifest: Manifest):
"""
Scans a single integration from the manifest and adds it to the cache.
"""
try:
self._cache_integration(Integration.from_manifest(manifest.file))
except Exception as e:
self.logger.warning(
'Could not import module %s: %s',
manifest.package,
e,
)
def _scan_integrations(
self,
base_type: type,
pool: ThreadPoolExecutor,
futures: List[Future],
force_refresh: bool = False,
):
"""
Scans the integrations with a manifest file (plugins and backends) and
refreshes the cache.
"""
for manifest in Manifests.by_base_class(base_type):
# An integration metadata needs to be refreshed if it's been
# modified since it was last loaded, or if it's not in the
# cache.
if force_refresh or self._needs_refresh(manifest.file):
futures.append(pool.submit(self._scan_integration, manifest))
def _scan_module(self, base_type: type, modname: str):
"""
Scans a single module for objects that match the given base_type and
adds them to the cache.
"""
try:
module = importlib.import_module(modname)
except Exception as e:
self.logger.warning('Could not import module %s: %s', modname, e)
return
for _, obj_type in inspect.getmembers(module):
if (
inspect.isclass(obj_type)
and issubclass(obj_type, base_type)
# Exclude the base_type itself
and obj_type != base_type
):
self.logger.info(
'Scanned %s: %s',
base_type.__name__,
f'{module.__name__}.{obj_type.__name__}',
)
self._cache.set(
base_type, obj_type, MessageMetadata.by_type(obj_type).to_dict()
)
def _scan_modules(
self,
base_type: type,
pool: ThreadPoolExecutor,
futures: List[Future],
force_refresh: bool = False,
):
"""
A generator that scans the modules given a ``base_type`` (e.g. ``Event``).
It's a bit more inefficient than :meth:`._scan_integrations` because it
needs to inspect all the members of a module to find the ones that
match the given ``base_type``, but it works fine for simple components
(like messages) that don't require extra recursive parsing and don't
have a manifest.
""" """
prefix = base_type.__module__ + '.' prefix = base_type.__module__ + '.'
path = str(pathlib.Path(inspect.getfile(base_type)).parent) path = str(pathlib.Path(inspect.getfile(base_type)).parent)
for _, modname, _ in pkgutil.walk_packages( for _, modname, __ in pkgutil.walk_packages(
path=[path], prefix=prefix, onerror=lambda _: None path=[path], prefix=prefix, onerror=lambda _: None
): ):
try: try:
yield importlib.import_module(modname) filename = self._module_filename(path, '.'.join(modname.split('.')[3:]))
if not (force_refresh or self._needs_refresh(filename)):
continue
except Exception as e: except Exception as e:
self.logger.debug('Could not import module %s: %s', modname, e) self.logger.warning('Could not scan module %s: %s', modname, e)
continue continue
def _init_component( futures.append(pool.submit(self._scan_module, base_type, modname))
self,
base_type: type, def _needs_refresh(self, filename: str) -> bool:
comp_type: type,
model_type: Type[Model],
index_by_module: bool = False,
) -> Model:
""" """
Initialize a component's ``Model`` object and cache it. :return: True if the given file needs to be refreshed in the cache.
:param base_type: The base type of the component (e.g. ``Plugin`` or
``Backend``).
:param comp_type: The specific type of the component (e.g.
``MusicMpdPlugin`` or ``HttpBackend``).
:param model_type: The type of the ``Model`` object that should be
created.
:param index_by_module: If ``True``, the ``Model`` object will be
indexed according to the ``base_type -> module -> comp_type``
mapping, otherwise ``base_type -> comp_type``.
:return: The initialized component's ``Model`` object.
""" """
prefix = base_type.__module__ + '.' return os.lstat(os.path.dirname(filename)).st_mtime > (
comp_file = inspect.getsourcefile(comp_type) self._cache.saved_at or 0
model = None
mtime = None
if comp_file:
mtime = os.stat(comp_file).st_mtime
cached_model = self._get_cached_component(base_type, comp_type)
# Only update the component model if its source file was
# modified since the last time it was scanned
if (
cached_model
and cached_model.last_modified
and mtime <= cached_model.last_modified
):
model = cached_model
if not model:
self.logger.info('Scanning component %s', comp_type.__name__)
model = model_type(comp_type, prefix=prefix, last_modified=mtime)
self._cache_component(
base_type, comp_type, model, index_by_module=index_by_module
)
return model
def _init_modules(
self,
base_type: type,
model_type: Type[Model],
):
"""
Initializes, parses and caches all the components of a given type.
Unlike :meth:`._scan_integrations`, this method inspects all the
members of a ``module`` for those that match the given ``base_type``
instead of relying on the information provided in the manifest.
It is a bit more inefficient, but it works fine for simple components
(like entities and messages) that don't require extra recursive parsing
logic for their docs (unlike plugins).
"""
for module in self._scan_modules(base_type):
for _, obj_type in inspect.getmembers(module):
if (
inspect.isclass(obj_type)
and issubclass(obj_type, base_type)
# Exclude the base_type itself
and obj_type != base_type
):
self._init_component(
base_type=base_type,
comp_type=obj_type,
model_type=model_type,
index_by_module=True,
)
def _init_integrations(
self,
base_type: Type[Union[Plugin, Backend]],
model_type: Type[Union[PluginModel, BackendModel]],
class_by_name: Callable[[str], Optional[type]],
):
"""
Initializes, parses and caches all the integrations of a given type.
:param base_type: The base type of the component (e.g. ``Plugin`` or
``Backend``).
:param model_type: The type of the ``Model`` objects that should be
created.
:param class_by_name: A function that returns the class of a given
integration given its qualified name.
"""
for module in self._scan_integrations(base_type):
comp_name = '.'.join(module.__name__.split('.')[2:])
comp_type = class_by_name(comp_name)
if not comp_type:
continue
self._init_component(
base_type=base_type,
comp_type=comp_type,
model_type=model_type,
)
self._flush_components_cache()
def _init_plugins(self):
"""
Initializes and caches all the available plugins.
"""
self._init_integrations(
base_type=Plugin,
model_type=PluginModel,
class_by_name=get_plugin_class_by_name,
) )
def _init_backends(self): @staticmethod
def _module_filename(path: str, modname: str) -> str:
""" """
Initializes and caches all the available backends. :param path: Path to the module.
:param modname: Module name.
:return: The full path to the module file.
""" """
self._init_integrations( filename = os.path.join(path, *modname.split('.')) + '.py'
base_type=Backend,
model_type=BackendModel,
class_by_name=get_backend_class_by_name,
)
def _init_events(self): if not os.path.isfile(filename):
""" filename = os.path.join(path, *modname.split('.'), '__init__.py')
Initializes and caches all the available events.
"""
self._init_modules(
base_type=Event,
model_type=EventModel,
)
def _init_responses(self): assert os.path.isfile(filename), f'No such file or directory: {filename}'
""" return filename
Initializes and caches all the available responses.
"""
self._init_modules(
base_type=Response,
model_type=ResponseModel,
)
def _init_components(self, base_type: type, initializer: Callable[[], None]): def _cache_integration(self, integration: Integration) -> dict:
""" """
Context manager boilerplate for the other ``_init_*`` methods. :param integration: The :class:`.IntegrationMetadata` object.
:return: The initialized component's metadata dict.
""" """
ctx = self._components_context[base_type] self.logger.info(
with ctx.init_lock: 'Scanned %s: %s', integration.base_type.__name__, integration.name
if not ctx.refreshed.is_set(): )
initializer() meta = integration.to_dict()
ctx.refreshed.set() self._cache.set(integration.base_type, integration.type, meta)
return meta
@action @action
def get_all_plugins(self): def get_all_plugins(self):
""" """
Get information about all the available plugins. Get information about all the available plugins.
""" """
self._init_components(Plugin, self._init_plugins) return json.dumps(self._cache.to_dict().get('plugins', {}), cls=Message.Encoder)
return json.dumps(
{
get_plugin_name_by_class(cls): dict(plugin)
for cls, plugin in self._components_cache.get(Plugin, {}).items()
},
cls=Message.Encoder,
)
@action @action
def get_all_backends(self): def get_all_backends(self):
""" """
Get information about all the available backends. Get information about all the available backends.
""" """
self._init_components(Backend, self._init_backends)
return json.dumps( return json.dumps(
{ self._cache.to_dict().get('backends', {}), cls=Message.Encoder
get_backend_name_by_class(cls): dict(backend)
for cls, backend in self._components_cache.get(Backend, {}).items()
}
) )
@action @action
@ -337,33 +239,15 @@ class InspectPlugin(Plugin):
""" """
Get information about all the available events. Get information about all the available events.
""" """
self._init_components(Event, self._init_events) return json.dumps(self._cache.to_dict().get('events', {}), cls=Message.Encoder)
return json.dumps(
{
package: {
obj_type.__name__: dict(event_model)
for obj_type, event_model in events.items()
}
for package, events in self._components_cache.get(Event, {}).items()
}
)
@action @action
def get_all_responses(self): def get_all_responses(self):
""" """
Get information about all the available responses. Get information about all the available responses.
""" """
self._init_components(Response, self._init_responses)
return json.dumps( return json.dumps(
{ self._cache.to_dict().get('responses', {}), cls=Message.Encoder
package: {
obj_type.__name__: dict(response_model)
for obj_type, response_model in responses.items()
}
for package, responses in self._components_cache.get(
Response, {}
).items()
}
) )
@action @action

View File

@ -0,0 +1,248 @@
from contextlib import contextmanager
import json
import logging
from collections import defaultdict
from time import time
from threading import RLock
from typing import Dict, Optional
from platypush.backend import Backend
from platypush.message.event import Event
from platypush.message.response import Response
from platypush.plugins import Plugin
from platypush.utils import (
get_backend_class_by_name,
get_backend_name_by_class,
get_plugin_class_by_name,
get_plugin_name_by_class,
)
logger = logging.getLogger(__name__)
class Cache:
"""
A cache for the parsed integration metadata.
Cache structure:
.. code-block:: python
{
<integration_category>: {
<integration_type>: {
'doc': <integration_docstring>,
'args': {
<arg_name>: {
'name': <arg_name>,
'type': <arg_type>,
'doc': <arg_docstring>,
'default': <arg_default_value>,
'required': <arg_required>,
},
...
},
'actions': {
<action_name>: {
'name': <action_name>,
'doc': <action_docstring>,
'args': {
...
},
'returns': {
'type': <return_type>,
'doc': <return_docstring>,
},
},
...
},
'events': [
<event_type1>,
<event_type2>,
...
],
},
...
},
...
}
"""
cur_version = 1
"""
Cache version, used to detect breaking changes in the cache logic that require a cache refresh.
"""
def __init__(
self,
items: Optional[Dict[type, Dict[type, dict]]] = None,
saved_at: Optional[float] = None,
loaded_at: Optional[float] = None,
version: int = cur_version,
):
self.saved_at = saved_at
self.loaded_at = loaded_at
self._cache: Dict[type, Dict[type, dict]] = defaultdict(dict)
self._lock = RLock()
self.version = version
self.has_changes = False
if items:
self._cache.update(items)
self.loaded_at = time()
@classmethod
def load(cls, cache_file: str) -> 'Cache':
"""
Loads the components cache from disk.
:param cache_file: Cache file path.
"""
with open(cache_file, 'r') as f:
data = json.load(f)
return cls.from_dict(data)
def dump(self, cache_file: str):
"""
Dumps the components cache to disk.
:param cache_file: Cache file path.
"""
from platypush.message import Message
self.version = self.cur_version
with open(cache_file, 'w') as f:
self.saved_at = time()
json.dump(
{
'saved_at': self.saved_at,
'version': self.version,
'items': self.to_dict(),
},
f,
cls=Message.Encoder,
)
self.has_changes = False
@classmethod
def from_dict(cls, data: dict) -> 'Cache':
"""
Creates a cache from a JSON-serializable dictionary.
"""
return cls(
items={
Backend: {
k: v
for k, v in {
get_backend_class_by_name(backend_type): backend_meta
for backend_type, backend_meta in data.get('items', {})
.get('backends', {})
.items()
}.items()
if k
},
Plugin: {
k: v
for k, v in {
get_plugin_class_by_name(plugin_type): plugin_meta
for plugin_type, plugin_meta in data.get('items', {})
.get('plugins', {})
.items()
}.items()
if k
},
Event: data.get('items', {}).get('events', {}),
Response: data.get('items', {}).get('responses', {}),
},
loaded_at=time(),
saved_at=data.get('saved_at'),
version=data.get('version', cls.cur_version),
)
def to_dict(self) -> Dict[str, Dict[str, dict]]:
"""
Converts the cache items to a JSON-serializable dictionary.
"""
return {
'backends': {
k: v
for k, v in {
get_backend_name_by_class(backend_type): backend_meta
for backend_type, backend_meta in self.backends.items()
}.items()
if k
},
'plugins': {
k: v
for k, v in {
get_plugin_name_by_class(plugin_type): plugin_meta
for plugin_type, plugin_meta in self.plugins.items()
}.items()
if k
},
'events': {
(k if isinstance(k, str) else f'{k.__module__}.{k.__qualname__}'): v
for k, v in self.events.items()
if k
},
'responses': {
(k if isinstance(k, str) else f'{k.__module__}.{k.__qualname__}'): v
for k, v in self.responses.items()
if k
},
}
def get(self, category: type, obj_type: Optional[type] = None) -> Optional[dict]:
"""
Retrieves an object from the cache.
:param category: Category type.
:param obj_type: Object type.
:return: Object metadata.
"""
collection = self._cache[category]
if not obj_type:
return collection
return collection.get(obj_type)
def set(self, category: type, obj_type: type, value: dict):
"""
Set an object on the cache.
:param category: Category type.
:param obj_type: Object type.
:param value: Value to set.
"""
self._cache[category][obj_type] = value
self.has_changes = True
@property
def plugins(self) -> Dict[type, dict]:
"""Plugins metadata."""
return self._cache[Plugin]
@property
def backends(self) -> Dict[type, dict]:
"""Backends metadata."""
return self._cache[Backend]
@property
def events(self) -> Dict[type, dict]:
"""Events metadata."""
return self._cache[Event]
@property
def responses(self) -> Dict[type, dict]:
"""Responses metadata."""
return self._cache[Response]
@contextmanager
def lock(self):
"""
Context manager that acquires a lock on the cache.
"""
with self._lock:
yield

View File

@ -1,12 +0,0 @@
from dataclasses import dataclass, field
import threading
@dataclass
class ComponentContext:
"""
This class is used to store the context of a component type.
"""
init_lock: threading.RLock = field(default_factory=threading.RLock)
refreshed: threading.Event = field(default_factory=threading.Event)

View File

@ -1,262 +0,0 @@
import inspect
import json
import re
from typing import Callable, List, Optional, Type
from platypush.backend import Backend
from platypush.message.event import Event
from platypush.message.response import Response
from platypush.plugins import Plugin
from platypush.utils import get_decorators
from ._parsers import (
BackendParser,
EventParser,
MethodParser,
Parser,
PluginParser,
ResponseParser,
SchemaParser,
)
class Model:
"""
Base class for component models.
"""
_parsers: List[Type[Parser]] = [
BackendParser,
EventParser,
MethodParser,
PluginParser,
ResponseParser,
SchemaParser,
]
_param_docstring_re = re.compile(r'^\s*:param ([^:]+):\s*(.*)')
_type_docstring_re = re.compile(r'^\s*:type ([^:]+):\s*([^\s]+).*')
_return_docstring_re = re.compile(r'^\s*:return:\s+(.*)')
def __init__(
self,
obj_type: type,
name: Optional[str] = None,
doc: Optional[str] = None,
prefix: str = '',
last_modified: Optional[float] = None,
) -> None:
"""
:param obj_type: Type of the component.
:param name: Name of the component.
:param doc: Documentation of the component.
:param last_modified: Last modified timestamp of the component.
"""
self._obj_type = obj_type
self.package = obj_type.__module__[len(prefix) :]
self.name = name or self.package
self.last_modified = last_modified
docstring = doc or ''
if obj_type.__doc__:
docstring += '\n\n' + obj_type.__doc__
if hasattr(obj_type, '__init__'):
docstring += '\n\n' + (obj_type.__init__.__doc__ or '')
self.doc, argsdoc = self._parse_docstring(docstring, obj_type=obj_type)
self.args = {}
self.has_kwargs = False
self.has_varargs = False
for arg in list(inspect.signature(obj_type).parameters.values())[1:]:
if arg.kind == arg.VAR_KEYWORD:
self.has_kwargs = True
continue
if arg.kind == arg.VAR_POSITIONAL:
self.has_varargs = True
continue
self.args[arg.name] = {
'default': (
arg.default if not issubclass(arg.default.__class__, type) else None
),
'doc': argsdoc.get(arg.name, {}).get('name'),
'required': arg.default is inspect._empty,
'type': (
argsdoc.get(arg.name, {}).get('type')
or (
(
arg.annotation.__name__
if arg.annotation.__module__ == 'builtins'
else (
None
if arg.annotation is inspect._empty
else str(arg.annotation).replace('typing.', '')
)
)
if arg.annotation
else None
)
),
}
def __str__(self):
"""
:return: JSON string representation of the model.
"""
return json.dumps(dict(self), indent=2, sort_keys=True)
def __repr__(self):
"""
:return: JSON string representation of the model.
"""
return json.dumps(dict(self))
def __iter__(self):
"""
Iterator for the model public attributes/values pairs.
"""
for attr in ['name', 'args', 'doc', 'has_varargs', 'has_kwargs']:
yield attr, getattr(self, attr)
@classmethod
def _parse_docstring(cls, docstring: str, obj_type: type):
new_docstring = ''
params = {}
cur_param = None
cur_param_docstring = ''
param_types = {}
if not docstring:
return None, {}
for line in docstring.split('\n'):
m = cls._param_docstring_re.match(line)
if m:
if cur_param:
params[cur_param] = cur_param_docstring
cur_param = m.group(1)
cur_param_docstring = m.group(2)
continue
m = cls._type_docstring_re.match(line)
if m:
if cur_param:
param_types[cur_param] = m.group(2).strip()
params[cur_param] = cur_param_docstring
cur_param = None
continue
m = cls._return_docstring_re.match(line)
if m:
if cur_param:
params[cur_param] = cur_param_docstring
new_docstring += '\n\n**Returns:**\n\n' + m.group(1).strip() + ' '
cur_param = None
continue
if cur_param:
if not line.strip():
params[cur_param] = cur_param_docstring
cur_param = None
cur_param_docstring = ''
else:
cur_param_docstring += '\n' + line.strip() + ' '
else:
new_docstring += line + '\n'
if cur_param:
params[cur_param] = cur_param_docstring
for param, doc in params.items():
params[param] = {
'name': cls._post_process_docstring(doc, obj_type=obj_type)
}
param_type = param_types.pop(param, None)
if param_type is not None:
params[param]['type'] = param_type
return cls._post_process_docstring(new_docstring, obj_type=obj_type), params
@classmethod
def _post_process_docstring(cls, docstring: str, obj_type: type) -> str:
for parsers in cls._parsers:
docstring = parsers.parse(docstring, obj_type=obj_type)
return docstring.strip()
# pylint: disable=too-few-public-methods
class BackendModel(Model):
"""
Model for backend components.
"""
def __init__(self, obj_type: Type[Backend], *args, **kwargs):
super().__init__(obj_type, *args, **kwargs)
# pylint: disable=too-few-public-methods
class PluginModel(Model):
"""
Model for plugin components.
"""
def __init__(self, obj_type: Type[Plugin], prefix: str = '', **kwargs):
super().__init__(
obj_type,
name=re.sub(r'\._plugin$', '', obj_type.__module__[len(prefix) :]),
**kwargs,
)
self.actions = {
action_name: ActionModel(getattr(obj_type, action_name))
for action_name in get_decorators(obj_type, climb_class_hierarchy=True).get(
'action', []
)
}
def __iter__(self):
"""
Overrides the default implementation of ``__iter__`` to also include
plugin actions.
"""
for attr in ['name', 'args', 'actions', 'doc', 'has_varargs', 'has_kwargs']:
if attr == 'actions':
yield attr, {
name: dict(action) for name, action in self.actions.items()
}
else:
yield attr, getattr(self, attr)
class EventModel(Model):
"""
Model for event components.
"""
def __init__(self, obj_type: Type[Event], **kwargs):
super().__init__(obj_type, **kwargs)
class ResponseModel(Model):
"""
Model for response components.
"""
def __init__(self, obj_type: Type[Response], **kwargs):
super().__init__(obj_type, **kwargs)
class ActionModel(Model):
"""
Model for plugin action components.
"""
def __init__(self, obj_type: Type[Callable], *args, **kwargs):
super().__init__(obj_type, name=obj_type.__name__, *args, **kwargs)

View File

@ -1,18 +0,0 @@
from ._backend import BackendParser
from ._base import Parser
from ._event import EventParser
from ._method import MethodParser
from ._plugin import PluginParser
from ._response import ResponseParser
from ._schema import SchemaParser
__all__ = [
'BackendParser',
'EventParser',
'MethodParser',
'Parser',
'PluginParser',
'ResponseParser',
'SchemaParser',
]

View File

@ -1,32 +0,0 @@
import re
from ._base import Parser
class BackendParser(Parser):
"""
Parse backend references in the docstrings with rendered links to their
respective documentation.
"""
_backend_regex = re.compile(
r'(\s*):class:`(platypush\.backend\.(.+?))`', re.MULTILINE
)
@classmethod
def parse(cls, docstring: str, *_, **__) -> str:
while True:
m = cls._backend_regex.search(docstring)
if not m:
break
class_name = m.group(3).split('.')[-1]
package = '.'.join(m.group(3).split('.')[:-1])
docstring = cls._backend_regex.sub(
f'{m.group(1)}`{class_name} '
f'<https://docs.platypush.tech/platypush/backend/{package}.html#{m.group(2)}>`_',
docstring,
count=1,
)
return docstring

View File

@ -1,12 +0,0 @@
from abc import ABC, abstractmethod
class Parser(ABC):
"""
Base class for parsers.
"""
@classmethod
@abstractmethod
def parse(cls, docstring: str, obj_type: type) -> str:
raise NotImplementedError()

View File

@ -1,32 +0,0 @@
import re
from ._base import Parser
class EventParser(Parser):
"""
Parse event references in the docstrings with rendered links to their
respective documentation.
"""
_event_regex = re.compile(
r'(\s*):class:`(platypush\.message\.event\.(.+?))`', re.MULTILINE
)
@classmethod
def parse(cls, docstring: str, *_, **__) -> str:
while True:
m = cls._event_regex.search(docstring)
if not m:
break
class_name = m.group(3).split('.')[-1]
package = '.'.join(m.group(3).split('.')[:-1])
docstring = cls._event_regex.sub(
f'{m.group(1)}`{class_name} '
f'<https://docs.platypush.tech/platypush/events/{package}.html#{m.group(2)}>`_',
docstring,
count=1,
)
return docstring

View File

@ -1,60 +0,0 @@
import re
from ._base import Parser
class MethodParser(Parser):
"""
Parse method references in the docstrings with rendered links to their
respective documentation.
"""
_abs_method_regex = re.compile(
r'(\s*):meth:`(platypush\.plugins\.(.+?))`', re.MULTILINE
)
_rel_method_regex = re.compile(r'(\s*):meth:`\.(.+?)`', re.MULTILINE)
@classmethod
def parse(cls, docstring: str, obj_type: type) -> str:
while True:
m = cls._rel_method_regex.search(docstring)
if m:
tokens = m.group(2).split('.')
method = tokens[-1]
package = obj_type.__module__
rel_package = '.'.join(package.split('.')[2:])
full_name = '.'.join(
[
package,
'.'.join(obj_type.__qualname__.split('.')[:-1]),
method,
]
)
docstring = cls._rel_method_regex.sub(
f'{m.group(1)}`{package}.{method} '
f'<https://docs.platypush.tech/platypush/plugins/{rel_package}.html#{full_name}>`_',
docstring,
count=1,
)
continue
m = cls._abs_method_regex.search(docstring)
if m:
tokens = m.group(3).split('.')
method = tokens[-1]
package = '.'.join(tokens[:-2])
docstring = cls._abs_method_regex.sub(
f'{m.group(1)}`{package}.{method} '
f'<https://docs.platypush.tech/platypush/plugins/{package}.html#{m.group(2)}>`_',
docstring,
count=1,
)
continue
break
return docstring

View File

@ -1,32 +0,0 @@
import re
from ._base import Parser
class PluginParser(Parser):
"""
Parse plugin references in the docstrings with rendered links to their
respective documentation.
"""
_plugin_regex = re.compile(
r'(\s*):class:`(platypush\.plugins\.(.+?))`', re.MULTILINE
)
@classmethod
def parse(cls, docstring: str, *_, **__) -> str:
while True:
m = cls._plugin_regex.search(docstring)
if not m:
break
class_name = m.group(3).split('.')[-1]
package = '.'.join(m.group(3).split('.')[:-1])
docstring = cls._plugin_regex.sub(
f'{m.group(1)}`{class_name} '
f'<https://docs.platypush.tech/platypush/plugins/{package}.html#{m.group(2)}>`_',
docstring,
count=1,
)
return docstring

View File

@ -1,32 +0,0 @@
import re
from ._base import Parser
class ResponseParser(Parser):
"""
Parse response references in the docstrings with rendered links to their
respective documentation.
"""
_response_regex = re.compile(
r'(\s*):class:`(platypush\.message\.response\.(.+?))`', re.MULTILINE
)
@classmethod
def parse(cls, docstring: str, *_, **__) -> str:
while True:
m = cls._response_regex.search(docstring)
if not m:
break
class_name = m.group(3).split('.')[-1]
package = '.'.join(m.group(3).split('.')[:-1])
docstring = cls._response_regex.sub(
f'{m.group(1)}`{class_name} '
f'<https://docs.platypush.tech/platypush/responses/{package}.html#{m.group(2)}>`_',
docstring,
count=1,
)
return docstring

View File

@ -1,95 +0,0 @@
import importlib
import inspect
import json
import os
from random import randint
import re
import textwrap
from marshmallow import fields
import platypush.schemas
from ._base import Parser
class SchemaParser(Parser):
"""
Support for response/message schemas in the docs. Format: ``.. schema:: rel_path.SchemaClass(arg1=value1, ...)``,
where ``rel_path`` is the path of the schema relative to ``platypush/schemas``.
"""
_schemas_path = os.path.dirname(inspect.getfile(platypush.schemas))
_schema_regex = re.compile(
r'^(\s*)\.\.\s+schema::\s*([a-zA-Z0-9._]+)\s*(\((.+?)\))?', re.MULTILINE
)
@classmethod
def _get_field_value(cls, field):
metadata = getattr(field, 'metadata', {})
if metadata.get('example'):
return metadata['example']
if metadata.get('description'):
return metadata['description']
if isinstance(field, fields.Number):
return randint(1, 99)
if isinstance(field, fields.Boolean):
return bool(randint(0, 1))
if isinstance(field, fields.URL):
return 'https://example.org'
if isinstance(field, fields.List):
return [cls._get_field_value(field.inner)]
if isinstance(field, fields.Dict):
return {
cls._get_field_value(field.key_field)
if field.key_field
else 'key': cls._get_field_value(field.value_field)
if field.value_field
else 'value'
}
if isinstance(field, fields.Nested):
ret = {
name: cls._get_field_value(f)
for name, f in field.nested().fields.items()
}
return [ret] if field.many else ret
return str(field.__class__.__name__).lower()
@classmethod
def parse(cls, docstring: str, *_, **__) -> str:
while True:
m = cls._schema_regex.search(docstring)
if not m:
break
schema_module_name = '.'.join(
['platypush.schemas', *(m.group(2).split('.')[:-1])]
)
schema_module = importlib.import_module(schema_module_name)
schema_class = getattr(schema_module, m.group(2).split('.')[-1])
schema_args = eval(f'dict({m.group(4)})') if m.group(4) else {}
schema = schema_class(**schema_args)
parsed_schema = {
name: cls._get_field_value(field)
for name, field in schema.fields.items()
if not field.load_only
}
if schema.many:
parsed_schema = [parsed_schema]
padding = m.group(1)
docstring = cls._schema_regex.sub(
textwrap.indent('\n\n.. code-block:: json\n\n', padding)
+ textwrap.indent(
json.dumps(parsed_schema, sort_keys=True, indent=2),
padding + ' ',
).replace('\n\n', '\n')
+ '\n\n',
docstring,
)
return docstring

View File

@ -5,7 +5,7 @@ import pathlib
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from typing import Collection, Coroutine, Sequence from typing import Collection, Coroutine, Optional, Sequence
from urllib.parse import urlparse from urllib.parse import urlparse
from nio import ( from nio import (
@ -47,7 +47,7 @@ class Credentials:
server_url: str server_url: str
user_id: str user_id: str
access_token: str access_token: str
device_id: str | None device_id: Optional[str] = None
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
@ -98,22 +98,22 @@ class MatrixPlugin(AsyncRunnablePlugin):
def __init__( def __init__(
self, self,
server_url: str = 'https://matrix-client.matrix.org', server_url: str = 'https://matrix-client.matrix.org',
user_id: str | None = None, user_id: Optional[str] = None,
password: str | None = None, password: Optional[str] = None,
access_token: str | None = None, access_token: Optional[str] = None,
device_name: str | None = 'platypush', device_name: Optional[str] = 'platypush',
device_id: str | None = None, device_id: Optional[str] = None,
download_path: str | None = None, download_path: Optional[str] = None,
autojoin_on_invite: bool = True, autojoin_on_invite: bool = True,
autotrust_devices: bool = False, autotrust_devices: bool = False,
autotrust_devices_whitelist: Collection[str] | None = None, autotrust_devices_whitelist: Optional[Collection[str]] = None,
autotrust_users_whitelist: Collection[str] | None = None, autotrust_users_whitelist: Optional[Collection[str]] = None,
autotrust_rooms_whitelist: Collection[str] | None = None, autotrust_rooms_whitelist: Optional[Collection[str]] = None,
**kwargs, **kwargs,
): ):
""" """
Authentication requires user_id/password on the first login. Authentication requires user_id/password on the first login.
Afterwards, session credentials are stored under Afterward, session credentials are stored under
``<$PLATYPUSH_WORKDIR>/matrix/credentials.json`` (default: ``<$PLATYPUSH_WORKDIR>/matrix/credentials.json`` (default:
``~/.local/share/platypush/matrix/credentials.json``), and you can ``~/.local/share/platypush/matrix/credentials.json``), and you can
remove the cleartext credentials from your configuration file. remove the cleartext credentials from your configuration file.
@ -299,9 +299,9 @@ class MatrixPlugin(AsyncRunnablePlugin):
self, self,
room_id: str, room_id: str,
message_type: str = 'text', message_type: str = 'text',
body: str | None = None, body: Optional[str] = None,
attachment: str | None = None, attachment: Optional[str] = None,
tx_id: str | None = None, tx_id: Optional[str] = None,
ignore_unverified_devices: bool = False, ignore_unverified_devices: bool = False,
): ):
""" """
@ -388,8 +388,8 @@ class MatrixPlugin(AsyncRunnablePlugin):
def get_messages( def get_messages(
self, self,
room_id: str, room_id: str,
start: str | None = None, start: Optional[str] = None,
end: str | None = None, end: Optional[str] = None,
backwards: bool = True, backwards: bool = True,
limit: int = 10, limit: int = 10,
): ):
@ -442,10 +442,11 @@ class MatrixPlugin(AsyncRunnablePlugin):
return MatrixDeviceSchema().dump(self._get_device(device_id)) return MatrixDeviceSchema().dump(self._get_device(device_id))
@action @action
def update_device(self, device_id: str, display_name: str | None = None): def update_device(self, device_id: str, display_name: Optional[str] = None):
""" """
Update information about a user's device. Update information about a user's device.
:param device_id: Device ID.
:param display_name: New display name. :param display_name: New display name.
:return: .. schema:: matrix.MatrixDeviceSchema :return: .. schema:: matrix.MatrixDeviceSchema
""" """
@ -460,8 +461,8 @@ class MatrixPlugin(AsyncRunnablePlugin):
def delete_devices( def delete_devices(
self, self,
devices: Sequence[str], devices: Sequence[str],
username: str | None = None, username: Optional[str] = None,
password: str | None = None, password: Optional[str] = None,
): ):
""" """
Delete a list of devices from the user's authorized list and invalidate Delete a list of devices from the user's authorized list and invalidate
@ -564,7 +565,7 @@ class MatrixPlugin(AsyncRunnablePlugin):
self.client.unverify_device(device) self.client.unverify_device(device)
@action @action
def mxc_to_http(self, url: str, homeserver: str | None = None) -> str: def mxc_to_http(self, url: str, homeserver: Optional[str] = None) -> str:
""" """
Convert a Matrix URL (in the format ``mxc://server/media_id``) to an Convert a Matrix URL (in the format ``mxc://server/media_id``) to an
HTTP URL. HTTP URL.
@ -587,8 +588,8 @@ class MatrixPlugin(AsyncRunnablePlugin):
def download( def download(
self, self,
url: str, url: str,
download_path: str | None = None, download_path: Optional[str] = None,
filename: str | None = None, filename: Optional[str] = None,
allow_remote=True, allow_remote=True,
): ):
""" """
@ -641,8 +642,8 @@ class MatrixPlugin(AsyncRunnablePlugin):
def upload( def upload(
self, self,
file: str, file: str,
name: str | None = None, name: Optional[str] = None,
content_type: str | None = None, content_type: Optional[str] = None,
encrypt: bool = False, encrypt: bool = False,
) -> str: ) -> str:
""" """
@ -665,9 +666,9 @@ class MatrixPlugin(AsyncRunnablePlugin):
@action @action
def create_room( def create_room(
self, self,
name: str | None = None, name: Optional[str] = None,
alias: str | None = None, alias: Optional[str] = None,
topic: str | None = None, topic: Optional[str] = None,
is_public: bool = False, is_public: bool = False,
is_direct: bool = False, is_direct: bool = False,
federate: bool = True, federate: bool = True,
@ -729,7 +730,7 @@ class MatrixPlugin(AsyncRunnablePlugin):
self._loop_execute(self.client.room_invite(room_id, user_id)) self._loop_execute(self.client.room_invite(room_id, user_id))
@action @action
def kick(self, room_id: str, user_id: str, reason: str | None = None): def kick(self, room_id: str, user_id: str, reason: Optional[str] = None):
""" """
Kick a user out of a room. Kick a user out of a room.
@ -740,7 +741,7 @@ class MatrixPlugin(AsyncRunnablePlugin):
self._loop_execute(self.client.room_kick(room_id, user_id, reason)) self._loop_execute(self.client.room_kick(room_id, user_id, reason))
@action @action
def ban(self, room_id: str, user_id: str, reason: str | None = None): def ban(self, room_id: str, user_id: str, reason: Optional[str] = None):
""" """
Ban a user from a room. Ban a user from a room.

View File

@ -100,7 +100,7 @@ class Credentials:
server_url: str server_url: str
user_id: str user_id: str
access_token: str access_token: str
device_id: str | None device_id: Optional[str] = None
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
@ -116,13 +116,13 @@ class MatrixClient(AsyncClient):
self, self,
*args, *args,
credentials_file: str, credentials_file: str,
store_path: str | None = None, store_path: Optional[str] = None,
config: Optional[AsyncClientConfig] = None, config: Optional[AsyncClientConfig] = None,
autojoin_on_invite=True, autojoin_on_invite=True,
autotrust_devices=False, autotrust_devices=False,
autotrust_devices_whitelist: Collection[str] | None = None, autotrust_devices_whitelist: Optional[Collection[str]] = None,
autotrust_rooms_whitelist: Collection[str] | None = None, autotrust_rooms_whitelist: Optional[Collection[str]] = None,
autotrust_users_whitelist: Collection[str] | None = None, autotrust_users_whitelist: Optional[Collection[str]] = None,
**kwargs, **kwargs,
): ):
credentials_file = os.path.abspath(os.path.expanduser(credentials_file)) credentials_file = os.path.abspath(os.path.expanduser(credentials_file))
@ -158,7 +158,7 @@ class MatrixClient(AsyncClient):
store_path, 'attachment_keys.json' store_path, 'attachment_keys.json'
) )
self._encrypted_attachments_keystore = {} self._encrypted_attachments_keystore = {}
self._sync_store_timer: threading.Timer | None = None self._sync_store_timer: Optional[threading.Timer] = None
keystore = {} keystore = {}
try: try:
@ -206,9 +206,9 @@ class MatrixClient(AsyncClient):
async def login( async def login(
self, self,
password: str | None = None, password: Optional[str] = None,
device_name: str | None = None, device_name: Optional[str] = None,
token: str | None = None, token: Optional[str] = None,
) -> LoginResponse: ) -> LoginResponse:
self._load_from_file() self._load_from_file()
login_res = None login_res = None
@ -289,7 +289,7 @@ class MatrixClient(AsyncClient):
@logged_in @logged_in
async def room_messages( async def room_messages(
self, room_id: str, start: str | None = None, *args, **kwargs self, room_id: str, start: Optional[str] = None, *args, **kwargs
) -> RoomMessagesResponse: ) -> RoomMessagesResponse:
if not start: if not start:
start = self._last_batches_by_room.get(room_id, {}).get('prev_batch') start = self._last_batches_by_room.get(room_id, {}).get('prev_batch')
@ -351,9 +351,9 @@ class MatrixClient(AsyncClient):
) )
def get_devices_by_user( def get_devices_by_user(
self, user_id: str | None = None self, user_id: Optional[str] = None
) -> Dict[str, Dict[str, OlmDevice]] | Dict[str, OlmDevice]: ) -> Dict[str, Dict[str, OlmDevice]] | Dict[str, OlmDevice]:
devices = {user: devices for user, devices in self.device_store.items()} devices = dict(self.device_store.items())
if user_id: if user_id:
devices = devices.get(user_id, {}) devices = devices.get(user_id, {})
@ -370,7 +370,7 @@ class MatrixClient(AsyncClient):
return self.get_devices().get(device_id) return self.get_devices().get(device_id)
def get_devices_by_room( def get_devices_by_room(
self, room_id: str | None = None self, room_id: Optional[str] = None
) -> Dict[str, Dict[str, OlmDevice]] | Dict[str, OlmDevice]: ) -> Dict[str, Dict[str, OlmDevice]] | Dict[str, OlmDevice]:
devices = { devices = {
room_id: { room_id: {
@ -432,7 +432,7 @@ class MatrixClient(AsyncClient):
@alru_cache(maxsize=500) @alru_cache(maxsize=500)
@client_session @client_session
async def get_profile(self, user_id: str | None = None) -> ProfileGetResponse: async def get_profile(self, user_id: Optional[str] = None) -> ProfileGetResponse:
""" """
Cached version of get_profile. Cached version of get_profile.
""" """
@ -459,7 +459,7 @@ class MatrixClient(AsyncClient):
self, self,
server_name: str, server_name: str,
media_id: str, media_id: str,
filename: str | None = None, filename: Optional[str] = None,
allow_remote: bool = True, allow_remote: bool = True,
): ):
response = await super().download( response = await super().download(

View File

@ -245,7 +245,7 @@ class MediaMplayerPlugin(MediaPlugin):
""" """
Execute a raw MPlayer command. See Execute a raw MPlayer command. See
https://www.mplayerhq.hu/DOCS/tech/slave.txt for a reference or call https://www.mplayerhq.hu/DOCS/tech/slave.txt for a reference or call
:meth:`platypush.plugins.media.mplayer.list_actions` to get a list :meth:`.list_actions` to get a list
""" """
args = args or [] args = args or []

View File

@ -368,7 +368,7 @@ class RssPlugin(RunnablePlugin):
responses[url] = response['content'] responses[url] = response['content']
responses = { responses = {
k: v for k, v in responses.items() if not isinstance(v, Exception) k: v for k, v in responses.items() if v and not isinstance(v, Exception)
} }
for url, response in responses.items(): for url, response in responses.items():

View File

@ -40,11 +40,11 @@ class SoundPlugin(RunnablePlugin):
): ):
""" """
:param input_device: Index or name of the default input device. Use :param input_device: Index or name of the default input device. Use
:meth:`platypush.plugins.sound.query_devices` to get the :meth:`.query_devices` to get the available devices. Default: system
available devices. Default: system default default
:param output_device: Index or name of the default output device. :param output_device: Index or name of the default output device.
Use :meth:`platypush.plugins.sound.query_devices` to get the Use :meth:`.query_devices` to get the available devices. Default:
available devices. Default: system default system default
:param input_blocksize: Blocksize to be applied to the input device. :param input_blocksize: Blocksize to be applied to the input device.
Try to increase this value if you get input overflow errors while Try to increase this value if you get input overflow errors while
recording. Default: 1024 recording. Default: 1024
@ -160,8 +160,7 @@ class SoundPlugin(RunnablePlugin):
in the audio file in file mode, 1 if in synth mode in the audio file in file mode, 1 if in synth mode
:param volume: Playback volume, between 0 and 100. Default: 100. :param volume: Playback volume, between 0 and 100. Default: 100.
:param stream_index: If specified, play to an already active stream :param stream_index: If specified, play to an already active stream
index (you can get them through index (you can get them through :meth:`.query_streams`). Default:
:meth:`platypush.plugins.sound.query_streams`). Default:
creates a new audio stream through PortAudio. creates a new audio stream through PortAudio.
:param stream_name: Name of the stream to play to. If set, the sound :param stream_name: Name of the stream to play to. If set, the sound
will be played to the specified stream name, or a stream with that will be played to the specified stream name, or a stream with that

View File

@ -13,6 +13,8 @@ import socket
import ssl import ssl
import time import time
import urllib.request import urllib.request
from importlib.machinery import SourceFileLoader
from importlib.util import spec_from_loader, module_from_spec
from multiprocessing import Lock as PLock from multiprocessing import Lock as PLock
from tempfile import gettempdir from tempfile import gettempdir
from threading import Lock as TLock from threading import Lock as TLock
@ -86,7 +88,7 @@ def get_backend_module_by_name(backend_name):
return None return None
def get_plugin_class_by_name(plugin_name): def get_plugin_class_by_name(plugin_name) -> Optional[type]:
"""Gets the class of a plugin by name (e.g. "music.mpd" or "media.vlc")""" """Gets the class of a plugin by name (e.g. "music.mpd" or "media.vlc")"""
module = get_plugin_module_by_name(plugin_name) module = get_plugin_module_by_name(plugin_name)
@ -123,7 +125,7 @@ def get_plugin_name_by_class(plugin) -> Optional[str]:
return '.'.join(class_tokens) return '.'.join(class_tokens)
def get_backend_class_by_name(backend_name: str): def get_backend_class_by_name(backend_name: str) -> Optional[type]:
"""Gets the class of a backend by name (e.g. "backend.http" or "backend.mqtt")""" """Gets the class of a backend by name (e.g. "backend.http" or "backend.mqtt")"""
module = get_backend_module_by_name(backend_name) module = get_backend_module_by_name(backend_name)
@ -685,4 +687,22 @@ def get_message_response(msg):
return response return response
def import_file(path: str, name: Optional[str] = None):
"""
Import a Python file as a module, even if no __init__.py is
defined in the directory.
:param path: Path of the file to import.
:param name: Custom name for the imported module (default: same as the file's basename).
:return: The imported module.
"""
name = name or re.split(r"\.py$", os.path.basename(path))[0]
loader = SourceFileLoader(name, os.path.expanduser(path))
mod_spec = spec_from_loader(name, loader)
assert mod_spec, f"Cannot create module specification for {path}"
mod = module_from_spec(mod_spec)
loader.exec_module(mod)
return mod
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View File

@ -273,6 +273,14 @@ class Dependencies:
by_pkg_manager: Dict[PackageManagers, Set[str]] = field(default_factory=dict) by_pkg_manager: Dict[PackageManagers, Set[str]] = field(default_factory=dict)
""" All system dependencies, grouped by package manager. """ """ All system dependencies, grouped by package manager. """
def to_dict(self):
return {
'before': self.before,
'packages': list(self.packages),
'pip': self.pip,
'after': self.after,
}
@property @property
def _is_venv(self) -> bool: def _is_venv(self) -> bool:
""" """
@ -517,6 +525,17 @@ class Manifest(ABC):
:return: The type of the manifest. :return: The type of the manifest.
""" """
@property
def file(self) -> str:
"""
:return: The path to the manifest file.
"""
return os.path.join(
get_src_root(),
*self.package.split('.')[1:],
'manifest.yaml',
)
def _init_deps(self, install: Mapping[str, Iterable[str]]) -> Dependencies: def _init_deps(self, install: Mapping[str, Iterable[str]]) -> Dependencies:
deps = Dependencies() deps = Dependencies()
for key, items in install.items(): for key, items in install.items():

View File

@ -6,6 +6,8 @@ from importlib.machinery import ModuleSpec
from types import ModuleType from types import ModuleType
from typing import Any, Iterator, Sequence, Generator, Optional, List from typing import Any, Iterator, Sequence, Generator, Optional, List
from .modules import mock_imports
class MockObject: class MockObject:
""" """
@ -137,7 +139,7 @@ class MockModule(ModuleType):
class MockFinder(MetaPathFinder): class MockFinder(MetaPathFinder):
"""A finder for mocking.""" """A finder for mocking."""
def __init__(self, modules: Sequence[str]) -> None: def __init__(self, modules: Sequence[str]) -> None: # noqa
super().__init__() super().__init__()
self.modules = modules self.modules = modules
self.loader = MockLoader(self) self.loader = MockLoader(self)
@ -146,9 +148,9 @@ class MockFinder(MetaPathFinder):
def find_spec( def find_spec(
self, self,
fullname: str, fullname: str,
path: Sequence[Optional[bytes]] | None, path: Optional[Sequence[Optional[bytes]]] = None,
target: Optional[ModuleType] = None, target: Optional[ModuleType] = None,
) -> ModuleSpec | None: ) -> Optional[ModuleSpec]:
for modname in self.modules: for modname in self.modules:
# check if fullname is (or is a descendant of) one of our targets # check if fullname is (or is a descendant of) one of our targets
if modname == fullname or fullname.startswith(modname + "."): if modname == fullname or fullname.startswith(modname + "."):
@ -178,7 +180,7 @@ class MockLoader(Loader):
@contextmanager @contextmanager
def mock(*modules: str) -> Generator[None, None, None]: def mock(*mods: str) -> Generator[None, None, None]:
""" """
Insert mock modules during context:: Insert mock modules during context::
@ -188,10 +190,25 @@ def mock(*modules: str) -> Generator[None, None, None]:
""" """
finder = None finder = None
try: try:
finder = MockFinder(modules) finder = MockFinder(mods)
sys.meta_path.insert(0, finder) sys.meta_path.insert(0, finder)
yield yield
finally: finally:
if finder: if finder:
sys.meta_path.remove(finder) sys.meta_path.remove(finder)
finder.invalidate_caches() finder.invalidate_caches()
@contextmanager
def auto_mocks():
"""
Automatically mock all the modules listed in ``mock_imports``.
"""
with mock(*mock_imports):
yield
__all__ = [
"auto_mocks",
"mock",
]

View File

@ -0,0 +1,111 @@
mock_imports = [
"Adafruit_IO",
"Adafruit_Python_DHT",
"Leap",
"PIL",
"PyOBEX",
"PyOBEX.client",
"RPLCD",
"RPi.GPIO",
"TheengsDecoder",
"aiofiles",
"aiofiles.os",
"aiohttp",
"aioxmpp",
"apiclient",
"async_lru",
"avs",
"bcrypt",
"bleak",
"bluetooth",
"bluetooth_numbers",
"cpuinfo",
"croniter",
"cups",
"cv2",
"cwiid",
"dbus",
"deepspeech",
"defusedxml",
"docutils",
"envirophat",
"feedparser",
"gevent.wsgi",
"gi",
"gi.repository",
"google",
"google.assistant.embedded",
"google.assistant.library",
"google.assistant.library.event",
"google.assistant.library.file_helpers",
"google.oauth2.credentials",
"googlesamples",
"googlesamples.assistant.grpc.audio_helpers",
"gps",
"graphyte",
"grpc",
"gunicorn",
"httplib2",
"icalendar",
"imapclient",
"inotify",
"inputs",
"irc",
"irc.bot",
"irc.client",
"irc.connection",
"irc.events",
"irc.strings",
"kafka",
"keras",
"linode_api4",
"luma",
"mpd",
"ndef",
"nfc",
"nio",
"numpy",
"oauth2client",
"oauth2client",
"omxplayer",
"openzwave",
"pandas",
"paramiko",
"picamera",
"plexapi",
"pmw3901",
"psutil",
"pvcheetah",
"pvporcupine ",
"pyHS100",
"pyaudio",
"pyclip",
"pydbus",
"pyfirmata2",
"pyngrok",
"pyotp",
"pysmartthings",
"pyzbar",
"rtmidi",
"samsungtvws",
"serial",
"simple_websocket",
"smartcard",
"sounddevice",
"soundfile",
"telegram",
"telegram.ext",
"tenacity",
"tensorflow",
"todoist",
"trello",
"twilio",
"uvicorn",
"watchdog",
"wave",
"websockets",
"zeroconf",
]
"""
List of modules that should be mocked when building the documentation or running tests.
"""