diff --git a/.drone.yml b/.drone.yml index 004ab1f15..1f0b78df7 100644 --- a/.drone.yml +++ b/.drone.yml @@ -49,7 +49,7 @@ steps: commands: - echo "Installing required build dependencies" - - apk add --update --no-cache make py3-sphinx py3-pip py3-paho-mqtt + - apk add --update --no-cache make py3-sphinx py3-pip py3-paho-mqtt py3-yaml - pip install -U hid sphinx-rtd-theme sphinx-book-theme - pip install . - mkdir -p /docs/current diff --git a/docs/source/_ext/add_dependencies.py b/docs/source/_ext/add_dependencies.py new file mode 100644 index 000000000..18c646c04 --- /dev/null +++ b/docs/source/_ext/add_dependencies.py @@ -0,0 +1,190 @@ +import inspect +import os +import re +import sys +import textwrap as tw +from contextlib import contextmanager + +from sphinx.application import Sphinx + +base_path = os.path.abspath( + os.path.join(os.path.dirname(os.path.relpath(__file__)), '..', '..', '..') +) + +sys.path.insert(0, base_path) + +from platypush.utils import get_plugin_name_by_class # noqa +from platypush.utils.mock import mock # noqa +from platypush.utils.reflection import IntegrationMetadata, import_file # noqa + + +class IntegrationEnricher: + @staticmethod + def add_events(source: list[str], manifest: IntegrationMetadata, idx: int) -> int: + if not manifest.events: + return idx + + source.insert( + idx, + 'Triggered events\n----------------\n\n' + + '\n'.join( + f'\t- :class:`{event.__module__}.{event.__qualname__}`' + for event in manifest.events + ) + + '\n\n', + ) + + return idx + 1 + + @staticmethod + def add_actions(source: list[str], manifest: IntegrationMetadata, idx: int) -> int: + if not (manifest.actions and manifest.cls): + return idx + + source.insert( + idx, + 'Actions\n-------\n\n' + + '\n'.join( + f'\t- `{get_plugin_name_by_class(manifest.cls)}.{action} ' + + f'<#{manifest.cls.__module__}.{manifest.cls.__qualname__}.{action}>`_' + for action in sorted(manifest.actions.keys()) + ) + + '\n\n', + ) + + return idx + 1 + + @staticmethod + def _shellify(title: str, cmd: str) -> str: + return f'**{title}**\n\n' + '.. code-block:: bash\n\n\t' + cmd + '\n\n' + + @classmethod + def add_install_deps( + cls, source: list[str], manifest: IntegrationMetadata, idx: int + ) -> int: + deps = manifest.deps + parsed_deps = { + 'before': deps.before, + 'pip': deps.pip, + 'after': deps.after, + } + + if not (any(parsed_deps.values()) or deps.by_pkg_manager): + return idx + + source.insert(idx, 'Dependencies\n------------\n\n') + idx += 1 + + if parsed_deps['before']: + source.insert(idx, cls._shellify('Pre-install', '\n'.join(deps.before))) + idx += 1 + + if parsed_deps['pip']: + source.insert(idx, cls._shellify('pip', 'pip ' + ' '.join(deps.pip))) + idx += 1 + + for pkg_manager, sys_deps in deps.by_pkg_manager.items(): + if not sys_deps: + continue + + source.insert( + idx, + cls._shellify( + pkg_manager.value.default_os.value.description, + pkg_manager.value.install_doc + ' ' + ' '.join(sys_deps), + ), + ) + + idx += 1 + + if parsed_deps['after']: + source.insert(idx, cls._shellify('Post-install', '\n'.join(deps.after))) + idx += 1 + + return idx + + @classmethod + def add_description( + cls, source: list[str], manifest: IntegrationMetadata, idx: int + ) -> int: + docs = ( + doc + for doc in ( + inspect.getdoc(manifest.cls) or '', + manifest.constructor.doc if manifest.constructor else '', + ) + if doc + ) + + if not docs: + return idx + + docstring = '\n\n'.join(docs) + source.insert(idx, f"Description\n-----------\n\n{docstring}\n\n") + return idx + 1 + + @classmethod + def add_conf_snippet( + cls, source: list[str], manifest: IntegrationMetadata, idx: int + ) -> int: + source.insert( + idx, + tw.dedent( + f""" + Configuration + ------------- + + .. code-block:: yaml + +{tw.indent(manifest.config_snippet, ' ')} + """ + ), + ) + + return idx + 1 + + def __call__(self, _: Sphinx, doc: str, source: list[str]): + if not (source and re.match(r'^platypush/(backend|plugins)/.*', doc)): + return + + src = [src.split('\n') for src in source][0] + if len(src) < 3: + return + + manifest_file = os.path.join( + base_path, + *doc.split(os.sep)[:-1], + *doc.split(os.sep)[-1].split('.'), + 'manifest.yaml', + ) + + if not os.path.isfile(manifest_file): + return + + with mock_imports(): + manifest = IntegrationMetadata.from_manifest(manifest_file) + idx = self.add_description(src, manifest, idx=3) + idx = self.add_conf_snippet(src, manifest, idx=idx) + idx = self.add_install_deps(src, manifest, idx=idx) + idx = self.add_events(src, manifest, idx=idx) + idx = self.add_actions(src, manifest, idx=idx) + + src.insert(idx, '\n\nModule reference\n----------------\n\n') + 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): + app.connect('source-read', IntegrationEnricher()) + return { + 'version': '0.1', + 'parallel_read_safe': True, + 'parallel_write_safe': True, + } diff --git a/docs/source/backends.rst b/docs/source/backends.rst index 60fe78475..9ac610bbb 100644 --- a/docs/source/backends.rst +++ b/docs/source/backends.rst @@ -20,7 +20,6 @@ Backends platypush/backend/google.pubsub.rst platypush/backend/gps.rst platypush/backend/http.rst - platypush/backend/inotify.rst platypush/backend/joystick.rst platypush/backend/joystick.jstest.rst platypush/backend/joystick.linux.rst @@ -28,7 +27,6 @@ Backends platypush/backend/log.http.rst platypush/backend/mail.rst platypush/backend/midi.rst - platypush/backend/mqtt.rst platypush/backend/music.mopidy.rst platypush/backend/music.mpd.rst platypush/backend/music.snapcast.rst @@ -52,4 +50,3 @@ Backends platypush/backend/weather.darksky.rst platypush/backend/weather.openweathermap.rst platypush/backend/wiimote.rst - platypush/backend/zwave.mqtt.rst diff --git a/docs/source/conf.py b/docs/source/conf.py index 0f1f9836f..4b159dc63 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -15,17 +15,14 @@ import sys # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # -# import os -# import sys -# sys.path.insert(0, os.path.abspath('.')) sys.path.insert(0, os.path.abspath("./_ext")) # -- Project information ----------------------------------------------------- project = 'Platypush' -copyright = '2017-2021, Fabio Manganiello' -author = 'Fabio Manganiello' +copyright = '2017-2023, Fabio Manganiello' +author = 'Fabio Manganiello ' # The short X.Y version version = '' @@ -52,6 +49,7 @@ extensions = [ 'sphinx.ext.githubpages', 'sphinx_rtd_theme', 'sphinx_marshmallow', + 'add_dependencies', ] # Add any paths that contain templates here, relative to this directory. @@ -190,11 +188,6 @@ texinfo_documents = [ # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = {'python': ('https://docs.python.org/3', None)} -# -- Options for todo extension ---------------------------------------------- - -# If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = True - autodoc_default_options = { 'members': True, 'show-inheritance': True, diff --git a/docs/source/platypush/backend/inotify.rst b/docs/source/platypush/backend/inotify.rst deleted file mode 100644 index 0f0967ddc..000000000 --- a/docs/source/platypush/backend/inotify.rst +++ /dev/null @@ -1,6 +0,0 @@ -``inotify`` -============================= - -.. automodule:: platypush.backend.inotify - :members: - diff --git a/docs/source/platypush/backend/mqtt.rst b/docs/source/platypush/backend/mqtt.rst deleted file mode 100644 index 5e788af8f..000000000 --- a/docs/source/platypush/backend/mqtt.rst +++ /dev/null @@ -1,6 +0,0 @@ -``mqtt`` -========================== - -.. automodule:: platypush.backend.mqtt - :members: - diff --git a/docs/source/platypush/backend/zwave.mqtt.rst b/docs/source/platypush/backend/zwave.mqtt.rst deleted file mode 100644 index ed0175d78..000000000 --- a/docs/source/platypush/backend/zwave.mqtt.rst +++ /dev/null @@ -1,5 +0,0 @@ -``zwave.mqtt`` -================================ - -.. automodule:: platypush.backend.zwave.mqtt - :members: diff --git a/docs/source/platypush/plugins/http.request.rss.rst b/docs/source/platypush/plugins/http.request.rss.rst deleted file mode 100644 index 65b54c70f..000000000 --- a/docs/source/platypush/plugins/http.request.rss.rst +++ /dev/null @@ -1,6 +0,0 @@ -``http.request.rss`` -====================================== - -.. automodule:: platypush.plugins.http.request.rss - :members: - diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index a585a7a74..be9e63243 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -50,7 +50,6 @@ Plugins platypush/plugins/graphite.rst platypush/plugins/hid.rst platypush/plugins/http.request.rst - platypush/plugins/http.request.rss.rst platypush/plugins/http.webpage.rst platypush/plugins/ifttt.rst platypush/plugins/inputs.rst diff --git a/platypush/backend/adafruit/io/__init__.py b/platypush/backend/adafruit/io/__init__.py index a403ed868..a22856ae8 100644 --- a/platypush/backend/adafruit/io/__init__.py +++ b/platypush/backend/adafruit/io/__init__.py @@ -2,23 +2,17 @@ from typing import Optional from platypush.backend import Backend from platypush.context import get_plugin -from platypush.message.event.adafruit import ConnectedEvent, DisconnectedEvent, \ - FeedUpdateEvent +from platypush.message.event.adafruit import ( + ConnectedEvent, + DisconnectedEvent, + FeedUpdateEvent, +) class AdafruitIoBackend(Backend): """ Backend that listens to messages received over the Adafruit IO message queue - Triggers: - - * :class:`platypush.message.event.adafruit.ConnectedEvent` when the - backend connects to the Adafruit queue - * :class:`platypush.message.event.adafruit.DisconnectedEvent` when the - backend disconnects from the Adafruit queue - * :class:`platypush.message.event.adafruit.FeedUpdateEvent` when an - update event is received on a monitored feed - Requires: * The :class:`platypush.plugins.adafruit.io.AdafruitIoPlugin` plugin to @@ -33,6 +27,7 @@ class AdafruitIoBackend(Backend): super().__init__(*args, **kwargs) from Adafruit_IO import MQTTClient + self.feeds = feeds self._client: Optional[MQTTClient] = None @@ -41,6 +36,7 @@ class AdafruitIoBackend(Backend): return from Adafruit_IO import MQTTClient + plugin = get_plugin('adafruit.io') if not plugin: raise RuntimeError('Adafruit IO plugin not configured') @@ -80,8 +76,11 @@ class AdafruitIoBackend(Backend): def run(self): super().run() - self.logger.info(('Initialized Adafruit IO backend, listening on ' + - 'feeds {}').format(self.feeds)) + self.logger.info( + ('Initialized Adafruit IO backend, listening on ' + 'feeds {}').format( + self.feeds + ) + ) while not self.should_stop(): try: @@ -94,4 +93,5 @@ class AdafruitIoBackend(Backend): self.logger.exception(e) self._client = None + # vim:sw=4:ts=4:et: diff --git a/platypush/backend/alarm/__init__.py b/platypush/backend/alarm/__init__.py index 9879f482e..a60040620 100644 --- a/platypush/backend/alarm/__init__.py +++ b/platypush/backend/alarm/__init__.py @@ -11,7 +11,11 @@ from dateutil.tz import gettz from platypush.backend import Backend from platypush.context import get_bus, get_plugin -from platypush.message.event.alarm import AlarmStartedEvent, AlarmDismissedEvent, AlarmSnoozedEvent +from platypush.message.event.alarm import ( + AlarmStartedEvent, + AlarmDismissedEvent, + AlarmSnoozedEvent, +) from platypush.plugins.media import MediaPlugin, PlayerState from platypush.procedure import Procedure @@ -28,10 +32,17 @@ class Alarm: _alarms_count = 0 _id_lock = threading.RLock() - def __init__(self, when: str, actions: Optional[list] = None, name: Optional[str] = None, - audio_file: Optional[str] = None, audio_plugin: Optional[str] = None, - audio_volume: Optional[Union[int, float]] = None, - snooze_interval: float = 300.0, enabled: bool = True): + def __init__( + self, + when: str, + actions: Optional[list] = None, + name: Optional[str] = None, + audio_file: Optional[str] = None, + audio_plugin: Optional[str] = None, + audio_volume: Optional[Union[int, float]] = None, + snooze_interval: float = 300.0, + enabled: bool = True, + ): with self._id_lock: self._alarms_count += 1 self.id = self._alarms_count @@ -42,20 +53,26 @@ class Alarm: if audio_file: self.audio_file = os.path.abspath(os.path.expanduser(audio_file)) - assert os.path.isfile(self.audio_file), 'No such audio file: {}'.format(self.audio_file) + assert os.path.isfile(self.audio_file), 'No such audio file: {}'.format( + self.audio_file + ) self.audio_plugin = audio_plugin self.audio_volume = audio_volume self.snooze_interval = snooze_interval self.state: Optional[AlarmState] = None self.timer: Optional[threading.Timer] = None - self.actions = Procedure.build(name=name, _async=False, requests=actions or [], id=self.id) + self.actions = Procedure.build( + name=name, _async=False, requests=actions or [], id=self.id + ) self._enabled = enabled self._runtime_snooze_interval = snooze_interval def get_next(self) -> float: - now = datetime.datetime.now().replace(tzinfo=gettz()) # lgtm [py/call-to-non-callable] + now = datetime.datetime.now().replace( + tzinfo=gettz() + ) # lgtm [py/call-to-non-callable] try: cron = croniter.croniter(self.when, now) @@ -63,10 +80,14 @@ class Alarm: except (AttributeError, croniter.CroniterBadCronError): try: timestamp = datetime.datetime.fromisoformat(self.when).replace( - tzinfo=gettz()) # lgtm [py/call-to-non-callable] + tzinfo=gettz() + ) # lgtm [py/call-to-non-callable] except (TypeError, ValueError): - timestamp = (datetime.datetime.now().replace(tzinfo=gettz()) + # lgtm [py/call-to-non-callable] - datetime.timedelta(seconds=int(self.when))) + timestamp = datetime.datetime.now().replace( + tzinfo=gettz() + ) + datetime.timedelta( # lgtm [py/call-to-non-callable] + seconds=int(self.when) + ) return timestamp.timestamp() if timestamp >= now else None @@ -88,7 +109,9 @@ class Alarm: self._runtime_snooze_interval = interval or self.snooze_interval self.state = AlarmState.SNOOZED self.stop_audio() - get_bus().post(AlarmSnoozedEvent(name=self.name, interval=self._runtime_snooze_interval)) + get_bus().post( + AlarmSnoozedEvent(name=self.name, interval=self._runtime_snooze_interval) + ) def start(self): if self.timer: @@ -159,7 +182,9 @@ class Alarm: break if not sleep_time: - sleep_time = self.get_next() - time.time() if self.get_next() else 10 + sleep_time = ( + self.get_next() - time.time() if self.get_next() else 10 + ) time.sleep(sleep_time) @@ -179,18 +204,15 @@ class Alarm: class AlarmBackend(Backend): """ Backend to handle user-configured alarms. - - Triggers: - - * :class:`platypush.message.event.alarm.AlarmStartedEvent` when an alarm starts. - * :class:`platypush.message.event.alarm.AlarmSnoozedEvent` when an alarm is snoozed. - * :class:`platypush.message.event.alarm.AlarmTimeoutEvent` when an alarm times out. - * :class:`platypush.message.event.alarm.AlarmDismissedEvent` when an alarm is dismissed. - """ - def __init__(self, alarms: Optional[Union[list, Dict[str, Any]]] = None, audio_plugin: str = 'media.mplayer', - *args, **kwargs): + def __init__( + self, + alarms: Optional[Union[list, Dict[str, Any]]] = None, + audio_plugin: str = 'media.mplayer', + *args, + **kwargs + ): """ :param alarms: List or name->value dict with the configured alarms. Example: @@ -231,13 +253,29 @@ class AlarmBackend(Backend): alarms = [{'name': name, **alarm} for name, alarm in alarms.items()] self.audio_plugin = audio_plugin - alarms = [Alarm(**{'audio_plugin': self.audio_plugin, **alarm}) for alarm in alarms] + alarms = [ + Alarm(**{'audio_plugin': self.audio_plugin, **alarm}) for alarm in alarms + ] self.alarms: Dict[str, Alarm] = {alarm.name: alarm for alarm in alarms} - def add_alarm(self, when: str, actions: list, name: Optional[str] = None, audio_file: Optional[str] = None, - audio_volume: Optional[Union[int, float]] = None, enabled: bool = True) -> Alarm: - alarm = Alarm(when=when, actions=actions, name=name, enabled=enabled, audio_file=audio_file, - audio_plugin=self.audio_plugin, audio_volume=audio_volume) + def add_alarm( + self, + when: str, + actions: list, + name: Optional[str] = None, + audio_file: Optional[str] = None, + audio_volume: Optional[Union[int, float]] = None, + enabled: bool = True, + ) -> Alarm: + alarm = Alarm( + when=when, + actions=actions, + name=name, + enabled=enabled, + audio_file=audio_file, + audio_plugin=self.audio_plugin, + audio_volume=audio_volume, + ) if alarm.name in self.alarms: self.logger.info('Overwriting existing alarm {}'.format(alarm.name)) @@ -274,10 +312,15 @@ class AlarmBackend(Backend): alarm.snooze(interval=interval) def get_alarms(self) -> List[Alarm]: - return sorted([alarm for alarm in self.alarms.values()], key=lambda alarm: alarm.get_next()) + return sorted( + self.alarms.values(), + key=lambda alarm: alarm.get_next(), + ) def get_running_alarm(self) -> Optional[Alarm]: - running_alarms = [alarm for alarm in self.alarms.values() if alarm.state == AlarmState.RUNNING] + running_alarms = [ + alarm for alarm in self.alarms.values() if alarm.state == AlarmState.RUNNING + ] return running_alarms[0] if running_alarms else None def __enter__(self): @@ -285,9 +328,11 @@ class AlarmBackend(Backend): alarm.stop() alarm.start() - self.logger.info('Initialized alarm backend with {} alarms'.format(len(self.alarms))) + self.logger.info( + 'Initialized alarm backend with {} alarms'.format(len(self.alarms)) + ) - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__(self, *_, **__): for alarm in self.alarms.values(): alarm.stop() @@ -295,7 +340,9 @@ class AlarmBackend(Backend): def loop(self): for name, alarm in self.alarms.copy().items(): - if not alarm.timer or (not alarm.timer.is_alive() and alarm.state == AlarmState.SHUTDOWN): + if not alarm.timer or ( + not alarm.timer.is_alive() and alarm.state == AlarmState.SHUTDOWN + ): del self.alarms[name] time.sleep(10) diff --git a/platypush/backend/assistant/google/__init__.py b/platypush/backend/assistant/google/__init__.py index f35c269f9..f6967bcd5 100644 --- a/platypush/backend/assistant/google/__init__.py +++ b/platypush/backend/assistant/google/__init__.py @@ -31,40 +31,6 @@ class AssistantGoogleBackend(AssistantBackend): https://developers.google.com/assistant/sdk/reference/library/python/. This backend still works on most of the devices where I use it, but its correct functioning is not guaranteed as the assistant library is no longer maintained. - - Triggers: - - * :class:`platypush.message.event.assistant.ConversationStartEvent` \ - when a new conversation starts - * :class:`platypush.message.event.assistant.SpeechRecognizedEvent` \ - when a new voice command is recognized - * :class:`platypush.message.event.assistant.NoResponse` \ - when a conversation returned no response - * :class:`platypush.message.event.assistant.ResponseEvent` \ - when the assistant is speaking a response - * :class:`platypush.message.event.assistant.ConversationTimeoutEvent` \ - when a conversation times out - * :class:`platypush.message.event.assistant.ConversationEndEvent` \ - when a new conversation ends - * :class:`platypush.message.event.assistant.AlarmStartedEvent` \ - when an alarm starts - * :class:`platypush.message.event.assistant.AlarmEndEvent` \ - when an alarm ends - * :class:`platypush.message.event.assistant.TimerStartedEvent` \ - when a timer starts - * :class:`platypush.message.event.assistant.TimerEndEvent` \ - when a timer ends - * :class:`platypush.message.event.assistant.MicMutedEvent` \ - when the microphone is muted. - * :class:`platypush.message.event.assistant.MicUnmutedEvent` \ - when the microphone is un-muted. - - Requires: - - * **google-assistant-library** (``pip install google-assistant-library``) - * **google-assistant-sdk[samples]** (``pip install google-assistant-sdk[samples]``) - * **google-auth** (``pip install google-auth``) - """ _default_credentials_file = os.path.join( @@ -164,12 +130,12 @@ class AssistantGoogleBackend(AssistantBackend): self.bus.post(event) def start_conversation(self): - """Starts an assistant conversation""" + """Starts a conversation.""" if self.assistant: self.assistant.start_conversation() def stop_conversation(self): - """Stops an assistant conversation""" + """Stops an active conversation.""" if self.assistant: self.assistant.stop_conversation() diff --git a/platypush/backend/assistant/snowboy/__init__.py b/platypush/backend/assistant/snowboy/__init__.py index 7d1c192be..6d4bd4f14 100644 --- a/platypush/backend/assistant/snowboy/__init__.py +++ b/platypush/backend/assistant/snowboy/__init__.py @@ -15,16 +15,7 @@ class AssistantSnowboyBackend(AssistantBackend): HotwordDetectedEvent to trigger the conversation on whichever assistant plugin you're using (Google, Alexa...) - Triggers: - - * :class:`platypush.message.event.assistant.HotwordDetectedEvent` \ - whenever the hotword has been detected - - Requires: - - * **snowboy** (``pip install snowboy``) - - Manual installation for snowboy and its Python bindings if the command above fails:: + Manual installation for snowboy and its Python bindings if the installation via package fails:: $ [sudo] apt-get install libatlas-base-dev swig $ [sudo] pip install pyaudio diff --git a/platypush/backend/button/flic/__init__.py b/platypush/backend/button/flic/__init__.py index 3e9ef4d1e..038795be2 100644 --- a/platypush/backend/button/flic/__init__.py +++ b/platypush/backend/button/flic/__init__.py @@ -12,15 +12,12 @@ class ButtonFlicBackend(Backend): Backend that listen for events from the Flic (https://flic.io/) bluetooth smart buttons. - Triggers: - - * :class:`platypush.message.event.button.flic.FlicButtonEvent` when a button is pressed. - The event will also contain the press sequence - (e.g. ``["ShortPressEvent", "LongPressEvent", "ShortPressEvent"]``) - Requires: - * **fliclib** (https://github.com/50ButtonsEach/fliclib-linux-hci). For the backend to work properly you need to have the ``flicd`` daemon from the fliclib running, and you have to first pair the buttons with your device using any of the scanners provided by the library. + * **fliclib** (https://github.com/50ButtonsEach/fliclib-linux-hci). For + the backend to work properly you need to have the ``flicd`` daemon + from the fliclib running, and you have to first pair the buttons with + your device using any of the scanners provided by the library. """ @@ -29,16 +26,23 @@ class ButtonFlicBackend(Backend): ShortPressEvent = "ShortPressEvent" LongPressEvent = "LongPressEvent" - def __init__(self, server='localhost', long_press_timeout=_long_press_timeout, - btn_timeout=_btn_timeout, **kwargs): + def __init__( + self, + server='localhost', + long_press_timeout=_long_press_timeout, + btn_timeout=_btn_timeout, + **kwargs + ): """ :param server: flicd server host (default: localhost) :type server: str - :param long_press_timeout: How long you should press a button for a press action to be considered "long press" (default: 0.3 secohds) + :param long_press_timeout: How long you should press a button for a + press action to be considered "long press" (default: 0.3 secohds) :type long_press_timeout: float - :param btn_timeout: How long since the last button release before considering the user interaction completed (default: 0.5 seconds) + :param btn_timeout: How long since the last button release before + considering the user interaction completed (default: 0.5 seconds) :type btn_timeout: float """ @@ -55,15 +59,16 @@ class ButtonFlicBackend(Backend): self._btn_addr = None self._down_pressed_time = None self._cur_sequence = [] - - self.logger.info('Initialized Flic buttons backend on {}'.format(self.server)) + self.logger.info('Initialized Flic buttons backend on %s', self.server) def _got_button(self): def _f(bd_addr): cc = ButtonConnectionChannel(bd_addr) - cc.on_button_up_or_down = \ - lambda channel, click_type, was_queued, time_diff: \ - self._on_event()(bd_addr, channel, click_type, was_queued, time_diff) + cc.on_button_up_or_down = ( + lambda channel, click_type, was_queued, time_diff: self._on_event()( + bd_addr, channel, click_type, was_queued, time_diff + ) + ) self.client.add_connection_channel(cc) return _f @@ -72,23 +77,27 @@ class ButtonFlicBackend(Backend): def _f(items): for bd_addr in items["bd_addr_of_verified_buttons"]: self._got_button()(bd_addr) + return _f def _on_btn_timeout(self): def _f(): - self.logger.info('Flic event triggered from {}: {}'.format( - self._btn_addr, self._cur_sequence)) + self.logger.info( + 'Flic event triggered from %s: %s', self._btn_addr, self._cur_sequence + ) - self.bus.post(FlicButtonEvent( - btn_addr=self._btn_addr, sequence=self._cur_sequence)) + self.bus.post( + FlicButtonEvent(btn_addr=self._btn_addr, sequence=self._cur_sequence) + ) self._cur_sequence = [] return _f def _on_event(self): - # noinspection PyUnusedLocal - def _f(bd_addr, channel, click_type, was_queued, time_diff): + # _ = channel + # __ = time_diff + def _f(bd_addr, _, click_type, was_queued, __): if was_queued: return @@ -120,4 +129,3 @@ class ButtonFlicBackend(Backend): # vim:sw=4:ts=4:et: - diff --git a/platypush/backend/camera/pi/__init__.py b/platypush/backend/camera/pi/__init__.py index d6f990cd3..169a15884 100644 --- a/platypush/backend/camera/pi/__init__.py +++ b/platypush/backend/camera/pi/__init__.py @@ -15,10 +15,6 @@ class CameraPiBackend(Backend): the :class:`platypush.plugins.camera.pi` plugin. Note that the Redis backend must be configured and running to enable camera control. - Requires: - - * **picamera** (``pip install picamera``) - This backend is **DEPRECATED**. Use the plugin :class:`platypush.plugins.camera.pi.CameraPiPlugin` instead to run Pi camera actions. If you want to start streaming the camera on application start then simply create an event hook on :class:`platypush.message.event.application.ApplicationStartedEvent` that runs ``camera.pi.start_streaming``. @@ -33,15 +29,32 @@ class CameraPiBackend(Backend): return self.value == other # noinspection PyUnresolvedReferences,PyPackageRequirements - def __init__(self, listen_port, bind_address='0.0.0.0', x_resolution=640, y_resolution=480, - redis_queue='platypush/camera/pi', - start_recording_on_startup=True, - framerate=24, hflip=False, vflip=False, - sharpness=0, contrast=0, brightness=50, - video_stabilization=False, iso=0, exposure_compensation=0, - exposure_mode='auto', meter_mode='average', awb_mode='auto', - image_effect='none', color_effects=None, rotation=0, - crop=(0.0, 0.0, 1.0, 1.0), **kwargs): + def __init__( + self, + listen_port, + bind_address='0.0.0.0', + x_resolution=640, + y_resolution=480, + redis_queue='platypush/camera/pi', + start_recording_on_startup=True, + framerate=24, + hflip=False, + vflip=False, + sharpness=0, + contrast=0, + brightness=50, + video_stabilization=False, + iso=0, + exposure_compensation=0, + exposure_mode='auto', + meter_mode='average', + awb_mode='auto', + image_effect='none', + color_effects=None, + rotation=0, + crop=(0.0, 0.0, 1.0, 1.0), + **kwargs + ): """ See https://www.raspberrypi.org/documentation/usage/camera/python/README.md for a detailed reference about the Pi camera options. @@ -58,7 +71,9 @@ class CameraPiBackend(Backend): self.bind_address = bind_address self.listen_port = listen_port self.server_socket = socket.socket() - self.server_socket.bind((self.bind_address, self.listen_port)) # lgtm [py/bind-socket-all-network-interfaces] + self.server_socket.bind( + (self.bind_address, self.listen_port) + ) # lgtm [py/bind-socket-all-network-interfaces] self.server_socket.listen(0) import picamera @@ -87,10 +102,7 @@ class CameraPiBackend(Backend): self._recording_thread = None def send_camera_action(self, action, **kwargs): - action = { - 'action': action.value, - **kwargs - } + action = {'action': action.value, **kwargs} self.redis.send_message(msg=json.dumps(action), queue_name=self.redis_queue) @@ -127,7 +139,9 @@ class CameraPiBackend(Backend): else: while not self.should_stop(): connection = self.server_socket.accept()[0].makefile('wb') - self.logger.info('Accepted client connection on port {}'.format(self.listen_port)) + self.logger.info( + 'Accepted client connection on port {}'.format(self.listen_port) + ) try: self.camera.start_recording(connection, format=format) @@ -138,12 +152,16 @@ class CameraPiBackend(Backend): try: self.stop_recording() except Exception as e: - self.logger.warning('Could not stop recording: {}'.format(str(e))) + self.logger.warning( + 'Could not stop recording: {}'.format(str(e)) + ) try: connection.close() except Exception as e: - self.logger.warning('Could not close connection: {}'.format(str(e))) + self.logger.warning( + 'Could not close connection: {}'.format(str(e)) + ) self.send_camera_action(self.CameraAction.START_RECORDING) @@ -152,12 +170,13 @@ class CameraPiBackend(Backend): return self.logger.info('Starting camera recording') - self._recording_thread = Thread(target=recording_thread, - name='PiCameraRecorder') + self._recording_thread = Thread( + target=recording_thread, name='PiCameraRecorder' + ) self._recording_thread.start() def stop_recording(self): - """ Stops recording """ + """Stops recording""" self.logger.info('Stopping camera recording') diff --git a/platypush/backend/chat/telegram/__init__.py b/platypush/backend/chat/telegram/__init__.py index c6c29aeaf..26bfa725e 100644 --- a/platypush/backend/chat/telegram/__init__.py +++ b/platypush/backend/chat/telegram/__init__.py @@ -22,17 +22,6 @@ class ChatTelegramBackend(Backend): """ Telegram bot that listens for messages and updates. - Triggers: - - * :class:`platypush.message.event.chat.telegram.TextMessageEvent` when a text message is received. - * :class:`platypush.message.event.chat.telegram.PhotoMessageEvent` when a photo is received. - * :class:`platypush.message.event.chat.telegram.VideoMessageEvent` when a video is received. - * :class:`platypush.message.event.chat.telegram.LocationMessageEvent` when a location is received. - * :class:`platypush.message.event.chat.telegram.ContactMessageEvent` when a contact is received. - * :class:`platypush.message.event.chat.telegram.DocumentMessageEvent` when a document is received. - * :class:`platypush.message.event.chat.telegram.CommandMessageEvent` when a command message is received. - * :class:`platypush.message.event.chat.telegram.GroupChatCreatedEvent` when the bot is invited to a new group. - Requires: * The :class:`platypush.plugins.chat.telegram.ChatTelegramPlugin` plugin configured diff --git a/platypush/backend/file/monitor/__init__.py b/platypush/backend/file/monitor/__init__.py index b2eb58024..b52ad8df0 100644 --- a/platypush/backend/file/monitor/__init__.py +++ b/platypush/backend/file/monitor/__init__.py @@ -10,17 +10,6 @@ from .entities.resources import MonitoredResource, MonitoredPattern, MonitoredRe class FileMonitorBackend(Backend): """ This backend monitors changes to local files and directories using the Watchdog API. - - Triggers: - - * :class:`platypush.message.event.file.FileSystemCreateEvent` if a resource is created. - * :class:`platypush.message.event.file.FileSystemDeleteEvent` if a resource is removed. - * :class:`platypush.message.event.file.FileSystemModifyEvent` if a resource is modified. - - Requires: - - * **watchdog** (``pip install watchdog``) - """ class EventHandlerFactory: @@ -29,20 +18,28 @@ class FileMonitorBackend(Backend): """ @staticmethod - def from_resource(resource: Union[str, Dict[str, Any], MonitoredResource]) -> EventHandler: + def from_resource( + resource: Union[str, Dict[str, Any], MonitoredResource] + ) -> EventHandler: if isinstance(resource, str): resource = MonitoredResource(resource) elif isinstance(resource, dict): if 'regexes' in resource or 'ignore_regexes' in resource: resource = MonitoredRegex(**resource) - elif 'patterns' in resource or 'ignore_patterns' in resource or 'ignore_directories' in resource: + elif ( + 'patterns' in resource + or 'ignore_patterns' in resource + or 'ignore_directories' in resource + ): resource = MonitoredPattern(**resource) else: resource = MonitoredResource(**resource) return EventHandler.from_resource(resource) - def __init__(self, paths: Iterable[Union[str, Dict[str, Any], MonitoredResource]], **kwargs): + def __init__( + self, paths: Iterable[Union[str, Dict[str, Any], MonitoredResource]], **kwargs + ): """ :param paths: List of paths to monitor. Paths can either be expressed in any of the following ways: @@ -113,7 +110,9 @@ class FileMonitorBackend(Backend): for path in paths: handler = self.EventHandlerFactory.from_resource(path) - self._observer.schedule(handler, handler.resource.path, recursive=handler.resource.recursive) + self._observer.schedule( + handler, handler.resource.path, recursive=handler.resource.recursive + ) def run(self): super().run() diff --git a/platypush/backend/foursquare/__init__.py b/platypush/backend/foursquare/__init__.py index f560efe52..29f2122ab 100644 --- a/platypush/backend/foursquare/__init__.py +++ b/platypush/backend/foursquare/__init__.py @@ -14,10 +14,6 @@ class FoursquareBackend(Backend): * The :class:`platypush.plugins.foursquare.FoursquarePlugin` plugin configured and enabled. - Triggers: - - - :class:`platypush.message.event.foursquare.FoursquareCheckinEvent` when a new check-in occurs. - """ _last_created_at_varname = '_foursquare_checkin_last_created_at' @@ -30,8 +26,12 @@ class FoursquareBackend(Backend): self._last_created_at = None def __enter__(self): - self._last_created_at = int(get_plugin('variable').get(self._last_created_at_varname). - output.get(self._last_created_at_varname) or 0) + self._last_created_at = int( + get_plugin('variable') + .get(self._last_created_at_varname) + .output.get(self._last_created_at_varname) + or 0 + ) self.logger.info('Started Foursquare backend') def loop(self): @@ -46,7 +46,9 @@ class FoursquareBackend(Backend): self.bus.post(FoursquareCheckinEvent(checkin=last_checkin)) self._last_created_at = last_checkin_created_at - get_plugin('variable').set(**{self._last_created_at_varname: self._last_created_at}) + get_plugin('variable').set( + **{self._last_created_at_varname: self._last_created_at} + ) # vim:sw=4:ts=4:et: diff --git a/platypush/backend/github/__init__.py b/platypush/backend/github/__init__.py index 6922db391..a66b6364a 100644 --- a/platypush/backend/github/__init__.py +++ b/platypush/backend/github/__init__.py @@ -60,27 +60,6 @@ class GithubBackend(Backend): - ``notifications`` - ``read:org`` if you want to access repositories on organization level. - Triggers: - - - :class:`platypush.message.event.github.GithubPushEvent` when a new push is created. - - :class:`platypush.message.event.github.GithubCommitCommentEvent` when a new commit comment is created. - - :class:`platypush.message.event.github.GithubCreateEvent` when a tag or branch is created. - - :class:`platypush.message.event.github.GithubDeleteEvent` when a tag or branch is deleted. - - :class:`platypush.message.event.github.GithubForkEvent` when a user forks a repository. - - :class:`platypush.message.event.github.GithubWikiEvent` when new activity happens on a repository wiki. - - :class:`platypush.message.event.github.GithubIssueCommentEvent` when new activity happens on an issue comment. - - :class:`platypush.message.event.github.GithubIssueEvent` when new repository issue activity happens. - - :class:`platypush.message.event.github.GithubMemberEvent` when new repository collaborators activity happens. - - :class:`platypush.message.event.github.GithubPublicEvent` when a repository goes public. - - :class:`platypush.message.event.github.GithubPullRequestEvent` when new pull request related activity happens. - - :class:`platypush.message.event.github.GithubPullRequestReviewCommentEvent` when activity happens on a pull - request commit. - - :class:`platypush.message.event.github.GithubReleaseEvent` when a new release happens. - - :class:`platypush.message.event.github.GithubSponsorshipEvent` when new sponsorship related activity happens. - - :class:`platypush.message.event.github.GithubWatchEvent` when someone stars/starts watching a repository. - - :class:`platypush.message.event.github.GithubEvent` for any event that doesn't fall in the above categories - (``event_type`` will be set accordingly). - """ _base_url = 'https://api.github.com' diff --git a/platypush/backend/google/fit/__init__.py b/platypush/backend/google/fit/__init__.py index 486597c18..523db0a8a 100644 --- a/platypush/backend/google/fit/__init__.py +++ b/platypush/backend/google/fit/__init__.py @@ -13,24 +13,24 @@ class GoogleFitBackend(Backend): measurements, new fitness activities etc.) on the specified data streams and fire an event upon new data. - Triggers: - - * :class:`platypush.message.event.google.fit.GoogleFitEvent` when a new - data point is received on one of the registered streams. - Requires: * The **google.fit** plugin (:class:`platypush.plugins.google.fit.GoogleFitPlugin`) enabled. - * The **db** plugin (:class:`platypush.plugins.db`) configured """ _default_poll_seconds = 60 _default_user_id = 'me' _last_timestamp_varname = '_GOOGLE_FIT_LAST_TIMESTAMP_' - def __init__(self, data_sources, user_id=_default_user_id, - poll_seconds=_default_poll_seconds, *args, **kwargs): + def __init__( + self, + data_sources, + user_id=_default_user_id, + poll_seconds=_default_poll_seconds, + *args, + **kwargs + ): """ :param data_sources: Google Fit data source IDs to monitor. You can get a list of the available data sources through the @@ -53,23 +53,31 @@ class GoogleFitBackend(Backend): def run(self): super().run() - self.logger.info('Started Google Fit backend on data sources {}'.format( - self.data_sources)) + self.logger.info( + 'Started Google Fit backend on data sources {}'.format(self.data_sources) + ) while not self.should_stop(): try: for data_source in self.data_sources: varname = self._last_timestamp_varname + data_source - last_timestamp = float(get_plugin('variable'). - get(varname).output.get(varname) or 0) + last_timestamp = float( + get_plugin('variable').get(varname).output.get(varname) or 0 + ) new_last_timestamp = last_timestamp - self.logger.info('Processing new entries from data source {}, last timestamp: {}'. - format(data_source, - str(datetime.datetime.fromtimestamp(last_timestamp)))) + self.logger.info( + 'Processing new entries from data source {}, last timestamp: {}'.format( + data_source, + str(datetime.datetime.fromtimestamp(last_timestamp)), + ) + ) - data_points = get_plugin('google.fit').get_data( - user_id=self.user_id, data_source_id=data_source).output + data_points = ( + get_plugin('google.fit') + .get_data(user_id=self.user_id, data_source_id=data_source) + .output + ) new_data_points = 0 for dp in data_points: @@ -78,25 +86,34 @@ class GoogleFitBackend(Backend): del dp['dataSourceId'] if dp_time > last_timestamp: - self.bus.post(GoogleFitEvent( - user_id=self.user_id, data_source_id=data_source, - data_type=dp.pop('dataTypeName'), - start_time=dp_time, - end_time=dp.pop('endTime'), - modified_time=dp.pop('modifiedTime'), - values=dp.pop('values'), - **{camel_case_to_snake_case(k): v - for k, v in dp.items()} - )) + self.bus.post( + GoogleFitEvent( + user_id=self.user_id, + data_source_id=data_source, + data_type=dp.pop('dataTypeName'), + start_time=dp_time, + end_time=dp.pop('endTime'), + modified_time=dp.pop('modifiedTime'), + values=dp.pop('values'), + **{ + camel_case_to_snake_case(k): v + for k, v in dp.items() + } + ) + ) new_data_points += 1 new_last_timestamp = max(dp_time, new_last_timestamp) last_timestamp = new_last_timestamp - self.logger.info('Got {} new entries from data source {}, last timestamp: {}'. - format(new_data_points, data_source, - str(datetime.datetime.fromtimestamp(last_timestamp)))) + self.logger.info( + 'Got {} new entries from data source {}, last timestamp: {}'.format( + new_data_points, + data_source, + str(datetime.datetime.fromtimestamp(last_timestamp)), + ) + ) get_plugin('variable').set(**{varname: last_timestamp}) except Exception as e: diff --git a/platypush/backend/google/pubsub/__init__.py b/platypush/backend/google/pubsub/__init__.py index 7d1aa984f..689e42056 100644 --- a/platypush/backend/google/pubsub/__init__.py +++ b/platypush/backend/google/pubsub/__init__.py @@ -12,16 +12,6 @@ class GooglePubsubBackend(Backend): Subscribe to a list of topics on a Google Pub/Sub instance. See :class:`platypush.plugins.google.pubsub.GooglePubsubPlugin` for a reference on how to generate your project and credentials file. - - Triggers: - - * :class:`platypush.message.event.google.pubsub.GooglePubsubMessageEvent` when a new message is received on - a subscribed topic. - - Requires: - - * **google-cloud-pubsub** (``pip install google-cloud-pubsub``) - """ def __init__( diff --git a/platypush/backend/gps/__init__.py b/platypush/backend/gps/__init__.py index a9ab9ba32..2c6b9a79b 100644 --- a/platypush/backend/gps/__init__.py +++ b/platypush/backend/gps/__init__.py @@ -9,17 +9,6 @@ class GpsBackend(Backend): """ This backend can interact with a GPS device and listen for events. - Triggers: - - * :class:`platypush.message.event.gps.GPSVersionEvent` when a GPS device advertises its version data - * :class:`platypush.message.event.gps.GPSDeviceEvent` when a GPS device is connected or updated - * :class:`platypush.message.event.gps.GPSUpdateEvent` when a GPS device has new data - - Requires: - - * **gps** (``pip install gps``) - * **gpsd** daemon running (``apt-get install gpsd`` or ``pacman -S gpsd`` depending on your distro) - Once installed gpsd you need to run it and associate it to your device. Example if your GPS device communicates over USB and is available on /dev/ttyUSB0:: @@ -52,41 +41,68 @@ class GpsBackend(Backend): with self._session_lock: if not self._session: - self._session = gps.gps(host=self.gpsd_server, port=self.gpsd_port, reconnect=True) + self._session = gps.gps( + host=self.gpsd_server, port=self.gpsd_port, reconnect=True + ) self._session.stream(gps.WATCH_ENABLE | gps.WATCH_NEWSTYLE) return self._session def _gps_report_to_event(self, report): if report.get('class').lower() == 'version': - return GPSVersionEvent(release=report.get('release'), - rev=report.get('rev'), - proto_major=report.get('proto_major'), - proto_minor=report.get('proto_minor')) + return GPSVersionEvent( + release=report.get('release'), + rev=report.get('rev'), + proto_major=report.get('proto_major'), + proto_minor=report.get('proto_minor'), + ) if report.get('class').lower() == 'devices': for device in report.get('devices', []): - if device.get('path') not in self._devices or device != self._devices.get('path'): + if device.get( + 'path' + ) not in self._devices or device != self._devices.get('path'): # noinspection DuplicatedCode self._devices[device.get('path')] = device - return GPSDeviceEvent(path=device.get('path'), activated=device.get('activated'), - native=device.get('native'), bps=device.get('bps'), - parity=device.get('parity'), stopbits=device.get('stopbits'), - cycle=device.get('cycle'), driver=device.get('driver')) + return GPSDeviceEvent( + path=device.get('path'), + activated=device.get('activated'), + native=device.get('native'), + bps=device.get('bps'), + parity=device.get('parity'), + stopbits=device.get('stopbits'), + cycle=device.get('cycle'), + driver=device.get('driver'), + ) if report.get('class').lower() == 'device': # noinspection DuplicatedCode self._devices[report.get('path')] = report - return GPSDeviceEvent(path=report.get('path'), activated=report.get('activated'), - native=report.get('native'), bps=report.get('bps'), - parity=report.get('parity'), stopbits=report.get('stopbits'), - cycle=report.get('cycle'), driver=report.get('driver')) + return GPSDeviceEvent( + path=report.get('path'), + activated=report.get('activated'), + native=report.get('native'), + bps=report.get('bps'), + parity=report.get('parity'), + stopbits=report.get('stopbits'), + cycle=report.get('cycle'), + driver=report.get('driver'), + ) if report.get('class').lower() == 'tpv': - return GPSUpdateEvent(device=report.get('device'), latitude=report.get('lat'), longitude=report.get('lon'), - altitude=report.get('alt'), mode=report.get('mode'), epv=report.get('epv'), - eph=report.get('eph'), sep=report.get('sep')) + return GPSUpdateEvent( + device=report.get('device'), + latitude=report.get('lat'), + longitude=report.get('lon'), + altitude=report.get('alt'), + mode=report.get('mode'), + epv=report.get('epv'), + eph=report.get('eph'), + sep=report.get('sep'), + ) def run(self): super().run() - self.logger.info('Initialized GPS backend on {}:{}'.format(self.gpsd_server, self.gpsd_port)) + self.logger.info( + 'Initialized GPS backend on {}:{}'.format(self.gpsd_server, self.gpsd_port) + ) last_event = None while not self.should_stop(): @@ -94,15 +110,31 @@ class GpsBackend(Backend): session = self._get_session() report = session.next() event = self._gps_report_to_event(report) - if event and (last_event is None or - abs((last_event.args.get('latitude') or 0) - (event.args.get('latitude') or 0)) >= self._lat_lng_tolerance or - abs((last_event.args.get('longitude') or 0) - (event.args.get('longitude') or 0)) >= self._lat_lng_tolerance or - abs((last_event.args.get('altitude') or 0) - (event.args.get('altitude') or 0)) >= self._alt_tolerance): + if event and ( + last_event is None + or abs( + (last_event.args.get('latitude') or 0) + - (event.args.get('latitude') or 0) + ) + >= self._lat_lng_tolerance + or abs( + (last_event.args.get('longitude') or 0) + - (event.args.get('longitude') or 0) + ) + >= self._lat_lng_tolerance + or abs( + (last_event.args.get('altitude') or 0) + - (event.args.get('altitude') or 0) + ) + >= self._alt_tolerance + ): self.bus.post(event) last_event = event except Exception as e: if isinstance(e, StopIteration): - self.logger.warning('GPS service connection lost, check that gpsd is running') + self.logger.warning( + 'GPS service connection lost, check that gpsd is running' + ) else: self.logger.exception(e) diff --git a/platypush/backend/http/request/rss/__init__.py b/platypush/backend/http/request/rss/__init__.py index 6c624ae49..dc6b2c42f 100644 --- a/platypush/backend/http/request/rss/__init__.py +++ b/platypush/backend/http/request/rss/__init__.py @@ -40,15 +40,6 @@ class RssUpdates(HttpRequest): poll_seconds: 86400 # Poll once a day digest_format: html # Generate an HTML feed with the new items - Triggers: - - - :class:`platypush.message.event.http.rss.NewFeedEvent` when new items are parsed from a feed or a new digest - is available. - - Requires: - - * **feedparser** (``pip install feedparser``) - """ user_agent = ( diff --git a/platypush/backend/inotify/__init__.py b/platypush/backend/inotify/__init__.py deleted file mode 100644 index d8d47bd6e..000000000 --- a/platypush/backend/inotify/__init__.py +++ /dev/null @@ -1,101 +0,0 @@ -import os - -from platypush.backend import Backend -from platypush.message.event.inotify import InotifyCreateEvent, InotifyDeleteEvent, \ - InotifyOpenEvent, InotifyModifyEvent, InotifyCloseEvent, InotifyAccessEvent, InotifyMovedEvent - - -class InotifyBackend(Backend): - """ - **NOTE**: This backend is *deprecated* in favour of :class:`platypush.backend.file.monitor.FileMonitorBackend`. - - (Linux only) This backend will listen for events on the filesystem (whether - a file/directory on a watch list is opened, modified, created, deleted, - closed or had its permissions changed) and will trigger a relevant event. - - Triggers: - - * :class:`platypush.message.event.inotify.InotifyCreateEvent` if a resource is created - * :class:`platypush.message.event.inotify.InotifyAccessEvent` if a resource is accessed - * :class:`platypush.message.event.inotify.InotifyOpenEvent` if a resource is opened - * :class:`platypush.message.event.inotify.InotifyModifyEvent` if a resource is modified - * :class:`platypush.message.event.inotify.InotifyPermissionsChangeEvent` if the permissions of a resource are changed - * :class:`platypush.message.event.inotify.InotifyCloseEvent` if a resource is closed - * :class:`platypush.message.event.inotify.InotifyDeleteEvent` if a resource is removed - - Requires: - - * **inotify** (``pip install inotify``) - - """ - - inotify_watch = None - - def __init__(self, watch_paths=None, **kwargs): - """ - :param watch_paths: Filesystem resources to watch for events - :type watch_paths: str - """ - - super().__init__(**kwargs) - self.watch_paths = set(map( - lambda path: os.path.abspath(os.path.expanduser(path)), - watch_paths if watch_paths else [])) - - def _cleanup(self): - if not self.inotify_watch: - return - - for path in self.watch_paths: - self.inotify_watch.remove_watch(path) - - self.inotify_watch = None - - def run(self): - import inotify.adapters - super().run() - - self.inotify_watch = inotify.adapters.Inotify() - for path in self.watch_paths: - self.inotify_watch.add_watch(path) - - moved_file = None - self.logger.info('Initialized inotify file monitoring backend, monitored resources: {}' - .format(self.watch_paths)) - - try: - for inotify_event in self.inotify_watch.event_gen(): - if inotify_event is not None: - (header, inotify_types, watch_path, filename) = inotify_event - event = None - resource_type = inotify_types[1] if len(inotify_types) > 1 else None - - if moved_file: - new = filename if 'IN_MOVED_TO' in inotify_types else None - event = InotifyMovedEvent(path=watch_path, old=moved_file, new=new) - moved_file = None - - if 'IN_OPEN' in inotify_types: - event = InotifyOpenEvent(path=watch_path, resource=filename, resource_type=resource_type) - elif 'IN_ACCESS' in inotify_types: - event = InotifyAccessEvent(path=watch_path, resource=filename, resource_type=resource_type) - elif 'IN_CREATE' in inotify_types: - event = InotifyCreateEvent(path=watch_path, resource=filename, resource_type=resource_type) - elif 'IN_MOVED_FROM' in inotify_types: - moved_file = filename - elif 'IN_MOVED_TO' in inotify_types and not moved_file: - event = InotifyMovedEvent(path=watch_path, old=None, new=filename) - elif 'IN_DELETE' in inotify_types: - event = InotifyDeleteEvent(path=watch_path, resource=filename, resource_type=resource_type) - elif 'IN_MODIFY' in inotify_types: - event = InotifyModifyEvent(path=watch_path, resource=filename, resource_type=resource_type) - elif 'IN_CLOSE_WRITE' in inotify_types or 'IN_CLOSE_NOWRITE' in inotify_types: - event = InotifyCloseEvent(path=watch_path, resource=filename, resource_type=resource_type) - - if event: - self.bus.post(event) - finally: - self._cleanup() - - -# vim:sw=4:ts=4:et: diff --git a/platypush/backend/inotify/manifest.yaml b/platypush/backend/inotify/manifest.yaml deleted file mode 100644 index d881d0329..000000000 --- a/platypush/backend/inotify/manifest.yaml +++ /dev/null @@ -1,21 +0,0 @@ -manifest: - events: - platypush.message.event.inotify.InotifyAccessEvent: if a resource is accessed - platypush.message.event.inotify.InotifyCloseEvent: if a resource is closed - platypush.message.event.inotify.InotifyCreateEvent: if a resource is created - platypush.message.event.inotify.InotifyDeleteEvent: if a resource is removed - platypush.message.event.inotify.InotifyModifyEvent: if a resource is modified - platypush.message.event.inotify.InotifyOpenEvent: if a resource is opened - platypush.message.event.inotify.InotifyPermissionsChangeEvent: if the permissions - of a resource are changed - install: - apk: - - py3-inotify - apt: - - python3-inotify - dnf: - - python-inotify - pip: - - inotify - package: platypush.backend.inotify - type: backend diff --git a/platypush/backend/joystick/__init__.py b/platypush/backend/joystick/__init__.py index ce962987e..247f9a014 100644 --- a/platypush/backend/joystick/__init__.py +++ b/platypush/backend/joystick/__init__.py @@ -8,14 +8,6 @@ class JoystickBackend(Backend): """ This backend will listen for events from a joystick device and post a JoystickEvent whenever a new event is captured. - - Triggers: - - * :class:`platypush.message.event.joystick.JoystickEvent` when a new joystick event is received - - Requires: - - * **inputs** (``pip install inputs``) """ def __init__(self, device, *args, **kwargs): @@ -32,7 +24,9 @@ class JoystickBackend(Backend): import inputs super().run() - self.logger.info('Initialized joystick backend on device {}'.format(self.device)) + self.logger.info( + 'Initialized joystick backend on device {}'.format(self.device) + ) while not self.should_stop(): try: diff --git a/platypush/backend/joystick/jstest/__init__.py b/platypush/backend/joystick/jstest/__init__.py index e7d3547e3..79bb1a714 100644 --- a/platypush/backend/joystick/jstest/__init__.py +++ b/platypush/backend/joystick/jstest/__init__.py @@ -6,8 +6,14 @@ import time from typing import Optional, List from platypush.backend import Backend -from platypush.message.event.joystick import JoystickConnectedEvent, JoystickDisconnectedEvent, JoystickStateEvent, \ - JoystickButtonPressedEvent, JoystickButtonReleasedEvent, JoystickAxisEvent +from platypush.message.event.joystick import ( + JoystickConnectedEvent, + JoystickDisconnectedEvent, + JoystickStateEvent, + JoystickButtonPressedEvent, + JoystickButtonReleasedEvent, + JoystickAxisEvent, +) class JoystickState: @@ -38,9 +44,7 @@ class JoystickState: }, } - return { - k: v for k, v in diff.items() if v - } + return {k: v for k, v in diff.items() if v} class JoystickJstestBackend(Backend): @@ -49,35 +53,17 @@ class JoystickJstestBackend(Backend): :class:`platypush.backend.joystick.JoystickBackend` backend (this may especially happen with some Bluetooth joysticks that don't support the ``ioctl`` requests used by ``inputs``). - This backend only works on Linux and it requires the ``joystick`` package to be installed. + This backend only works on Linux, and it requires the ``joystick`` package to be installed. **NOTE**: This backend can be quite slow, since it has to run another program (``jstest``) and parse its output. Consider it as a last resort if your joystick works with neither :class:`platypush.backend.joystick.JoystickBackend` nor :class:`platypush.backend.joystick.JoystickLinuxBackend`. - Instructions on Debian-based distros:: - - # apt-get install joystick - - Instructions on Arch-based distros:: - - # pacman -S joyutils - To test if your joystick is compatible, connect it to your device, check for its path (usually under ``/dev/input/js*``) and run:: $ jstest /dev/input/js[n] - Triggers: - - * :class:`platypush.message.event.joystick.JoystickConnectedEvent` when the joystick is connected. - * :class:`platypush.message.event.joystick.JoystickDisconnectedEvent` when the joystick is disconnected. - * :class:`platypush.message.event.joystick.JoystickStateEvent` when the state of the joystick (i.e. some of its - axes or buttons values) changes. - * :class:`platypush.message.event.joystick.JoystickButtonPressedEvent` when a joystick button is pressed. - * :class:`platypush.message.event.joystick.JoystickButtonReleasedEvent` when a joystick button is released. - * :class:`platypush.message.event.joystick.JoystickAxisEvent` when an axis value of the joystick changes. - """ js_axes_regex = re.compile(r'Axes:\s+(((\d+):\s*([\-\d]+)\s*)+)') @@ -85,10 +71,12 @@ class JoystickJstestBackend(Backend): js_axis_regex = re.compile(r'^\s*(\d+):\s*([\-\d]+)\s*(.*)') js_button_regex = re.compile(r'^\s*(\d+):\s*(on|off)\s*(.*)') - def __init__(self, - device: str = '/dev/input/js0', - jstest_path: str = '/usr/bin/jstest', - **kwargs): + def __init__( + self, + device: str = '/dev/input/js0', + jstest_path: str = '/usr/bin/jstest', + **kwargs, + ): """ :param device: Path to the joystick device (default: ``/dev/input/js0``). :param jstest_path: Path to the ``jstest`` executable that comes with the ``joystick`` system package @@ -140,7 +128,11 @@ class JoystickJstestBackend(Backend): if line.endswith('Axes: '): break - while os.path.exists(self.device) and not self.should_stop() and len(axes) < len(self._state.axes): + while ( + os.path.exists(self.device) + and not self.should_stop() + and len(axes) < len(self._state.axes) + ): ch = ' ' while ch == ' ': ch = self._process.stdout.read(1).decode() @@ -174,7 +166,11 @@ class JoystickJstestBackend(Backend): if line.endswith('Buttons: '): break - while os.path.exists(self.device) and not self.should_stop() and len(buttons) < len(self._state.buttons): + while ( + os.path.exists(self.device) + and not self.should_stop() + and len(buttons) < len(self._state.buttons) + ): ch = ' ' while ch == ' ': ch = self._process.stdout.read(1).decode() @@ -195,10 +191,12 @@ class JoystickJstestBackend(Backend): return JoystickState(axes=axes, buttons=buttons) def _initialize(self): - while self._process.poll() is None and \ - os.path.exists(self.device) and \ - not self.should_stop() and \ - not self._state: + while ( + self._process.poll() is None + and os.path.exists(self.device) + and not self.should_stop() + and not self._state + ): line = b'' ch = None @@ -243,7 +241,9 @@ class JoystickJstestBackend(Backend): self.bus.post(JoystickStateEvent(device=self.device, **state.__dict__)) for button, pressed in diff.get('buttons', {}).items(): - evt_class = JoystickButtonPressedEvent if pressed else JoystickButtonReleasedEvent + evt_class = ( + JoystickButtonPressedEvent if pressed else JoystickButtonReleasedEvent + ) self.bus.post(evt_class(device=self.device, button=button)) for axis, value in diff.get('axes', {}).items(): @@ -259,8 +259,8 @@ class JoystickJstestBackend(Backend): self._wait_ready() with subprocess.Popen( - [self.jstest_path, '--normal', self.device], - stdout=subprocess.PIPE) as self._process: + [self.jstest_path, '--normal', self.device], stdout=subprocess.PIPE + ) as self._process: self.logger.info('Device opened') self._initialize() @@ -268,7 +268,9 @@ class JoystickJstestBackend(Backend): break for state in self._read_states(): - if self._process.poll() is not None or not os.path.exists(self.device): + if self._process.poll() is not None or not os.path.exists( + self.device + ): self.logger.warning(f'Connection to {self.device} lost') self.bus.post(JoystickDisconnectedEvent(self.device)) break diff --git a/platypush/backend/joystick/jstest/manifest.yaml b/platypush/backend/joystick/jstest/manifest.yaml index b4f94b68e..d0269b769 100644 --- a/platypush/backend/joystick/jstest/manifest.yaml +++ b/platypush/backend/joystick/jstest/manifest.yaml @@ -1,17 +1,11 @@ manifest: events: - platypush.message.event.joystick.JoystickAxisEvent: when an axis value of the - joystick changes. - platypush.message.event.joystick.JoystickButtonPressedEvent: when a joystick button - is pressed. - platypush.message.event.joystick.JoystickButtonReleasedEvent: when a joystick - button is released. - platypush.message.event.joystick.JoystickConnectedEvent: when the joystick is - connected. - platypush.message.event.joystick.JoystickDisconnectedEvent: when the joystick - is disconnected. - platypush.message.event.joystick.JoystickStateEvent: when the state of the joystick - (i.e. some of itsaxes or buttons values) changes. + - platypush.message.event.joystick.JoystickAxisEvent + - platypush.message.event.joystick.JoystickButtonPressedEvent + - platypush.message.event.joystick.JoystickButtonReleasedEvent + - platypush.message.event.joystick.JoystickConnectedEvent + - platypush.message.event.joystick.JoystickDisconnectedEvent + - platypush.message.event.joystick.JoystickStateEvent install: apk: - linuxconsoletools diff --git a/platypush/backend/joystick/linux/__init__.py b/platypush/backend/joystick/linux/__init__.py index 9130e2a60..61ed80c54 100644 --- a/platypush/backend/joystick/linux/__init__.py +++ b/platypush/backend/joystick/linux/__init__.py @@ -5,8 +5,13 @@ from fcntl import ioctl from typing import IO from platypush.backend import Backend -from platypush.message.event.joystick import JoystickConnectedEvent, JoystickDisconnectedEvent, \ - JoystickButtonPressedEvent, JoystickButtonReleasedEvent, JoystickAxisEvent +from platypush.message.event.joystick import ( + JoystickConnectedEvent, + JoystickDisconnectedEvent, + JoystickButtonPressedEvent, + JoystickButtonReleasedEvent, + JoystickAxisEvent, +) class JoystickLinuxBackend(Backend): @@ -16,15 +21,6 @@ class JoystickLinuxBackend(Backend): It is loosely based on https://gist.github.com/rdb/8864666, which itself uses the `Linux kernel joystick API `_ to interact with the devices. - - Triggers: - - * :class:`platypush.message.event.joystick.JoystickConnectedEvent` when the joystick is connected. - * :class:`platypush.message.event.joystick.JoystickDisconnectedEvent` when the joystick is disconnected. - * :class:`platypush.message.event.joystick.JoystickButtonPressedEvent` when a joystick button is pressed. - * :class:`platypush.message.event.joystick.JoystickButtonReleasedEvent` when a joystick button is released. - * :class:`platypush.message.event.joystick.JoystickAxisEvent` when an axis value of the joystick changes. - """ # These constants were borrowed from linux/input.h @@ -39,7 +35,7 @@ class JoystickLinuxBackend(Backend): 0x07: 'rudder', 0x08: 'wheel', 0x09: 'gas', - 0x0a: 'brake', + 0x0A: 'brake', 0x10: 'hat0x', 0x11: 'hat0y', 0x12: 'hat1x', @@ -50,9 +46,9 @@ class JoystickLinuxBackend(Backend): 0x17: 'hat3y', 0x18: 'pressure', 0x19: 'distance', - 0x1a: 'tilt_x', - 0x1b: 'tilt_y', - 0x1c: 'tool_width', + 0x1A: 'tilt_x', + 0x1B: 'tilt_y', + 0x1C: 'tool_width', 0x20: 'volume', 0x28: 'misc', } @@ -68,9 +64,9 @@ class JoystickLinuxBackend(Backend): 0x127: 'base2', 0x128: 'base3', 0x129: 'base4', - 0x12a: 'base5', - 0x12b: 'base6', - 0x12f: 'dead', + 0x12A: 'base5', + 0x12B: 'base6', + 0x12F: 'dead', 0x130: 'a', 0x131: 'b', 0x132: 'c', @@ -81,20 +77,20 @@ class JoystickLinuxBackend(Backend): 0x137: 'tr', 0x138: 'tl2', 0x139: 'tr2', - 0x13a: 'select', - 0x13b: 'start', - 0x13c: 'mode', - 0x13d: 'thumbl', - 0x13e: 'thumbr', + 0x13A: 'select', + 0x13B: 'start', + 0x13C: 'mode', + 0x13D: 'thumbl', + 0x13E: 'thumbr', 0x220: 'dpad_up', 0x221: 'dpad_down', 0x222: 'dpad_left', 0x223: 'dpad_right', # XBox 360 controller uses these codes. - 0x2c0: 'dpad_left', - 0x2c1: 'dpad_right', - 0x2c2: 'dpad_up', - 0x2c3: 'dpad_down', + 0x2C0: 'dpad_left', + 0x2C1: 'dpad_right', + 0x2C2: 'dpad_up', + 0x2C3: 'dpad_down', } def __init__(self, device: str = '/dev/input/js0', *args, **kwargs): @@ -111,21 +107,21 @@ class JoystickLinuxBackend(Backend): def _init_joystick(self, dev: IO): # Get the device name. buf = array.array('B', [0] * 64) - ioctl(dev, 0x80006a13 + (0x10000 * len(buf)), buf) # JSIOCGNAME(len) + ioctl(dev, 0x80006A13 + (0x10000 * len(buf)), buf) # JSIOCGNAME(len) js_name = buf.tobytes().rstrip(b'\x00').decode('utf-8') # Get number of axes and buttons. buf = array.array('B', [0]) - ioctl(dev, 0x80016a11, buf) # JSIOCGAXES + ioctl(dev, 0x80016A11, buf) # JSIOCGAXES num_axes = buf[0] buf = array.array('B', [0]) - ioctl(dev, 0x80016a12, buf) # JSIOCGBUTTONS + ioctl(dev, 0x80016A12, buf) # JSIOCGBUTTONS num_buttons = buf[0] # Get the axis map. buf = array.array('B', [0] * 0x40) - ioctl(dev, 0x80406a32, buf) # JSIOCGAXMAP + ioctl(dev, 0x80406A32, buf) # JSIOCGAXMAP for axis in buf[:num_axes]: axis_name = self.axis_names.get(axis, 'unknown(0x%02x)' % axis) @@ -134,15 +130,21 @@ class JoystickLinuxBackend(Backend): # Get the button map. buf = array.array('H', [0] * 200) - ioctl(dev, 0x80406a34, buf) # JSIOCGBTNMAP + ioctl(dev, 0x80406A34, buf) # JSIOCGBTNMAP for btn in buf[:num_buttons]: btn_name = self.button_names.get(btn, 'unknown(0x%03x)' % btn) self._button_map.append(btn_name) self._button_states[btn_name] = 0 - self.bus.post(JoystickConnectedEvent(device=self.device, name=js_name, axes=self._axis_map, - buttons=self._button_map)) + self.bus.post( + JoystickConnectedEvent( + device=self.device, + name=js_name, + axes=self._axis_map, + buttons=self._button_map, + ) + ) def run(self): super().run() @@ -151,39 +153,54 @@ class JoystickLinuxBackend(Backend): while not self.should_stop(): # Open the joystick device. try: - jsdev = open(self.device, 'rb') + jsdev = open(self.device, 'rb') # noqa self._init_joystick(jsdev) except Exception as e: - self.logger.debug(f'Joystick device on {self.device} not available: {e}') + self.logger.debug( + 'Joystick device on %s not available: %s', self.device, e + ) time.sleep(5) continue - # Joystick event loop - while not self.should_stop(): - try: - evbuf = jsdev.read(8) - if evbuf: - _, value, evt_type, number = struct.unpack('IhBB', evbuf) + try: + # Joystick event loop + while not self.should_stop(): + try: + evbuf = jsdev.read(8) + if evbuf: + _, value, evt_type, number = struct.unpack('IhBB', evbuf) - if evt_type & 0x80: # Initial state notification - continue + if evt_type & 0x80: # Initial state notification + continue - if evt_type & 0x01: - button = self._button_map[number] - if button: - self._button_states[button] = value - evt_class = JoystickButtonPressedEvent if value else JoystickButtonReleasedEvent - # noinspection PyTypeChecker - self.bus.post(evt_class(device=self.device, button=button)) + if evt_type & 0x01: + button = self._button_map[number] + if button: + self._button_states[button] = value + evt_class = ( + JoystickButtonPressedEvent + if value + else JoystickButtonReleasedEvent + ) + # noinspection PyTypeChecker + self.bus.post( + evt_class(device=self.device, button=button) + ) - if evt_type & 0x02: - axis = self._axis_map[number] - if axis: - fvalue = value / 32767.0 - self._axis_states[axis] = fvalue - # noinspection PyTypeChecker - self.bus.post(JoystickAxisEvent(device=self.device, axis=axis, value=fvalue)) - except OSError as e: - self.logger.warning(f'Connection to {self.device} lost: {e}') - self.bus.post(JoystickDisconnectedEvent(device=self.device)) - break + if evt_type & 0x02: + axis = self._axis_map[number] + if axis: + fvalue = value / 32767.0 + self._axis_states[axis] = fvalue + # noinspection PyTypeChecker + self.bus.post( + JoystickAxisEvent( + device=self.device, axis=axis, value=fvalue + ) + ) + except OSError as e: + self.logger.warning(f'Connection to {self.device} lost: {e}') + self.bus.post(JoystickDisconnectedEvent(device=self.device)) + break + finally: + jsdev.close() diff --git a/platypush/backend/kafka/__init__.py b/platypush/backend/kafka/__init__.py index bd4af2c9d..a86b715cf 100644 --- a/platypush/backend/kafka/__init__.py +++ b/platypush/backend/kafka/__init__.py @@ -11,10 +11,6 @@ class KafkaBackend(Backend): """ Backend to interact with an Apache Kafka (https://kafka.apache.org/) streaming platform, send and receive messages. - - Requires: - - * **kafka** (``pip install kafka-python``) """ _conn_retry_secs = 5 @@ -24,7 +20,9 @@ class KafkaBackend(Backend): :param server: Kafka server name or address + port (default: ``localhost:9092``) :type server: str - :param topic: (Prefix) topic to listen to (default: platypush). The Platypush device_id (by default the hostname) will be appended to the topic (the real topic name will e.g. be "platypush.my_rpi") + :param topic: (Prefix) topic to listen to (default: platypush). The + Platypush device_id (by default the hostname) will be appended to + the topic (the real topic name will e.g. be "platypush.my_rpi") :type topic: str """ @@ -40,7 +38,8 @@ class KafkaBackend(Backend): logging.getLogger('kafka').setLevel(logging.ERROR) def _on_record(self, record): - if record.topic != self.topic: return + if record.topic != self.topic: + return msg = record.value.decode('utf-8') is_platypush_message = False @@ -60,12 +59,12 @@ class KafkaBackend(Backend): def _topic_by_device_id(self, device_id): return '{}.{}'.format(self.topic_prefix, device_id) - def send_message(self, msg, **kwargs): + def send_message(self, msg, **_): target = msg.target kafka_plugin = get_plugin('kafka') - kafka_plugin.send_message(msg=msg, - topic=self._topic_by_device_id(target), - server=self.server) + kafka_plugin.send_message( + msg=msg, topic=self._topic_by_device_id(target), server=self.server + ) def on_stop(self): super().on_stop() @@ -82,21 +81,29 @@ class KafkaBackend(Backend): def run(self): from kafka import KafkaConsumer + super().run() self.consumer = KafkaConsumer(self.topic, bootstrap_servers=self.server) - self.logger.info('Initialized kafka backend - server: {}, topic: {}' - .format(self.server, self.topic)) + self.logger.info( + 'Initialized kafka backend - server: {}, topic: {}'.format( + self.server, self.topic + ) + ) try: for msg in self.consumer: self._on_record(msg) - if self.should_stop(): break + if self.should_stop(): + break except Exception as e: - self.logger.warning('Kafka connection error, reconnecting in {} seconds'. - format(self._conn_retry_secs)) + self.logger.warning( + 'Kafka connection error, reconnecting in {} seconds'.format( + self._conn_retry_secs + ) + ) self.logger.exception(e) time.sleep(self._conn_retry_secs) -# vim:sw=4:ts=4:et: +# vim:sw=4:ts=4:et: diff --git a/platypush/backend/log/http/__init__.py b/platypush/backend/log/http/__init__.py index c2fbb0212..4ba1fad61 100644 --- a/platypush/backend/log/http/__init__.py +++ b/platypush/backend/log/http/__init__.py @@ -7,7 +7,11 @@ from logging import getLogger from threading import RLock from typing import List, Optional, Iterable -from platypush.backend.file.monitor import FileMonitorBackend, EventHandler, MonitoredResource +from platypush.backend.file.monitor import ( + FileMonitorBackend, + EventHandler, + MonitoredResource, +) from platypush.context import get_bus from platypush.message.event.log.http import HttpLogEvent @@ -15,8 +19,10 @@ logger = getLogger(__name__) class LogEventHandler(EventHandler): - http_line_regex = re.compile(r'^([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+\[([^]]+)]\s+"([^"]+)"\s+([\d]+)\s+' - r'([\d]+)\s*("([^"\s]+)")?\s*("([^"]+)")?$') + http_line_regex = re.compile( + r'^([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+\[([^]]+)]\s+"([^"]+)"\s+([\d]+)\s+' + r'([\d]+)\s*("([^"\s]+)")?\s*("([^"]+)")?$' + ) @dataclass class FileResource: @@ -25,16 +31,17 @@ class LogEventHandler(EventHandler): lock: RLock = RLock() last_timestamp: Optional[datetime.datetime] = None - def __init__(self, *args, monitored_files: Optional[Iterable[str]] = None, **kwargs): + def __init__( + self, *args, monitored_files: Optional[Iterable[str]] = None, **kwargs + ): super().__init__(*args, **kwargs) self._monitored_files = {} self.monitor_files(monitored_files or []) def monitor_files(self, files: Iterable[str]): - self._monitored_files.update({ - f: self.FileResource(path=f, pos=self._get_size(f)) - for f in files - }) + self._monitored_files.update( + {f: self.FileResource(path=f, pos=self._get_size(f)) for f in files} + ) @staticmethod def _get_size(file: str) -> int: @@ -68,12 +75,17 @@ class LogEventHandler(EventHandler): try: file_size = os.path.getsize(event.src_path) except OSError as e: - logger.warning('Could not get the size of {}: {}'.format(event.src_path, str(e))) + logger.warning( + 'Could not get the size of {}: {}'.format(event.src_path, str(e)) + ) return if file_info.pos > file_size: - logger.warning('The size of {} been unexpectedly decreased from {} to {} bytes'.format( - event.src_path, file_info.pos, file_size)) + logger.warning( + 'The size of {} been unexpectedly decreased from {} to {} bytes'.format( + event.src_path, file_info.pos, file_size + ) + ) file_info.pos = 0 try: @@ -81,13 +93,18 @@ class LogEventHandler(EventHandler): f.seek(file_info.pos) for line in f.readlines(): evt = self._build_event(file=event.src_path, line=line) - if evt and (not file_info.last_timestamp or evt.args['time'] >= file_info.last_timestamp): + if evt and ( + not file_info.last_timestamp + or evt.args['time'] >= file_info.last_timestamp + ): get_bus().post(evt) file_info.last_timestamp = evt.args['time'] file_info.pos = f.tell() except OSError as e: - logger.warning('Error while reading from {}: {}'.format(self.resource.path, str(e))) + logger.warning( + 'Error while reading from {}: {}'.format(self.resource.path, str(e)) + ) @classmethod def _build_event(cls, file: str, line: str) -> Optional[HttpLogEvent]: @@ -139,15 +156,6 @@ class LogHttpBackend(FileMonitorBackend): """ This backend can be used to monitor one or more HTTP log files (tested on Apache and Nginx) and trigger events whenever a new log line is added. - - Triggers: - - * :class:`platypush.message.event.log.http.HttpLogEvent` when a new log line is created. - - Requires: - - * **watchdog** (``pip install watchdog``) - """ class EventHandlerFactory: diff --git a/platypush/backend/mail/__init__.py b/platypush/backend/mail/__init__.py index 5dd4474fe..1616c2337 100644 --- a/platypush/backend/mail/__init__.py +++ b/platypush/backend/mail/__init__.py @@ -60,14 +60,6 @@ class MailBackend(Backend): It requires at least one plugin that extends :class:`platypush.plugins.mail.MailInPlugin` (e.g. ``mail.imap``) to be installed. - - Triggers: - - - :class:`platypush.message.event.mail.MailReceivedEvent` when a new message is received. - - :class:`platypush.message.event.mail.MailSeenEvent` when a message is marked as seen. - - :class:`platypush.message.event.mail.MailFlaggedEvent` when a message is marked as flagged/starred. - - :class:`platypush.message.event.mail.MailUnflaggedEvent` when a message is marked as unflagged/unstarred. - """ def __init__( diff --git a/platypush/backend/midi/__init__.py b/platypush/backend/midi/__init__.py index 30db06ac0..c396c13c9 100644 --- a/platypush/backend/midi/__init__.py +++ b/platypush/backend/midi/__init__.py @@ -10,18 +10,16 @@ class MidiBackend(Backend): """ This backend will listen for events from a MIDI device and post a MidiMessageEvent whenever a new MIDI event happens. - - Triggers: - - * :class:`platypush.message.event.midi.MidiMessageEvent` when a new MIDI event is received - - Requires: - - * **rtmidi** (``pip install rtmidi``) """ - def __init__(self, device_name=None, port_number=None, - midi_throttle_time=None, *args, **kwargs): + def __init__( + self, + device_name=None, + port_number=None, + midi_throttle_time=None, + *args, + **kwargs + ): """ :param device_name: Name of the MIDI device. *N.B.* either `device_name` or `port_number` must be set. @@ -40,12 +38,16 @@ class MidiBackend(Backend): """ import rtmidi + super().__init__(*args, **kwargs) - if (device_name and port_number is not None) or \ - (not device_name and port_number is None): - raise RuntimeError('Either device_name or port_number (not both) ' + - 'must be set in the MIDI backend configuration') + if (device_name and port_number is not None) or ( + not device_name and port_number is None + ): + raise RuntimeError( + 'Either device_name or port_number (not both) ' + + 'must be set in the MIDI backend configuration' + ) self.midi_throttle_time = midi_throttle_time self.midi = rtmidi.MidiIn() @@ -75,9 +77,12 @@ class MidiBackend(Backend): def _on_midi_message(self): def flush_midi_message(message): def _f(): - self.logger.info('Flushing throttled MIDI message {} to the bus'.format(message)) + self.logger.info( + 'Flushing throttled MIDI message {} to the bus'.format(message) + ) delay = time.time() - self.last_trigger_event_time self.bus.post(MidiMessageEvent(message=message, delay=delay)) + return _f # noinspection PyUnusedLocal @@ -95,8 +100,9 @@ class MidiBackend(Backend): self.midi_flush_timeout.cancel() self.midi_flush_timeout = Timer( - self.midi_throttle_time-event_delta, - flush_midi_message(message)) + self.midi_throttle_time - event_delta, + flush_midi_message(message), + ) self.midi_flush_timeout.start() return @@ -110,8 +116,11 @@ class MidiBackend(Backend): super().run() self.midi.open_port(self.port_number) - self.logger.info('Initialized MIDI backend, listening for events on device {}'. - format(self.device_name)) + self.logger.info( + 'Initialized MIDI backend, listening for events on device {}'.format( + self.device_name + ) + ) while not self.should_stop(): try: diff --git a/platypush/backend/mqtt/__init__.py b/platypush/backend/mqtt/__init__.py deleted file mode 100644 index 2619ce7db..000000000 --- a/platypush/backend/mqtt/__init__.py +++ /dev/null @@ -1,475 +0,0 @@ -import hashlib -import json -import os -import threading -from typing import Any, Dict, Optional, List, Callable - -import paho.mqtt.client as mqtt - -from platypush.backend import Backend -from platypush.config import Config -from platypush.context import get_plugin -from platypush.message import Message -from platypush.message.event.mqtt import MQTTMessageEvent -from platypush.message.request import Request -from platypush.plugins.mqtt import MqttPlugin as MQTTPlugin - - -class MqttClient(mqtt.Client, threading.Thread): - """ - Wrapper class for an MQTT client executed in a separate thread. - """ - - def __init__( - self, - *args, - host: str, - port: int, - topics: Optional[List[str]] = None, - on_message: Optional[Callable] = None, - username: Optional[str] = None, - password: Optional[str] = None, - client_id: Optional[str] = None, - tls_cafile: Optional[str] = None, - tls_certfile: Optional[str] = None, - tls_keyfile: Optional[str] = None, - tls_version=None, - tls_ciphers=None, - tls_insecure: bool = False, - keepalive: Optional[int] = 60, - **kwargs, - ): - mqtt.Client.__init__(self, *args, client_id=client_id, **kwargs) - threading.Thread.__init__(self) - - self.name = f'MQTTClient:{client_id}' - self.host = host - self.port = port - self.topics = set(topics or []) - self.keepalive = keepalive - self.on_connect = self.connect_hndl() - - if on_message: - self.on_message = on_message - - if username and password: - self.username_pw_set(username, password) - - if tls_cafile: - self.tls_set( - ca_certs=tls_cafile, - certfile=tls_certfile, - keyfile=tls_keyfile, - tls_version=tls_version, - ciphers=tls_ciphers, - ) - - self.tls_insecure_set(tls_insecure) - - self._running = False - self._stop_scheduled = False - - def subscribe(self, *topics, **kwargs): - """ - Client subscription handler. - """ - if not topics: - topics = self.topics - - self.topics.update(topics) - for topic in topics: - super().subscribe(topic, **kwargs) - - def unsubscribe(self, *topics, **kwargs): - """ - Client unsubscribe handler. - """ - if not topics: - topics = self.topics - - for topic in topics: - super().unsubscribe(topic, **kwargs) - self.topics.remove(topic) - - def connect_hndl(self): - def handler(*_, **__): - self.subscribe() - - return handler - - def run(self): - super().run() - self.connect(host=self.host, port=self.port, keepalive=self.keepalive) - self._running = True - self.loop_forever() - - def stop(self): - if not self.is_alive(): - return - - self._stop_scheduled = True - self.disconnect() - self._running = False - - -class MqttBackend(Backend): - """ - Backend that reads messages from a configured MQTT topic (default: - ``platypush_bus_mq/``) and posts them to the application bus. - - Triggers: - - * :class:`platypush.message.event.mqtt.MQTTMessageEvent` when a new - message is received on one of the custom listeners - - Requires: - - * **paho-mqtt** (``pip install paho-mqtt``) - """ - - _default_mqtt_port = 1883 - - def __init__( - self, - *args, - host: Optional[str] = None, - port: int = _default_mqtt_port, - topic: str = 'platypush_bus_mq', - subscribe_default_topic: bool = True, - tls_cafile: Optional[str] = None, - tls_certfile: Optional[str] = None, - tls_keyfile: Optional[str] = None, - tls_version: Optional[str] = None, - tls_ciphers: Optional[str] = None, - tls_insecure: bool = False, - username: Optional[str] = None, - password: Optional[str] = None, - client_id: Optional[str] = None, - listeners=None, - **kwargs, - ): - """ - :param host: MQTT broker host. If no host configuration is specified then - the backend will use the host configuration specified on the ``mqtt`` - plugin if it's available. - :param port: MQTT broker port (default: 1883) - :param topic: Topic to read messages from (default: ``platypush_bus_mq/``) - :param subscribe_default_topic: Whether the backend should subscribe the default topic (default: - ``platypush_bus_mq/``) and execute the messages received there as action requests - (default: True). - :param tls_cafile: If TLS/SSL is enabled on the MQTT server and the certificate requires a certificate authority - to authenticate it, `ssl_cafile` will point to the provided ca.crt file (default: None) - :param tls_certfile: If TLS/SSL is enabled on the MQTT server and a client certificate it required, specify it - here (default: None) - :param tls_keyfile: If TLS/SSL is enabled on the MQTT server and a client certificate key it required, - specify it here (default: None) :type tls_keyfile: str - :param tls_version: If TLS/SSL is enabled on the MQTT server and it requires a certain TLS version, specify it - here (default: None). Supported versions: ``tls`` (automatic), ``tlsv1``, ``tlsv1.1``, ``tlsv1.2``. - :param tls_ciphers: If TLS/SSL is enabled on the MQTT server and an explicit list of supported ciphers is - required, specify it here (default: None) - :param tls_insecure: Set to True to ignore TLS insecure warnings (default: False). - :param username: Specify it if the MQTT server requires authentication (default: None) - :param password: Specify it if the MQTT server requires authentication (default: None) - :param client_id: ID used to identify the client on the MQTT server (default: None). - If None is specified then ``Config.get('device_id')`` will be used. - :param listeners: If specified then the MQTT backend will also listen for - messages on the additional configured message queues. This parameter - is a list of maps where each item supports the same arguments passed - to the main backend configuration (host, port, topic, password etc.). - Note that the message queue configured on the main configuration - will expect valid Platypush messages that then can execute, while - message queues registered to the listeners will accept any message. Example:: - - listeners: - - host: localhost - topics: - - topic1 - - topic2 - - topic3 - - host: sensors - topics: - - topic4 - - topic5 - - """ - - super().__init__(*args, **kwargs) - - if host: - self.host = host - self.port = port - self.tls_cafile = self._expandpath(tls_cafile) if tls_cafile else None - self.tls_certfile = self._expandpath(tls_certfile) if tls_certfile else None - self.tls_keyfile = self._expandpath(tls_keyfile) if tls_keyfile else None - self.tls_version = MQTTPlugin.get_tls_version(tls_version) - self.tls_ciphers = tls_ciphers - self.tls_insecure = tls_insecure - self.username = username - self.password = password - self.client_id: str = client_id or Config.get('device_id') - else: - client = get_plugin('mqtt') - assert ( - client.host - ), 'No host specified on backend.mqtt nor mqtt configuration' - - self.host = client.host - self.port = client.port - self.tls_cafile = client.tls_cafile - self.tls_certfile = client.tls_certfile - self.tls_keyfile = client.tls_keyfile - self.tls_version = client.tls_version - self.tls_ciphers = client.tls_ciphers - self.tls_insecure = client.tls_insecure - self.username = client.username - self.password = client.password - self.client_id = client_id or client.client_id - - self.topic = f'{topic}/{self.device_id}' - self.subscribe_default_topic = subscribe_default_topic - self._listeners: Dict[str, MqttClient] = {} # client_id -> MqttClient map - self.listeners_conf = listeners or [] - - def send_message(self, msg, *_, topic: Optional[str] = None, **kwargs): - try: - client = get_plugin('mqtt') - client.send_message( - topic=topic or self.topic, - msg=msg, - host=self.host, - port=self.port, - username=self.username, - password=self.password, - tls_cafile=self.tls_cafile, - tls_certfile=self.tls_certfile, - tls_keyfile=self.tls_keyfile, - tls_version=self.tls_version, - tls_insecure=self.tls_insecure, - tls_ciphers=self.tls_ciphers, - **kwargs, - ) - except Exception as e: - self.logger.exception(e) - - @staticmethod - def _expandpath(path: str) -> str: - return os.path.abspath(os.path.expanduser(path)) if path else path - - def add_listeners(self, *listeners): - for i, listener in enumerate(listeners): - host = listener.get('host', self.host) - port = listener.get('port', self.port) - username = listener.get('username', self.username) - password = listener.get('password', self.password) - tls_cafile = self._expandpath(listener.get('tls_cafile', self.tls_cafile)) - tls_certfile = self._expandpath( - listener.get('tls_certfile', self.tls_certfile) - ) - tls_keyfile = self._expandpath( - listener.get('tls_keyfile', self.tls_keyfile) - ) - tls_version = MQTTPlugin.get_tls_version( - listener.get('tls_version', self.tls_version) - ) - tls_ciphers = listener.get('tls_ciphers', self.tls_ciphers) - tls_insecure = listener.get('tls_insecure', self.tls_insecure) - topics = listener.get('topics') - - if not topics: - self.logger.warning( - 'No list of topics specified for listener n.%d', i + 1 - ) - continue - - client = self._get_client( - host=host, - port=port, - topics=topics, - username=username, - password=password, - client_id=self.client_id, - tls_cafile=tls_cafile, - tls_certfile=tls_certfile, - tls_keyfile=tls_keyfile, - tls_version=tls_version, - tls_ciphers=tls_ciphers, - tls_insecure=tls_insecure, - ) - - if not client.is_alive(): - client.start() - - def _get_client_id( - self, - host: str, - port: int, - topics: Optional[List[str]] = None, - client_id: Optional[str] = None, - on_message: Optional[Callable[[MqttClient, Any, mqtt.MQTTMessage], Any]] = None, - ) -> str: - client_id = client_id or self.client_id - client_hash = hashlib.sha1( - '|'.join( - [ - host, - str(port), - json.dumps(sorted(topics or [])), - str(id(on_message)), - ] - ).encode() - ).hexdigest() - - return f'{client_id}-{client_hash}' - - def _get_client( - self, - host: str, - port: int, - topics: Optional[List[str]] = None, - username: Optional[str] = None, - password: Optional[str] = None, - client_id: Optional[str] = None, - tls_cafile: Optional[str] = None, - tls_certfile: Optional[str] = None, - tls_keyfile: Optional[str] = None, - tls_version=None, - tls_ciphers=None, - tls_insecure: bool = False, - on_message: Optional[Callable] = None, - ) -> MqttClient: - on_message = on_message or self.on_mqtt_message() - client_id = self._get_client_id( - host=host, - port=port, - topics=topics, - client_id=client_id, - on_message=on_message, - ) - client = self._listeners.get(client_id) - - if not (client and client.is_alive()): - client = self._listeners[client_id] = MqttClient( - host=host, - port=port, - topics=topics, - username=username, - password=password, - client_id=client_id, - tls_cafile=tls_cafile, - tls_certfile=tls_certfile, - tls_keyfile=tls_keyfile, - tls_version=tls_version, - tls_ciphers=tls_ciphers, - tls_insecure=tls_insecure, - on_message=on_message, - ) - - if topics: - client.subscribe(*topics) - - return client - - def on_mqtt_message(self): - def handler(client: MqttClient, _, msg: mqtt.MQTTMessage): - data = msg.payload - try: - data = data.decode('utf-8') - data = json.loads(data) - except Exception as e: - self.logger.debug(str(e)) - - self.bus.post( - MQTTMessageEvent( - host=client.host, port=client.port, topic=msg.topic, msg=data - ) - ) - - return handler - - def on_exec_message(self): - def handler(_, __, msg: mqtt.MQTTMessage): - def response_thread(msg): - response = self.get_message_response(msg) - if not response: - return - response_topic = f'{self.topic}/responses/{msg.id}' - - self.logger.info( - 'Processing response on the MQTT topic %s: %s', - response_topic, - response, - ) - - self.send_message(response, topic=response_topic) - - msg = msg.payload.decode('utf-8') - try: - msg = json.loads(msg) - msg = Message.build(msg) - except Exception as e: - self.logger.debug(str(e)) - - if not msg: - return - - self.logger.info('Received message on the MQTT backend: %s', msg) - - try: - self.on_message(msg) - except Exception as e: - self.logger.exception(e) - return - - if isinstance(msg, Request): - threading.Thread( - target=response_thread, - name='MQTTProcessorResponseThread', - args=(msg,), - ).start() - - return handler - - def run(self): - super().run() - - if self.host and self.subscribe_default_topic: - topics = [self.topic] - client = self._get_client( - host=self.host, - port=self.port, - topics=topics, - username=self.username, - password=self.password, - client_id=self.client_id, - tls_cafile=self.tls_cafile, - tls_certfile=self.tls_certfile, - tls_keyfile=self.tls_keyfile, - tls_version=self.tls_version, - tls_ciphers=self.tls_ciphers, - tls_insecure=self.tls_insecure, - on_message=self.on_exec_message(), - ) - - client.start() - self.logger.info( - 'Initialized MQTT backend on host %s:%d, topic=%s', - self.host, - self.port, - self.topic, - ) - - self.add_listeners(*self.listeners_conf) - - def on_stop(self): - self.logger.info('Received STOP event on the MQTT backend') - - for listener in self._listeners.values(): - try: - listener.stop() - except Exception as e: - self.logger.warning('Could not stop MQTT listener: %s', e) - - self.logger.info('MQTT backend terminated') - - -# vim:sw=4:ts=4:et: diff --git a/platypush/backend/mqtt/manifest.yaml b/platypush/backend/mqtt/manifest.yaml deleted file mode 100644 index c1697bfef..000000000 --- a/platypush/backend/mqtt/manifest.yaml +++ /dev/null @@ -1,17 +0,0 @@ -manifest: - events: - platypush.message.event.mqtt.MQTTMessageEvent: when a newmessage is received on - one of the custom listeners - install: - apk: - - py3-paho-mqtt - dnf: - - python-paho-mqtt - pacman: - - python-paho-mqtt - apt: - - python3-paho-mqtt - pip: - - paho-mqtt - package: platypush.backend.mqtt - type: backend diff --git a/platypush/backend/music/mopidy/__init__.py b/platypush/backend/music/mopidy/__init__.py index 0448038af..6914c9317 100644 --- a/platypush/backend/music/mopidy/__init__.py +++ b/platypush/backend/music/mopidy/__init__.py @@ -5,11 +5,20 @@ import threading import websocket from platypush.backend import Backend -from platypush.message.event.music import MusicPlayEvent, MusicPauseEvent, \ - MusicStopEvent, NewPlayingTrackEvent, PlaylistChangeEvent, VolumeChangeEvent, \ - PlaybackConsumeModeChangeEvent, PlaybackSingleModeChangeEvent, \ - PlaybackRepeatModeChangeEvent, PlaybackRandomModeChangeEvent, \ - MuteChangeEvent, SeekChangeEvent +from platypush.message.event.music import ( + MusicPlayEvent, + MusicPauseEvent, + MusicStopEvent, + NewPlayingTrackEvent, + PlaylistChangeEvent, + VolumeChangeEvent, + PlaybackConsumeModeChangeEvent, + PlaybackSingleModeChangeEvent, + PlaybackRepeatModeChangeEvent, + PlaybackRandomModeChangeEvent, + MuteChangeEvent, + SeekChangeEvent, +) # noinspection PyUnusedLocal @@ -22,20 +31,10 @@ class MusicMopidyBackend(Backend): solution if you're not running Mopidy or your instance has the websocket interface or web port disabled. - Triggers: - - * :class:`platypush.message.event.music.MusicPlayEvent` if the playback state changed to play - * :class:`platypush.message.event.music.MusicPauseEvent` if the playback state changed to pause - * :class:`platypush.message.event.music.MusicStopEvent` if the playback state changed to stop - * :class:`platypush.message.event.music.NewPlayingTrackEvent` if a new track is being played - * :class:`platypush.message.event.music.PlaylistChangeEvent` if the main playlist has changed - * :class:`platypush.message.event.music.VolumeChangeEvent` if the main volume has changed - * :class:`platypush.message.event.music.MuteChangeEvent` if the mute status has changed - * :class:`platypush.message.event.music.SeekChangeEvent` if a track seek event occurs - Requires: - * Mopidy installed and the HTTP service enabled + * A Mopidy instance running with the HTTP service enabled. + """ def __init__(self, host='localhost', port=6680, **kwargs): @@ -77,8 +76,11 @@ class MusicMopidyBackend(Backend): conv_track['album'] = conv_track['album']['name'] if 'length' in conv_track: - conv_track['time'] = conv_track['length']/1000 \ - if conv_track['length'] else conv_track['length'] + conv_track['time'] = ( + conv_track['length'] / 1000 + if conv_track['length'] + else conv_track['length'] + ) del conv_track['length'] if pos is not None: @@ -90,7 +92,6 @@ class MusicMopidyBackend(Backend): return conv_track def _communicate(self, msg): - if isinstance(msg, str): msg = json.loads(msg) @@ -107,14 +108,10 @@ class MusicMopidyBackend(Backend): def _get_tracklist_status(self): return { - 'repeat': self._communicate({ - 'method': 'core.tracklist.get_repeat'}), - 'random': self._communicate({ - 'method': 'core.tracklist.get_random'}), - 'single': self._communicate({ - 'method': 'core.tracklist.get_single'}), - 'consume': self._communicate({ - 'method': 'core.tracklist.get_consume'}), + 'repeat': self._communicate({'method': 'core.tracklist.get_repeat'}), + 'random': self._communicate({'method': 'core.tracklist.get_random'}), + 'single': self._communicate({'method': 'core.tracklist.get_single'}), + 'consume': self._communicate({'method': 'core.tracklist.get_consume'}), } def _on_msg(self): @@ -133,19 +130,25 @@ class MusicMopidyBackend(Backend): track = self._parse_track(track) if not track: return - self.bus.post(MusicPauseEvent(status=status, track=track, plugin_name='music.mpd')) + self.bus.post( + MusicPauseEvent(status=status, track=track, plugin_name='music.mpd') + ) elif event == 'track_playback_resumed': status['state'] = 'play' track = self._parse_track(track) if not track: return - self.bus.post(MusicPlayEvent(status=status, track=track, plugin_name='music.mpd')) + self.bus.post( + MusicPlayEvent(status=status, track=track, plugin_name='music.mpd') + ) elif event == 'track_playback_ended' or ( - event == 'playback_state_changed' - and msg.get('new_state') == 'stopped'): + event == 'playback_state_changed' and msg.get('new_state') == 'stopped' + ): status['state'] = 'stop' track = self._parse_track(track) - self.bus.post(MusicStopEvent(status=status, track=track, plugin_name='music.mpd')) + self.bus.post( + MusicStopEvent(status=status, track=track, plugin_name='music.mpd') + ) elif event == 'track_playback_started': track = self._parse_track(track) if not track: @@ -154,9 +157,13 @@ class MusicMopidyBackend(Backend): status['state'] = 'play' status['position'] = 0.0 status['time'] = track.get('time') - self.bus.post(NewPlayingTrackEvent(status=status, track=track, plugin_name='music.mpd')) + self.bus.post( + NewPlayingTrackEvent( + status=status, track=track, plugin_name='music.mpd' + ) + ) elif event == 'stream_title_changed': - m = re.match('^\s*(.+?)\s+-\s+(.*)\s*$', msg.get('title', '')) + m = re.match(r'^\s*(.+?)\s+-\s+(.*)\s*$', msg.get('title', '')) if not m: return @@ -164,35 +171,78 @@ class MusicMopidyBackend(Backend): track['title'] = m.group(2) status['state'] = 'play' status['position'] = 0.0 - self.bus.post(NewPlayingTrackEvent(status=status, track=track, plugin_name='music.mpd')) + self.bus.post( + NewPlayingTrackEvent( + status=status, track=track, plugin_name='music.mpd' + ) + ) elif event == 'volume_changed': status['volume'] = msg.get('volume') - self.bus.post(VolumeChangeEvent(volume=status['volume'], status=status, track=track, - plugin_name='music.mpd')) + self.bus.post( + VolumeChangeEvent( + volume=status['volume'], + status=status, + track=track, + plugin_name='music.mpd', + ) + ) elif event == 'mute_changed': status['mute'] = msg.get('mute') - self.bus.post(MuteChangeEvent(mute=status['mute'], status=status, track=track, - plugin_name='music.mpd')) + self.bus.post( + MuteChangeEvent( + mute=status['mute'], + status=status, + track=track, + plugin_name='music.mpd', + ) + ) elif event == 'seeked': - status['position'] = msg.get('time_position')/1000 - self.bus.post(SeekChangeEvent(position=status['position'], status=status, track=track, - plugin_name='music.mpd')) + status['position'] = msg.get('time_position') / 1000 + self.bus.post( + SeekChangeEvent( + position=status['position'], + status=status, + track=track, + plugin_name='music.mpd', + ) + ) elif event == 'tracklist_changed': - tracklist = [self._parse_track(t, pos=i) - for i, t in enumerate(self._communicate({ - 'method': 'core.tracklist.get_tl_tracks'}))] + tracklist = [ + self._parse_track(t, pos=i) + for i, t in enumerate( + self._communicate({'method': 'core.tracklist.get_tl_tracks'}) + ) + ] - self.bus.post(PlaylistChangeEvent(changes=tracklist, plugin_name='music.mpd')) + self.bus.post( + PlaylistChangeEvent(changes=tracklist, plugin_name='music.mpd') + ) elif event == 'options_changed': new_status = self._get_tracklist_status() if new_status['random'] != self._latest_status.get('random'): - self.bus.post(PlaybackRandomModeChangeEvent(state=new_status['random'], plugin_name='music.mpd')) + self.bus.post( + PlaybackRandomModeChangeEvent( + state=new_status['random'], plugin_name='music.mpd' + ) + ) if new_status['repeat'] != self._latest_status['repeat']: - self.bus.post(PlaybackRepeatModeChangeEvent(state=new_status['repeat'], plugin_name='music.mpd')) + self.bus.post( + PlaybackRepeatModeChangeEvent( + state=new_status['repeat'], plugin_name='music.mpd' + ) + ) if new_status['single'] != self._latest_status['single']: - self.bus.post(PlaybackSingleModeChangeEvent(state=new_status['single'], plugin_name='music.mpd')) + self.bus.post( + PlaybackSingleModeChangeEvent( + state=new_status['single'], plugin_name='music.mpd' + ) + ) if new_status['consume'] != self._latest_status['consume']: - self.bus.post(PlaybackConsumeModeChangeEvent(state=new_status['consume'], plugin_name='music.mpd')) + self.bus.post( + PlaybackConsumeModeChangeEvent( + state=new_status['consume'], plugin_name='music.mpd' + ) + ) self._latest_status = new_status @@ -204,7 +254,7 @@ class MusicMopidyBackend(Backend): try: self._connect() except Exception as e: - self.logger.warning('Error on websocket reconnection: '.format(str(e))) + self.logger.warning('Error on websocket reconnection: %s', e) self._connected_event.wait(timeout=10) @@ -244,17 +294,23 @@ class MusicMopidyBackend(Backend): def _connect(self): if not self._ws: - self._ws = websocket.WebSocketApp(self.url, - on_open=self._on_open(), - on_message=self._on_msg(), - on_error=self._on_error(), - on_close=self._on_close()) + self._ws = websocket.WebSocketApp( + self.url, + on_open=self._on_open(), + on_message=self._on_msg(), + on_error=self._on_error(), + on_close=self._on_close(), + ) self._ws.run_forever() def run(self): super().run() - self.logger.info('Started tracking Mopidy events backend on {}:{}'.format(self.host, self.port)) + self.logger.info( + 'Started tracking Mopidy events backend on {}:{}'.format( + self.host, self.port + ) + ) self._connect() def on_stop(self): diff --git a/platypush/backend/music/mpd/__init__.py b/platypush/backend/music/mpd/__init__.py index 6ad71881c..30ced92ff 100644 --- a/platypush/backend/music/mpd/__init__.py +++ b/platypush/backend/music/mpd/__init__.py @@ -2,28 +2,28 @@ import time from platypush.backend import Backend from platypush.context import get_plugin -from platypush.message.event.music import MusicPlayEvent, MusicPauseEvent, \ - MusicStopEvent, NewPlayingTrackEvent, PlaylistChangeEvent, VolumeChangeEvent, \ - PlaybackConsumeModeChangeEvent, PlaybackSingleModeChangeEvent, \ - PlaybackRepeatModeChangeEvent, PlaybackRandomModeChangeEvent +from platypush.message.event.music import ( + MusicPlayEvent, + MusicPauseEvent, + MusicStopEvent, + NewPlayingTrackEvent, + PlaylistChangeEvent, + VolumeChangeEvent, + PlaybackConsumeModeChangeEvent, + PlaybackSingleModeChangeEvent, + PlaybackRepeatModeChangeEvent, + PlaybackRandomModeChangeEvent, +) class MusicMpdBackend(Backend): """ This backend listens for events on a MPD/Mopidy music server. - Triggers: - - * :class:`platypush.message.event.music.MusicPlayEvent` if the playback state changed to play - * :class:`platypush.message.event.music.MusicPauseEvent` if the playback state changed to pause - * :class:`platypush.message.event.music.MusicStopEvent` if the playback state changed to stop - * :class:`platypush.message.event.music.NewPlayingTrackEvent` if a new track is being played - * :class:`platypush.message.event.music.PlaylistChangeEvent` if the main playlist has changed - * :class:`platypush.message.event.music.VolumeChangeEvent` if the main volume has changed - Requires: - * **python-mpd2** (``pip install python-mpd2``) - * The :mod:`platypush.plugins.music.mpd` plugin to be configured + + * :class:`platypush.plugins.music.mpd.MusicMpdPlugin` configured + """ def __init__(self, server='localhost', port=6600, poll_seconds=3, **kwargs): @@ -81,11 +81,23 @@ class MusicMpdBackend(Backend): if state != last_state: if state == 'stop': - self.bus.post(MusicStopEvent(status=status, track=track, plugin_name='music.mpd')) + self.bus.post( + MusicStopEvent( + status=status, track=track, plugin_name='music.mpd' + ) + ) elif state == 'pause': - self.bus.post(MusicPauseEvent(status=status, track=track, plugin_name='music.mpd')) + self.bus.post( + MusicPauseEvent( + status=status, track=track, plugin_name='music.mpd' + ) + ) elif state == 'play': - self.bus.post(MusicPlayEvent(status=status, track=track, plugin_name='music.mpd')) + self.bus.post( + MusicPlayEvent( + status=status, track=track, plugin_name='music.mpd' + ) + ) if playlist != last_playlist: if last_playlist: @@ -97,31 +109,66 @@ class MusicMpdBackend(Backend): last_playlist = playlist if state == 'play' and track != last_track: - self.bus.post(NewPlayingTrackEvent(status=status, track=track, plugin_name='music.mpd')) + self.bus.post( + NewPlayingTrackEvent( + status=status, track=track, plugin_name='music.mpd' + ) + ) - if last_status.get('volume', None) != status['volume']: - self.bus.post(VolumeChangeEvent(volume=int(status['volume']), status=status, track=track, - plugin_name='music.mpd')) + if last_status.get('volume') != status['volume']: + self.bus.post( + VolumeChangeEvent( + volume=int(status['volume']), + status=status, + track=track, + plugin_name='music.mpd', + ) + ) - if last_status.get('random', None) != status['random']: - self.bus.post(PlaybackRandomModeChangeEvent(state=bool(int(status['random'])), status=status, - track=track, plugin_name='music.mpd')) + if last_status.get('random') != status['random']: + self.bus.post( + PlaybackRandomModeChangeEvent( + state=bool(int(status['random'])), + status=status, + track=track, + plugin_name='music.mpd', + ) + ) - if last_status.get('repeat', None) != status['repeat']: - self.bus.post(PlaybackRepeatModeChangeEvent(state=bool(int(status['repeat'])), status=status, - track=track, plugin_name='music.mpd')) + if last_status.get('repeat') != status['repeat']: + self.bus.post( + PlaybackRepeatModeChangeEvent( + state=bool(int(status['repeat'])), + status=status, + track=track, + plugin_name='music.mpd', + ) + ) - if last_status.get('consume', None) != status['consume']: - self.bus.post(PlaybackConsumeModeChangeEvent(state=bool(int(status['consume'])), status=status, - track=track, plugin_name='music.mpd')) + if last_status.get('consume') != status['consume']: + self.bus.post( + PlaybackConsumeModeChangeEvent( + state=bool(int(status['consume'])), + status=status, + track=track, + plugin_name='music.mpd', + ) + ) - if last_status.get('single', None) != status['single']: - self.bus.post(PlaybackSingleModeChangeEvent(state=bool(int(status['single'])), status=status, - track=track, plugin_name='music.mpd')) + if last_status.get('single') != status['single']: + self.bus.post( + PlaybackSingleModeChangeEvent( + state=bool(int(status['single'])), + status=status, + track=track, + plugin_name='music.mpd', + ) + ) last_status = status last_state = state last_track = track time.sleep(self.poll_seconds) + # vim:sw=4:ts=4:et: diff --git a/platypush/backend/music/snapcast/__init__.py b/platypush/backend/music/snapcast/__init__.py index 89d6303fc..509776e00 100644 --- a/platypush/backend/music/snapcast/__init__.py +++ b/platypush/backend/music/snapcast/__init__.py @@ -21,19 +21,7 @@ from platypush.message.event.music.snapcast import ( class MusicSnapcastBackend(Backend): """ Backend that listens for notification and status changes on one or more - [Snapcast](https://github.com/badaix/snapcast) servers. - - Triggers: - - * :class:`platypush.message.event.music.snapcast.ClientConnectedEvent` - * :class:`platypush.message.event.music.snapcast.ClientDisconnectedEvent` - * :class:`platypush.message.event.music.snapcast.ClientVolumeChangeEvent` - * :class:`platypush.message.event.music.snapcast.ClientLatencyChangeEvent` - * :class:`platypush.message.event.music.snapcast.ClientNameChangeEvent` - * :class:`platypush.message.event.music.snapcast.GroupMuteChangeEvent` - * :class:`platypush.message.event.music.snapcast.GroupStreamChangeEvent` - * :class:`platypush.message.event.music.snapcast.StreamUpdateEvent` - * :class:`platypush.message.event.music.snapcast.ServerUpdateEvent` + `Snapcast `_ servers. """ _DEFAULT_SNAPCAST_PORT = 1705 diff --git a/platypush/backend/music/spotify/__init__.py b/platypush/backend/music/spotify/__init__.py index cd59432f8..1017db0df 100644 --- a/platypush/backend/music/spotify/__init__.py +++ b/platypush/backend/music/spotify/__init__.py @@ -7,8 +7,14 @@ from typing import Optional, Dict, Any from platypush.backend import Backend from platypush.common.spotify import SpotifyMixin from platypush.config import Config -from platypush.message.event.music import MusicPlayEvent, MusicPauseEvent, MusicStopEvent, \ - NewPlayingTrackEvent, SeekChangeEvent, VolumeChangeEvent +from platypush.message.event.music import ( + MusicPlayEvent, + MusicPauseEvent, + MusicStopEvent, + NewPlayingTrackEvent, + SeekChangeEvent, + VolumeChangeEvent, +) from platypush.utils import get_redis from .event import status_queue @@ -21,53 +27,47 @@ class MusicSpotifyBackend(Backend, SpotifyMixin): stream Spotify through the Platypush host. After the backend has started, you should see a new entry in the Spotify Connect devices list in your app. - Triggers: - - * :class:`platypush.message.event.music.MusicPlayEvent` if the playback state changed to play - * :class:`platypush.message.event.music.MusicPauseEvent` if the playback state changed to pause - * :class:`platypush.message.event.music.MusicStopEvent` if the playback state changed to stop - * :class:`platypush.message.event.music.NewPlayingTrackEvent` if a new track is being played - * :class:`platypush.message.event.music.VolumeChangeEvent` if the volume changes - Requires: * **librespot**. Consult the `README `_ for instructions. """ - def __init__(self, - librespot_path: str = 'librespot', - device_name: Optional[str] = None, - device_type: str = 'speaker', - audio_backend: str = 'alsa', - audio_device: Optional[str] = None, - mixer: str = 'softvol', - mixer_name: str = 'PCM', - mixer_card: str = 'default', - mixer_index: int = 0, - volume: int = 100, - volume_ctrl: str = 'linear', - bitrate: int = 160, - autoplay: bool = False, - disable_gapless: bool = False, - username: Optional[str] = None, - password: Optional[str] = None, - client_id: Optional[str] = None, - client_secret: Optional[str] = None, - proxy: Optional[str] = None, - ap_port: Optional[int] = None, - disable_discovery: bool = False, - cache_dir: Optional[str] = None, - system_cache_dir: Optional[str] = None, - disable_audio_cache=False, - enable_volume_normalization: bool = False, - normalization_method: str = 'dynamic', - normalization_pre_gain: Optional[float] = None, - normalization_threshold: float = -1., - normalization_attack: int = 5, - normalization_release: int = 100, - normalization_knee: float = 1., - **kwargs): + def __init__( + self, + librespot_path: str = 'librespot', + device_name: Optional[str] = None, + device_type: str = 'speaker', + audio_backend: str = 'alsa', + audio_device: Optional[str] = None, + mixer: str = 'softvol', + mixer_name: str = 'PCM', + mixer_card: str = 'default', + mixer_index: int = 0, + volume: int = 100, + volume_ctrl: str = 'linear', + bitrate: int = 160, + autoplay: bool = False, + disable_gapless: bool = False, + username: Optional[str] = None, + password: Optional[str] = None, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + proxy: Optional[str] = None, + ap_port: Optional[int] = None, + disable_discovery: bool = False, + cache_dir: Optional[str] = None, + system_cache_dir: Optional[str] = None, + disable_audio_cache=False, + enable_volume_normalization: bool = False, + normalization_method: str = 'dynamic', + normalization_pre_gain: Optional[float] = None, + normalization_threshold: float = -1.0, + normalization_attack: int = 5, + normalization_release: int = 100, + normalization_knee: float = 1.0, + **kwargs, + ): """ :param librespot_path: Librespot path/executable name (default: ``librespot``). :param device_name: Device name (default: same as configured Platypush ``device_id`` or hostname). @@ -121,17 +121,36 @@ class MusicSpotifyBackend(Backend, SpotifyMixin): SpotifyMixin.__init__(self, client_id=client_id, client_secret=client_secret) self.device_name = device_name or Config.get('device_id') self._librespot_args = [ - librespot_path, '--name', self.device_name, '--backend', audio_backend, - '--device-type', device_type, '--mixer', mixer, '--alsa-mixer-control', mixer_name, - '--initial-volume', str(volume), '--volume-ctrl', volume_ctrl, '--bitrate', str(bitrate), - '--emit-sink-events', '--onevent', 'python -m platypush.backend.music.spotify.event', + librespot_path, + '--name', + self.device_name, + '--backend', + audio_backend, + '--device-type', + device_type, + '--mixer', + mixer, + '--alsa-mixer-control', + mixer_name, + '--initial-volume', + str(volume), + '--volume-ctrl', + volume_ctrl, + '--bitrate', + str(bitrate), + '--emit-sink-events', + '--onevent', + 'python -m platypush.backend.music.spotify.event', ] if audio_device: self._librespot_args += ['--alsa-mixer-device', audio_device] else: self._librespot_args += [ - '--alsa-mixer-device', mixer_card, '--alsa-mixer-index', str(mixer_index) + '--alsa-mixer-device', + mixer_card, + '--alsa-mixer-index', + str(mixer_index), ] if autoplay: self._librespot_args += ['--autoplay'] @@ -148,17 +167,30 @@ class MusicSpotifyBackend(Backend, SpotifyMixin): if cache_dir: self._librespot_args += ['--cache', os.path.expanduser(cache_dir)] if system_cache_dir: - self._librespot_args += ['--system-cache', os.path.expanduser(system_cache_dir)] + self._librespot_args += [ + '--system-cache', + os.path.expanduser(system_cache_dir), + ] if enable_volume_normalization: self._librespot_args += [ - '--enable-volume-normalisation', '--normalisation-method', normalization_method, - '--normalisation-threshold', str(normalization_threshold), '--normalisation-attack', - str(normalization_attack), '--normalisation-release', str(normalization_release), - '--normalisation-knee', str(normalization_knee), + '--enable-volume-normalisation', + '--normalisation-method', + normalization_method, + '--normalisation-threshold', + str(normalization_threshold), + '--normalisation-attack', + str(normalization_attack), + '--normalisation-release', + str(normalization_release), + '--normalisation-knee', + str(normalization_knee), ] if normalization_pre_gain: - self._librespot_args += ['--normalisation-pregain', str(normalization_pre_gain)] + self._librespot_args += [ + '--normalisation-pregain', + str(normalization_pre_gain), + ] self._librespot_dump_args = self._librespot_args.copy() if username and password: @@ -227,11 +259,21 @@ class MusicSpotifyBackend(Backend, SpotifyMixin): def _process_status_msg(self, status): event_type = status.get('PLAYER_EVENT') - volume = int(status['VOLUME'])/655.35 if status.get('VOLUME') is not None else None + volume = ( + int(status['VOLUME']) / 655.35 if status.get('VOLUME') is not None else None + ) track_id = status.get('TRACK_ID') old_track_id = status.get('OLD_TRACK_ID', self.track['id']) - duration = int(status['DURATION_MS'])/1000. if status.get('DURATION_MS') is not None else None - elapsed = int(status['POSITION_MS'])/1000. if status.get('POSITION_MS') is not None else None + duration = ( + int(status['DURATION_MS']) / 1000.0 + if status.get('DURATION_MS') is not None + else None + ) + elapsed = ( + int(status['POSITION_MS']) / 1000.0 + if status.get('POSITION_MS') is not None + else None + ) if volume is not None: self.status['volume'] = volume @@ -275,7 +317,7 @@ class MusicSpotifyBackend(Backend, SpotifyMixin): self._librespot_proc.terminate() try: - self._librespot_proc.wait(timeout=5.) + self._librespot_proc.wait(timeout=5.0) except subprocess.TimeoutExpired: self.logger.warning('Librespot has not yet terminated: killing it') self._librespot_proc.kill() diff --git a/platypush/backend/nextcloud/__init__.py b/platypush/backend/nextcloud/__init__.py index 66301326e..a873b0514 100644 --- a/platypush/backend/nextcloud/__init__.py +++ b/platypush/backend/nextcloud/__init__.py @@ -11,71 +11,76 @@ class NextcloudBackend(Backend): """ This backend triggers events when new activities occur on a NextCloud instance. - Triggers: + The field ``activity_type`` in the triggered :class:`platypush.message.event.nextcloud.NextCloudActivityEvent` + events identifies the activity type (e.g. ``file_created``, ``file_deleted``, + ``file_changed``). Example in the case of the creation of new files: - - :class:`platypush.message.event.nextcloud.NextCloudActivityEvent` when new activity occurs on the instance. - The field ``activity_type`` identifies the activity type (e.g. ``file_created``, ``file_deleted``, - ``file_changed``). Example in the case of the creation of new files: + .. code-block:: json - .. code-block:: json - - { - "activity_id": 387, - "app": "files", - "activity_type": "file_created", - "user": "your-user", - "subject": "You created InstantUpload/Camera/IMG_0100.jpg, InstantUpload/Camera/IMG_0101.jpg and InstantUpload/Camera/IMG_0102.jpg", - "subject_rich": [ - "You created {file3}, {file2} and {file1}", - { - "file1": { - "type": "file", - "id": "41994", - "name": "IMG_0100.jpg", - "path": "InstantUpload/Camera/IMG_0100.jpg", - "link": "https://your-domain/nextcloud/index.php/f/41994" - }, - "file2": { - "type": "file", - "id": "42005", - "name": "IMG_0101.jpg", - "path": "InstantUpload/Camera/IMG_0102.jpg", - "link": "https://your-domain/nextcloud/index.php/f/42005" - }, - "file3": { - "type": "file", - "id": "42014", - "name": "IMG_0102.jpg", - "path": "InstantUpload/Camera/IMG_0102.jpg", - "link": "https://your-domain/nextcloud/index.php/f/42014" - } - } - ], - "message": "", - "message_rich": [ - "", - [] - ], - "object_type": "files", - "object_id": 41994, - "object_name": "/InstantUpload/Camera/IMG_0102.jpg", - "objects": { - "42014": "/InstantUpload/Camera/IMG_0100.jpg", - "42005": "/InstantUpload/Camera/IMG_0101.jpg", - "41994": "/InstantUpload/Camera/IMG_0102.jpg" - }, - "link": "https://your-domain/nextcloud/index.php/apps/files/?dir=/InstantUpload/Camera", - "icon": "https://your-domain/nextcloud/apps/files/img/add-color.svg", - "datetime": "2020-09-07T17:04:29+00:00" + { + "activity_id": 387, + "app": "files", + "activity_type": "file_created", + "user": "your-user", + "subject": "You created InstantUpload/Camera/IMG_0100.jpg", + "subject_rich": [ + "You created {file3}, {file2} and {file1}", + { + "file1": { + "type": "file", + "id": "41994", + "name": "IMG_0100.jpg", + "path": "InstantUpload/Camera/IMG_0100.jpg", + "link": "https://your-domain/nextcloud/index.php/f/41994" + }, + "file2": { + "type": "file", + "id": "42005", + "name": "IMG_0101.jpg", + "path": "InstantUpload/Camera/IMG_0102.jpg", + "link": "https://your-domain/nextcloud/index.php/f/42005" + }, + "file3": { + "type": "file", + "id": "42014", + "name": "IMG_0102.jpg", + "path": "InstantUpload/Camera/IMG_0102.jpg", + "link": "https://your-domain/nextcloud/index.php/f/42014" } + } + ], + "message": "", + "message_rich": [ + "", + [] + ], + "object_type": "files", + "object_id": 41994, + "object_name": "/InstantUpload/Camera/IMG_0102.jpg", + "objects": { + "42014": "/InstantUpload/Camera/IMG_0100.jpg", + "42005": "/InstantUpload/Camera/IMG_0101.jpg", + "41994": "/InstantUpload/Camera/IMG_0102.jpg" + }, + "link": "https://your-domain/nextcloud/index.php/apps/files/?dir=/InstantUpload/Camera", + "icon": "https://your-domain/nextcloud/apps/files/img/add-color.svg", + "datetime": "2020-09-07T17:04:29+00:00" + } """ _LAST_ACTIVITY_VARNAME = '_NEXTCLOUD_LAST_ACTIVITY_ID' - def __init__(self, url: Optional[str] = None, username: Optional[str] = None, password: Optional[str] = None, - object_type: Optional[str] = None, object_id: Optional[int] = None, - poll_seconds: Optional[float] = 60., **kwargs): + def __init__( + self, + url: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + object_type: Optional[str] = None, + object_id: Optional[int] = None, + poll_seconds: Optional[float] = 60.0, + **kwargs + ): """ :param url: NextCloud instance URL (default: same as the :class:`platypush.plugins.nextcloud.NextCloudPlugin`). :param username: NextCloud username (default: same as the :class:`platypush.plugins.nextcloud.NextCloudPlugin`). @@ -106,14 +111,17 @@ class NextcloudBackend(Backend): self.username = username if username else self.username self.password = password if password else self.password - assert self.url and self.username and self.password, \ - 'No configuration provided neither for the NextCloud plugin nor the backend' + assert ( + self.url and self.username and self.password + ), 'No configuration provided neither for the NextCloud plugin nor the backend' @property def last_seen_id(self) -> Optional[int]: if self._last_seen_id is None: variables: VariablePlugin = get_plugin('variable') - last_seen_id = variables.get(self._LAST_ACTIVITY_VARNAME).output.get(self._LAST_ACTIVITY_VARNAME) + last_seen_id = variables.get(self._LAST_ACTIVITY_VARNAME).output.get( + self._LAST_ACTIVITY_VARNAME + ) self._last_seen_id = last_seen_id return self._last_seen_id @@ -133,8 +141,14 @@ class NextcloudBackend(Backend): new_last_seen_id = int(last_seen_id) plugin: NextcloudPlugin = get_plugin('nextcloud') # noinspection PyUnresolvedReferences - activities = plugin.get_activities(sort='desc', url=self.url, username=self.username, password=self.password, - object_type=self.object_type, object_id=self.object_id).output + activities = plugin.get_activities( + sort='desc', + url=self.url, + username=self.username, + password=self.password, + object_type=self.object_type, + object_id=self.object_id, + ).output events = [] for activity in activities: diff --git a/platypush/backend/nfc/__init__.py b/platypush/backend/nfc/__init__.py index c481b77a2..e6dc13a5e 100644 --- a/platypush/backend/nfc/__init__.py +++ b/platypush/backend/nfc/__init__.py @@ -14,18 +14,6 @@ class NfcBackend(Backend): """ Backend to detect NFC card events from a compatible reader. - Triggers: - - * :class:`platypush.message.event.nfc.NFCDeviceConnectedEvent` when an NFC reader/writer is connected - * :class:`platypush.message.event.nfc.NFCDeviceDisconnectedEvent` when an NFC reader/writer is disconnected - * :class:`platypush.message.event.nfc.NFCTagDetectedEvent` when an NFC tag is detected - * :class:`platypush.message.event.nfc.NFCTagRemovedEvent` when an NFC tag is removed - - Requires: - - * **nfcpy** >= 1.0 (``pip install 'nfcpy>=1.0'``) - * **ndef** (``pip install ndeflib``) - Run the following to check if your device is compatible with nfcpy and the right permissions are set:: python -m nfc diff --git a/platypush/backend/nodered/__init__.py b/platypush/backend/nodered/__init__.py index b8b4bedf2..91bc06393 100644 --- a/platypush/backend/nodered/__init__.py +++ b/platypush/backend/nodered/__init__.py @@ -13,11 +13,6 @@ class NoderedBackend(Backend): used in your flows. This block will accept JSON requests as input in the format ``{"type":"request", "action":"plugin.name.action_name", "args": {...}}`` and return the output of the action as block output, or raise an exception if the action failed. - - Requires: - - * **pynodered** (``pip install pynodered``) - """ def __init__(self, port: int = 5051, *args, **kwargs): @@ -27,7 +22,8 @@ class NoderedBackend(Backend): super().__init__(*args, **kwargs) self.port = port self._runner_path = os.path.join( - os.path.dirname(inspect.getfile(self.__class__)), 'runner.py') + os.path.dirname(inspect.getfile(self.__class__)), 'runner.py' + ) self._server = None def on_stop(self): @@ -40,8 +36,16 @@ class NoderedBackend(Backend): super().run() self.register_service(port=self.port, name='node') - self._server = subprocess.Popen([sys.executable, '-m', 'pynodered.server', - '--port', str(self.port), self._runner_path]) + self._server = subprocess.Popen( + [ + sys.executable, + '-m', + 'pynodered.server', + '--port', + str(self.port), + self._runner_path, + ] + ) self.logger.info('Started Node-RED backend on port {}'.format(self.port)) self._server.wait() diff --git a/platypush/backend/ping/__init__.py b/platypush/backend/ping/__init__.py index 15dc321a9..4577bbad1 100644 --- a/platypush/backend/ping/__init__.py +++ b/platypush/backend/ping/__init__.py @@ -11,12 +11,6 @@ from platypush.utils.workers import Worker, Workers class PingBackend(Backend): """ This backend allows you to ping multiple remote hosts at regular intervals. - - Triggers: - - - :class:`platypush.message.event.ping.HostDownEvent` if a host stops responding ping requests - - :class:`platypush.message.event.ping.HostUpEvent` if a host starts responding ping requests - """ class Pinger(Worker): @@ -30,7 +24,15 @@ class PingBackend(Backend): response = pinger.ping(host, timeout=self.timeout, count=self.count).output return host, response['success'] is True - def __init__(self, hosts: List[str], timeout: float = 5.0, interval: float = 60.0, count: int = 1, *args, **kwargs): + def __init__( + self, + hosts: List[str], + timeout: float = 5.0, + interval: float = 60.0, + count: int = 1, + *args, + **kwargs + ): """ :param hosts: List of IP addresses or host names to monitor. :param timeout: Ping timeout. @@ -47,7 +49,9 @@ class PingBackend(Backend): def run(self): super().run() - self.logger.info('Starting ping backend with {} hosts to monitor'.format(len(self.hosts))) + self.logger.info( + 'Starting ping backend with {} hosts to monitor'.format(len(self.hosts)) + ) while not self.should_stop(): workers = Workers(10, self.Pinger, timeout=self.timeout, count=self.count) diff --git a/platypush/backend/pushbullet/__init__.py b/platypush/backend/pushbullet/__init__.py index 71f26db70..c328697b1 100644 --- a/platypush/backend/pushbullet/__init__.py +++ b/platypush/backend/pushbullet/__init__.py @@ -14,19 +14,16 @@ class PushbulletBackend(Backend): Pushbullet app and/or through Tasker), synchronize clipboards, send pictures and files to other devices etc. You can also wrap Platypush messages as JSON into a push body to execute them. - - Triggers: - - * :class:`platypush.message.event.pushbullet.PushbulletEvent` if a new push is received - - Requires: - - * **pushbullet.py** (``pip install git+https://github.com/pushbullet.py/pushbullet.py``) - """ - def __init__(self, token: str, device: str = 'Platypush', proxy_host: Optional[str] = None, - proxy_port: Optional[int] = None, **kwargs): + def __init__( + self, + token: str, + device: str = 'Platypush', + proxy_host: Optional[str] = None, + proxy_port: Optional[int] = None, + **kwargs, + ): """ :param token: Your Pushbullet API token, see https://docs.pushbullet.com/#authentication :param device: Name of the virtual device for Platypush (default: Platypush) @@ -47,12 +44,15 @@ class PushbulletBackend(Backend): def _initialize(self): # noinspection PyPackageRequirements from pushbullet import Pushbullet + self.pb = Pushbullet(self.token) try: self.device = self.pb.get_device(self.device_name) except Exception as e: - self.logger.info(f'Device {self.device_name} does not exist: {e}. Creating it') + self.logger.info( + f'Device {self.device_name} does not exist: {e}. Creating it' + ) self.device = self.pb.new_device(self.device_name) self.pb_device_id = self.get_device_id() @@ -98,8 +98,10 @@ class PushbulletBackend(Backend): body = json.loads(body) self.on_message(body) except Exception as e: - self.logger.debug('Unexpected message received on the ' + - f'Pushbullet backend: {e}. Message: {body}') + self.logger.debug( + 'Unexpected message received on the ' + + f'Pushbullet backend: {e}. Message: {body}' + ) except Exception as e: self.logger.exception(e) return @@ -111,8 +113,12 @@ class PushbulletBackend(Backend): try: return self.pb.get_device(self.device_name).device_iden except Exception: - device = self.pb.new_device(self.device_name, model='Platypush virtual device', - manufacturer='platypush', icon='system') + device = self.pb.new_device( + self.device_name, + model='Platypush virtual device', + manufacturer='platypush', + icon='system', + ) self.logger.info(f'Created Pushbullet device {self.device_name}') return device.device_iden @@ -158,14 +164,18 @@ class PushbulletBackend(Backend): def run_listener(self): from .listener import Listener - self.logger.info(f'Initializing Pushbullet backend - device_id: {self.device_name}') - self.listener = Listener(account=self.pb, - on_push=self.on_push(), - on_open=self.on_open(), - on_close=self.on_close(), - on_error=self.on_error(), - http_proxy_host=self.proxy_host, - http_proxy_port=self.proxy_port) + self.logger.info( + f'Initializing Pushbullet backend - device_id: {self.device_name}' + ) + self.listener = Listener( + account=self.pb, + on_push=self.on_push(), + on_open=self.on_open(), + on_close=self.on_close(), + on_error=self.on_error(), + http_proxy_host=self.proxy_host, + http_proxy_port=self.proxy_port, + ) self.listener.run_forever() diff --git a/platypush/backend/scard/__init__.py b/platypush/backend/scard/__init__.py index 532539cf3..210467bb3 100644 --- a/platypush/backend/scard/__init__.py +++ b/platypush/backend/scard/__init__.py @@ -9,23 +9,18 @@ class ScardBackend(Backend): Extend this backend to implement more advanced communication with custom smart cards. - - Triggers: - - * :class:`platypush.message.event.scard.SmartCardDetectedEvent` when a smart card is detected - * :class:`platypush.message.event.scard.SmartCardRemovedEvent` when a smart card is removed - - Requires: - - * **pyscard** (``pip install pyscard``) """ def __init__(self, atr=None, *args, **kwargs): """ - :param atr: If set, the backend will trigger events only for card(s) with the specified ATR(s). It can be either an ATR string (space-separated hex octects) or a list of ATR strings. Default: none (any card will be detected) + :param atr: If set, the backend will trigger events only for card(s) + with the specified ATR(s). It can be either an ATR string + (space-separated hex octects) or a list of ATR strings. Default: + none (any card will be detected). """ from smartcard.CardType import AnyCardType, ATRCardType + super().__init__(*args, **kwargs) self.ATRs = [] @@ -35,9 +30,10 @@ class ScardBackend(Backend): elif isinstance(atr, list): self.ATRs = atr else: - raise RuntimeError("Unsupported ATR: \"{}\" - type: {}, " + - "supported types: string, list".format( - atr, type(atr))) + raise RuntimeError( + f"Unsupported ATR: \"{atr}\" - type: {type(atr)}, " + + "supported types: string, list" + ) self.cardtype = ATRCardType(*[self._to_bytes(atr) for atr in self.ATRs]) else: @@ -56,8 +52,9 @@ class ScardBackend(Backend): super().run() - self.logger.info('Initialized smart card reader backend - ATR filter: {}'. - format(self.ATRs)) + self.logger.info( + 'Initialized smart card reader backend - ATR filter: {}'.format(self.ATRs) + ) prev_atr = None reader = None @@ -72,17 +69,19 @@ class ScardBackend(Backend): atr = toHexString(cardservice.connection.getATR()) if atr != prev_atr: - self.logger.info('Smart card detected on reader {}, ATR: {}'. - format(reader, atr)) + self.logger.info( + 'Smart card detected on reader {}, ATR: {}'.format(reader, atr) + ) self.bus.post(SmartCardDetectedEvent(atr=atr, reader=reader)) prev_atr = atr except Exception as e: - if isinstance(e, NoCardException) or isinstance(e, CardConnectionException): + if isinstance(e, (NoCardException, CardConnectionException)): self.bus.post(SmartCardRemovedEvent(atr=prev_atr, reader=reader)) else: self.logger.exception(e) prev_atr = None + # vim:sw=4:ts=4:et: diff --git a/platypush/backend/sensor/ir/zeroborg/__init__.py b/platypush/backend/sensor/ir/zeroborg/__init__.py index 076a6ea46..9f583a2d4 100644 --- a/platypush/backend/sensor/ir/zeroborg/__init__.py +++ b/platypush/backend/sensor/ir/zeroborg/__init__.py @@ -14,11 +14,6 @@ class SensorIrZeroborgBackend(Backend): remote by running the scan utility:: python -m platypush.backend.sensor.ir.zeroborg.scan - - Triggers: - - * :class:`platypush.message.event.sensor.ir.IrKeyDownEvent` when a key is pressed - * :class:`platypush.message.event.sensor.ir.IrKeyUpEvent` when a key is released """ last_message = None @@ -40,20 +35,29 @@ class SensorIrZeroborgBackend(Backend): if self.zb.HasNewIrMessage(): message = self.zb.GetIrMessage() if message != self.last_message: - self.logger.info('Received key down event on the IR sensor: {}'.format(message)) + self.logger.info( + 'Received key down event on the IR sensor: {}'.format( + message + ) + ) self.bus.post(IrKeyDownEvent(message=message)) self.last_message = message self.last_message_timestamp = time.time() except OSError as e: - self.logger.warning('Failed reading IR sensor status: {}: {}'.format(type(e), str(e))) + self.logger.warning( + 'Failed reading IR sensor status: {}: {}'.format(type(e), str(e)) + ) - if self.last_message_timestamp and \ - time.time() - self.last_message_timestamp > self.no_message_timeout: + if ( + self.last_message_timestamp + and time.time() - self.last_message_timestamp > self.no_message_timeout + ): self.logger.info('Received key up event on the IR sensor') self.bus.post(IrKeyUpEvent(message=self.last_message)) self.last_message = None self.last_message_timestamp = None + # vim:sw=4:ts=4:et: diff --git a/platypush/backend/sensor/leap/__init__.py b/platypush/backend/sensor/leap/__init__.py index 5b8c09d17..9b2fff0f4 100644 --- a/platypush/backend/sensor/leap/__init__.py +++ b/platypush/backend/sensor/leap/__init__.py @@ -7,8 +7,13 @@ import Leap from platypush.backend import Backend from platypush.context import get_backend -from platypush.message.event.sensor.leap import LeapFrameEvent, \ - LeapFrameStartEvent, LeapFrameStopEvent, LeapConnectEvent, LeapDisconnectEvent +from platypush.message.event.sensor.leap import ( + LeapFrameEvent, + LeapFrameStartEvent, + LeapFrameStopEvent, + LeapConnectEvent, + LeapDisconnectEvent, +) class SensorLeapBackend(Backend): @@ -26,40 +31,38 @@ class SensorLeapBackend(Backend): Requires: - * The Redis backend enabled - * The Leap Motion SDK compiled with Python 3 support, see my port at https://github.com:BlackLight/leap-sdk-python3.git - * The `leapd` daemon to be running and your Leap Motion connected + * The Leap Motion SDK compiled with Python 3 support, see my port at + https://github.com:BlackLight/leap-sdk-python3.git + * The ``leapd`` daemon to be running and your Leap Motion connected - Triggers: - - * :class:`platypush.message.event.sensor.leap.LeapFrameEvent` when a new frame is received - * :class:`platypush.message.event.sensor.leap.LeapFrameStartEvent` when a new sequence of frame starts - * :class:`platypush.message.event.sensor.leap.LeapFrameStopEvent` when a sequence of frame stops - * :class:`platypush.message.event.sensor.leap.LeapConnectEvent` when a Leap Motion device is connected - * :class:`platypush.message.event.sensor.leap.LeapDisconnectEvent` when a Leap Motion device disconnects """ _listener_proc = None - def __init__(self, - position_ranges=None, - position_tolerance=0.0, # Position variation tolerance in % - frames_throttle_secs=None, - *args, **kwargs): + def __init__( + self, + position_ranges=None, + position_tolerance=0.0, # Position variation tolerance in % + frames_throttle_secs=None, + *args, + **kwargs + ): """ - :param position_ranges: It specifies how wide the hand space (x, y and z axes) should be in millimiters. + :param position_ranges: It specifies how wide the hand space (x, y and + z axes) should be in millimiters. - Default:: + Default:: - [ - [-300.0, 300.0], # x axis - [25.0, 600.0], # y axis - [-300.0, 300.0], # z axis - ] + [ + [-300.0, 300.0], # x axis + [25.0, 600.0], # y axis + [-300.0, 300.0], # z axis + ] :type position_ranges: list[list[float]] - :param position_tolerance: % of change between a frame and the next to really consider the next frame as a new one (default: 0) + :param position_tolerance: % of change between a frame and the next to + really consider the next frame as a new one (default: 0). :type position_tolerance: float :param frames_throttle_secs: If set, the frame events will be throttled @@ -87,16 +90,20 @@ class SensorLeapBackend(Backend): super().run() def _listener_process(): - listener = LeapListener(position_ranges=self.position_ranges, - position_tolerance=self.position_tolerance, - frames_throttle_secs=self.frames_throttle_secs, - logger=self.logger) + listener = LeapListener( + position_ranges=self.position_ranges, + position_tolerance=self.position_tolerance, + frames_throttle_secs=self.frames_throttle_secs, + logger=self.logger, + ) controller = Leap.Controller() if not controller: - raise RuntimeError('No Leap Motion controller found - is your ' + - 'device connected and is leapd running?') + raise RuntimeError( + 'No Leap Motion controller found - is your ' + + 'device connected and is leapd running?' + ) controller.add_listener(listener) self.logger.info('Leap Motion backend initialized') @@ -120,12 +127,14 @@ class LeapFuture(Timer): def _callback_wrapper(self): def _callback(): self.listener._send_event(self.event) + return _callback class LeapListener(Leap.Listener): - def __init__(self, position_ranges, position_tolerance, logger, - frames_throttle_secs=None): + def __init__( + self, position_ranges, position_tolerance, logger, frames_throttle_secs=None + ): super().__init__() self.prev_frame = None @@ -138,8 +147,11 @@ class LeapListener(Leap.Listener): def _send_event(self, event): backend = get_backend('redis') if not backend: - self.logger.warning('Redis backend not configured, I cannot propagate the following event: {}'. - format(event)) + self.logger.warning( + 'Redis backend not configured, I cannot propagate the following event: {}'.format( + event + ) + ) return backend.send_message(event) @@ -147,8 +159,9 @@ class LeapListener(Leap.Listener): def send_event(self, event): if self.frames_throttle_secs: if not self.running_future or not self.running_future.is_alive(): - self.running_future = LeapFuture(seconds=self.frames_throttle_secs, - listener=self, event=event) + self.running_future = LeapFuture( + seconds=self.frames_throttle_secs, listener=self, event=event + ) self.running_future.start() else: self._send_event(event) @@ -193,23 +206,38 @@ class LeapListener(Leap.Listener): 'id': hand.id, 'is_left': hand.is_left, 'is_right': hand.is_right, - 'palm_normal': [hand.palm_normal[0], hand.palm_normal[1], hand.palm_normal[2]], + 'palm_normal': [ + hand.palm_normal[0], + hand.palm_normal[1], + hand.palm_normal[2], + ], 'palm_position': self._normalize_position(hand.palm_position), - 'palm_velocity': [hand.palm_velocity[0], hand.palm_velocity[1], hand.palm_velocity[2]], + 'palm_velocity': [ + hand.palm_velocity[0], + hand.palm_velocity[1], + hand.palm_velocity[2], + ], 'palm_width': hand.palm_width, - 'sphere_center': [hand.sphere_center[0], hand.sphere_center[1], hand.sphere_center[2]], + 'sphere_center': [ + hand.sphere_center[0], + hand.sphere_center[1], + hand.sphere_center[2], + ], 'sphere_radius': hand.sphere_radius, - 'stabilized_palm_position': self._normalize_position(hand.stabilized_palm_position), + 'stabilized_palm_position': self._normalize_position( + hand.stabilized_palm_position + ), 'time_visible': hand.time_visible, 'wrist_position': self._normalize_position(hand.wrist_position), } for i, hand in enumerate(frame.hands) - if hand.is_valid and ( - len(frame.hands) != len(self.prev_frame.hands) or - self._position_changed( + if hand.is_valid + and ( + len(frame.hands) != len(self.prev_frame.hands) + or self._position_changed( old_position=self.prev_frame.hands[i].stabilized_palm_position, - new_position=hand.stabilized_palm_position) - + new_position=hand.stabilized_palm_position, + ) if self.prev_frame else True ) @@ -220,25 +248,38 @@ class LeapListener(Leap.Listener): # having x_range = z_range = [-100, 100], y_range = [0, 100] return [ - self._scale_scalar(value=position[0], range=self.position_ranges[0], new_range=[-100.0, 100.0]), - self._scale_scalar(value=position[1], range=self.position_ranges[1], new_range=[0.0, 100.0]), - self._scale_scalar(value=position[2], range=self.position_ranges[2], new_range=[-100.0, 100.0]), + self._scale_scalar( + value=position[0], + range=self.position_ranges[0], + new_range=[-100.0, 100.0], + ), + self._scale_scalar( + value=position[1], range=self.position_ranges[1], new_range=[0.0, 100.0] + ), + self._scale_scalar( + value=position[2], + range=self.position_ranges[2], + new_range=[-100.0, 100.0], + ), ] @staticmethod def _scale_scalar(value, range, new_range): if value < range[0]: - value=range[0] + value = range[0] if value > range[1]: - value=range[1] + value = range[1] - return ((new_range[1]-new_range[0])/(range[1]-range[0]))*(value-range[0]) + new_range[0] + return ((new_range[1] - new_range[0]) / (range[1] - range[0])) * ( + value - range[0] + ) + new_range[0] def _position_changed(self, old_position, new_position): return ( - abs(old_position[0]-new_position[0]) > self.position_tolerance or - abs(old_position[1]-new_position[1]) > self.position_tolerance or - abs(old_position[2]-new_position[2]) > self.position_tolerance) + abs(old_position[0] - new_position[0]) > self.position_tolerance + or abs(old_position[1] - new_position[1]) > self.position_tolerance + or abs(old_position[2] - new_position[2]) > self.position_tolerance + ) # vim:sw=4:ts=4:et: diff --git a/platypush/backend/tcp/__init__.py b/platypush/backend/tcp/__init__.py index 2d83feddf..8b829ecbe 100644 --- a/platypush/backend/tcp/__init__.py +++ b/platypush/backend/tcp/__init__.py @@ -10,7 +10,18 @@ from platypush.message import Message class TcpBackend(Backend): """ - Backend that reads messages from a configured TCP port + Backend that reads messages from a configured TCP port. + + You can use this backend to send messages to Platypush from any TCP client, for example: + + .. code-block:: bash + + $ echo '{"type": "request", "action": "shell.exec", "args": {"cmd": "ls /"}}' | nc localhost 1234 + + .. warning:: Be **VERY** careful when exposing this backend to the Internet. Unlike the HTTP backend, this backend + doesn't implement any authentication mechanisms, so anyone who can connect to the TCP port will be able to + execute commands on your Platypush instance. + """ # Maximum length of a request to be processed diff --git a/platypush/backend/todoist/__init__.py b/platypush/backend/todoist/__init__.py index 1f1f4f12a..ab2a57577 100644 --- a/platypush/backend/todoist/__init__.py +++ b/platypush/backend/todoist/__init__.py @@ -3,31 +3,21 @@ import time from platypush.backend import Backend from platypush.context import get_plugin -from platypush.message.event.todoist import NewItemEvent, RemovedItemEvent, ModifiedItemEvent, CheckedItemEvent, \ - ItemContentChangeEvent, TodoistSyncRequiredEvent +from platypush.message.event.todoist import ( + NewItemEvent, + RemovedItemEvent, + ModifiedItemEvent, + CheckedItemEvent, + ItemContentChangeEvent, + TodoistSyncRequiredEvent, +) from platypush.plugins.todoist import TodoistPlugin class TodoistBackend(Backend): """ - This backend listens for events on a remote Todoist account. - - Requires: - - * **todoist-python** (``pip install todoist-python``) - - Triggers: - - * :class:`platypush.message.event.todoist.NewItemEvent` when a new item is created. - * :class:`platypush.message.event.todoist.RemovedItemEvent` when an item is removed. - * :class:`platypush.message.event.todoist.CheckedItemEvent` when an item is checked. - * :class:`platypush.message.event.todoist.ItemContentChangeEvent` when the content of an item is changed. - * :class:`platypush.message.event.todoist.ModifiedItemEvent` when an item is changed and the change - doesn't fall into the categories above. - * :class:`platypush.message.event.todoist.TodoistSyncRequiredEvent` when an update has occurred that doesn't - fall into the categories above and a sync is required to get up-to-date. - + This backend listens for events on a Todoist account. """ def __init__(self, api_token: str = None, **kwargs): @@ -35,7 +25,9 @@ class TodoistBackend(Backend): self._plugin: TodoistPlugin = get_plugin('todoist') if not api_token: - assert self._plugin and self._plugin.api_token, 'No api_token specified either on Todoist backend or plugin' + assert ( + self._plugin and self._plugin.api_token + ), 'No api_token specified either on Todoist backend or plugin' self.api_token = self._plugin.api_token else: self.api_token = api_token @@ -97,16 +89,15 @@ class TodoistBackend(Backend): import websocket if not self._ws: - self._ws = websocket.WebSocketApp(self.url, - on_message=self._on_msg(), - on_error=self._on_error(), - on_close=self._on_close()) + self._ws = websocket.WebSocketApp( + self.url, + on_message=self._on_msg(), + on_error=self._on_error(), + on_close=self._on_close(), + ) def _refresh_items(self): - new_items = { - i['id']: i - for i in self._plugin.get_items().output - } + new_items = {i['id']: i for i in self._plugin.get_items().output} if self._todoist_initialized: for id, item in new_items.items(): diff --git a/platypush/backend/trello/__init__.py b/platypush/backend/trello/__init__.py index 0c968cae0..bfaf87c8a 100644 --- a/platypush/backend/trello/__init__.py +++ b/platypush/backend/trello/__init__.py @@ -34,13 +34,6 @@ class TrelloBackend(Backend): * The :class:`platypush.plugins.trello.TrelloPlugin` configured. - Triggers: - - * :class:`platypush.message.event.trello.NewCardEvent` when a card is created. - * :class:`platypush.message.event.trello.MoveCardEvent` when a card is moved. - * :class:`platypush.message.event.trello.ArchivedCardEvent` when a card is archived/closed. - * :class:`platypush.message.event.trello.UnarchivedCardEvent` when a card is un-archived/opened. - """ _websocket_url_base = 'wss://trello.com/1/Session/socket?token={token}' diff --git a/platypush/backend/weather/buienradar/__init__.py b/platypush/backend/weather/buienradar/__init__.py index 281b42d22..64a7dc8a9 100644 --- a/platypush/backend/weather/buienradar/__init__.py +++ b/platypush/backend/weather/buienradar/__init__.py @@ -2,7 +2,10 @@ import time from platypush.backend import Backend from platypush.context import get_plugin -from platypush.message.event.weather import NewWeatherConditionEvent, NewPrecipitationForecastEvent +from platypush.message.event.weather import ( + NewWeatherConditionEvent, + NewPrecipitationForecastEvent, +) from platypush.plugins.weather.buienradar import WeatherBuienradarPlugin @@ -10,10 +13,6 @@ class WeatherBuienradarBackend(Backend): """ Buienradar weather forecast backend. Listens for new weather or precipitation updates. - Triggers: - - * :class:`platypush.message.event.weather.NewWeatherConditionEvent` when there is a weather condition update - Requires: * The :mod:`platypush.plugins.weather.buienradar` plugin configured @@ -37,16 +36,24 @@ class WeatherBuienradarBackend(Backend): del weather['measured'] if precip != self.last_precip: - self.bus.post(NewPrecipitationForecastEvent(plugin_name='weather.buienradar', - average=precip.get('average'), - total=precip.get('total'), - time_frame=precip.get('time_frame'))) + self.bus.post( + NewPrecipitationForecastEvent( + plugin_name='weather.buienradar', + average=precip.get('average'), + total=precip.get('total'), + time_frame=precip.get('time_frame'), + ) + ) if weather != self.last_weather: - self.bus.post(NewWeatherConditionEvent(**{ - **weather, - 'plugin_name': 'weather.buienradar', - })) + self.bus.post( + NewWeatherConditionEvent( + **{ + **weather, + 'plugin_name': 'weather.buienradar', + } + ) + ) self.last_weather = weather self.last_precip = precip diff --git a/platypush/backend/weather/darksky/__init__.py b/platypush/backend/weather/darksky/__init__.py index 1051d5d34..5d89c0aa0 100644 --- a/platypush/backend/weather/darksky/__init__.py +++ b/platypush/backend/weather/darksky/__init__.py @@ -5,10 +5,6 @@ class WeatherDarkskyBackend(WeatherBackend): """ Weather forecast backend that leverages the DarkSky API. - Triggers: - - * :class:`platypush.message.event.weather.NewWeatherConditionEvent` when there is a weather condition update - Requires: * The :class:`platypush.plugins.weather.darksky.WeatherDarkskyPlugin` plugin configured @@ -19,7 +15,9 @@ class WeatherDarkskyBackend(WeatherBackend): """ :param poll_seconds: How often the backend should check for updates (default: every 5 minutes). """ - super().__init__(plugin_name='weather.darksky', poll_seconds=poll_seconds, **kwargs) + super().__init__( + plugin_name='weather.darksky', poll_seconds=poll_seconds, **kwargs + ) # vim:sw=4:ts=4:et: diff --git a/platypush/backend/weather/openweathermap/__init__.py b/platypush/backend/weather/openweathermap/__init__.py index 90e0050ec..26f35b1de 100644 --- a/platypush/backend/weather/openweathermap/__init__.py +++ b/platypush/backend/weather/openweathermap/__init__.py @@ -5,10 +5,6 @@ class WeatherOpenweathermapBackend(WeatherBackend): """ Weather forecast backend that leverages the OpenWeatherMap API. - Triggers: - - * :class:`platypush.message.event.weather.NewWeatherConditionEvent` when there is a weather condition update - Requires: * The :class:`platypush.plugins.weather.openweathermap.WeatherOpenWeatherMapPlugin` plugin configured @@ -19,7 +15,9 @@ class WeatherOpenweathermapBackend(WeatherBackend): """ :param poll_seconds: How often the backend should check for updates (default: every minute). """ - super().__init__(plugin_name='weather.openweathermap', poll_seconds=poll_seconds, **kwargs) + super().__init__( + plugin_name='weather.openweathermap', poll_seconds=poll_seconds, **kwargs + ) # vim:sw=4:ts=4:et: diff --git a/platypush/backend/wiimote/__init__.py b/platypush/backend/wiimote/__init__.py index 0fa1ae270..1d5819619 100644 --- a/platypush/backend/wiimote/__init__.py +++ b/platypush/backend/wiimote/__init__.py @@ -13,14 +13,10 @@ class WiimoteBackend(Backend): """ Backend to communicate with a Nintendo WiiMote controller - Triggers: - - * :class:`platypush.message.event.wiimote.WiimoteEvent` \ - when the state of the Wiimote (battery, buttons, acceleration etc.) changes - Requires: * **python3-wiimote** (follow instructions at https://github.com/azzra/python3-wiimote) + """ _wiimote = None diff --git a/platypush/backend/wiimote/manifest.yaml b/platypush/backend/wiimote/manifest.yaml index b1f41b0cf..44a6af1a3 100644 --- a/platypush/backend/wiimote/manifest.yaml +++ b/platypush/backend/wiimote/manifest.yaml @@ -1,7 +1,6 @@ manifest: events: - platypush.message.event.wiimote.WiimoteEvent: when the state of the Wiimote (battery, - buttons, acceleration etc.) changes + - platypush.message.event.wiimote.WiimoteEvent install: apt: - libcwiid1 diff --git a/platypush/backend/zwave/__init__.py b/platypush/backend/zwave/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/platypush/backend/zwave/mqtt/__init__.py b/platypush/backend/zwave/mqtt/__init__.py deleted file mode 100644 index 9bf93da5c..000000000 --- a/platypush/backend/zwave/mqtt/__init__.py +++ /dev/null @@ -1,34 +0,0 @@ -import warnings - -from platypush.backend import Backend - - -class ZwaveMqttBackend(Backend): - """ - Listen for events on a zwave2mqtt service. - - **WARNING**: This backend is **DEPRECATED** and it will be removed in a - future version. - - It has been merged with - :class:`platypush.plugins.zwave.mqtt.ZwaveMqttPlugin`. - - Now you can simply configure the `zwave.mqtt` plugin in order to enable - the Zwave integration - no need to enable both the plugin and the backend. - """ - - def run(self): - super().run() - warnings.warn( - ''' - The zwave.mqtt backend has been merged into the zwave.mqtt plugin. - It is now deprecated and it will be removed in a future version. - Please remove any references to it from your configuration. - ''', - DeprecationWarning, - ) - - self.wait_stop() - - -# vim:sw=4:ts=4:et: diff --git a/platypush/backend/zwave/mqtt/manifest.yaml b/platypush/backend/zwave/mqtt/manifest.yaml deleted file mode 100644 index f539c5551..000000000 --- a/platypush/backend/zwave/mqtt/manifest.yaml +++ /dev/null @@ -1,28 +0,0 @@ -manifest: - events: - platypush.message.event.zwave.ZwaveNodeAddedEvent: when a node is added to the - network. - platypush.message.event.zwave.ZwaveNodeAsleepEvent: when a node goes into sleep - mode. - platypush.message.event.zwave.ZwaveNodeAwakeEvent: when a node goes back into - awake mode. - platypush.message.event.zwave.ZwaveNodeEvent: when a node attribute changes. - platypush.message.event.zwave.ZwaveNodeReadyEvent: when a node is ready. - platypush.message.event.zwave.ZwaveNodeRemovedEvent: when a node is removed from - the network. - platypush.message.event.zwave.ZwaveNodeRenamedEvent: when a node is renamed. - platypush.message.event.zwave.ZwaveValueChangedEvent: when the value of a node - on the networkchanges. - install: - apk: - - py3-paho-mqtt - dnf: - - python-paho-mqtt - pacman: - - python-paho-mqtt - apt: - - python3-paho-mqtt - pip: - - paho-mqtt - package: platypush.backend.zwave.mqtt - type: backend diff --git a/platypush/plugins/adafruit/io/__init__.py b/platypush/plugins/adafruit/io/__init__.py index 2f7e100e5..31dcbfae9 100644 --- a/platypush/plugins/adafruit/io/__init__.py +++ b/platypush/plugins/adafruit/io/__init__.py @@ -18,11 +18,6 @@ class AdafruitIoPlugin(Plugin): You can send values to feeds on your Adafruit IO account and read the values of those feeds as well through any device. - Requires: - - * **adafruit-io** (``pip install adafruit-io``) - * Redis server running and Redis backend configured if you want to enable throttling - Some example usages:: # Send the temperature value for a connected sensor to the "temperature" feed @@ -63,6 +58,7 @@ class AdafruitIoPlugin(Plugin): """ from Adafruit_IO import Client + global data_throttler_lock super().__init__(**kwargs) @@ -109,15 +105,19 @@ class AdafruitIoPlugin(Plugin): while True: try: new_data = ast.literal_eval( - redis.blpop(self._DATA_THROTTLER_QUEUE)[1].decode('utf-8')) + redis.blpop(self._DATA_THROTTLER_QUEUE)[1].decode('utf-8') + ) for (key, value) in new_data.items(): data.setdefault(key, []).append(value) except QueueTimeoutError: pass - if data and (last_processed_batch_timestamp is None or - time.time() - last_processed_batch_timestamp >= self.throttle_seconds): + if data and ( + last_processed_batch_timestamp is None + or time.time() - last_processed_batch_timestamp + >= self.throttle_seconds + ): last_processed_batch_timestamp = time.time() self.logger.info('Processing feeds batch for Adafruit IO') @@ -128,8 +128,10 @@ class AdafruitIoPlugin(Plugin): try: self.send(feed, value, enqueue=False) except ThrottlingError: - self.logger.warning('Adafruit IO throttling threshold hit, taking a nap ' + - 'before retrying') + self.logger.warning( + 'Adafruit IO throttling threshold hit, taking a nap ' + + 'before retrying' + ) time.sleep(self.throttle_seconds) data = {} @@ -184,11 +186,15 @@ class AdafruitIoPlugin(Plugin): :type value: Numeric or string """ - self.aio.send_data(feed=feed, value=value, metadata={ - 'lat': lat, - 'lon': lon, - 'ele': ele, - }) + self.aio.send_data( + feed=feed, + value=value, + metadata={ + 'lat': lat, + 'lon': lon, + 'ele': ele, + }, + ) @classmethod def _cast_value(cls, value): @@ -205,9 +211,12 @@ class AdafruitIoPlugin(Plugin): return [ { attr: self._cast_value(getattr(i, attr)) - if attr == 'value' else getattr(i, attr) - for attr in DATA_FIELDS if getattr(i, attr) is not None - } for i in data + if attr == 'value' + else getattr(i, attr) + for attr in DATA_FIELDS + if getattr(i, attr) is not None + } + for i in data ] @action diff --git a/platypush/plugins/arduino/__init__.py b/platypush/plugins/arduino/__init__.py index 06b00543b..a191bbecf 100644 --- a/platypush/plugins/arduino/__init__.py +++ b/platypush/plugins/arduino/__init__.py @@ -58,17 +58,6 @@ class ArduinoPlugin(SensorPlugin): Download and flash the `Standard Firmata `_ firmware to the Arduino in order to use this plugin. - - Requires: - - * **pyfirmata2** (``pip install pyfirmata2``) - - Triggers: - - * :class:`platypush.message.event.sensor.SensorDataAboveThresholdEvent` - * :class:`platypush.message.event.sensor.SensorDataBelowThresholdEvent` - * :class:`platypush.message.event.sensor.SensorDataChangeEvent` - """ def __init__( diff --git a/platypush/plugins/assistant/echo/__init__.py b/platypush/plugins/assistant/echo/__init__.py index 8d8292c22..c9429ba87 100644 --- a/platypush/plugins/assistant/echo/__init__.py +++ b/platypush/plugins/assistant/echo/__init__.py @@ -25,18 +25,6 @@ class AssistantEchoPlugin(AssistantPlugin): 4. Log in to your Amazon account 5. The required credentials will be stored to ~/.avs.json - Triggers: - - * :class:`platypush.message.event.assistant.ConversationStartEvent` - when a new conversation starts - * :class:`platypush.message.event.assistant.SpeechRecognizedEvent` - when a new voice command is recognized - * :class:`platypush.message.event.assistant.ConversationEndEvent` - when a new conversation ends - - Requires: - - * **avs** (``pip install avs``) """ def __init__( diff --git a/platypush/plugins/assistant/google/pushtotalk/__init__.py b/platypush/plugins/assistant/google/pushtotalk/__init__.py index b094534cd..addcd0b8a 100644 --- a/platypush/plugins/assistant/google/pushtotalk/__init__.py +++ b/platypush/plugins/assistant/google/pushtotalk/__init__.py @@ -20,22 +20,6 @@ from platypush.plugins.assistant import AssistantPlugin class AssistantGooglePushtotalkPlugin(AssistantPlugin): """ Plugin for the Google Assistant push-to-talk API. - - Triggers: - - * :class:`platypush.message.event.assistant.ConversationStartEvent` - when a new conversation starts - * :class:`platypush.message.event.assistant.SpeechRecognizedEvent` - when a new voice command is recognized - * :class:`platypush.message.event.assistant.ConversationEndEvent` - when a new conversation ends - - Requires: - - * **tenacity** (``pip install tenacity``) - * **google-assistant-sdk** (``pip install google-assistant-sdk[samples]``) - * **google-auth** (``pip install google-auth``) - """ api_endpoint = 'embeddedassistant.googleapis.com' diff --git a/platypush/plugins/bluetooth/__init__.py b/platypush/plugins/bluetooth/__init__.py index e91e061b2..ae8be5de9 100644 --- a/platypush/plugins/bluetooth/__init__.py +++ b/platypush/plugins/bluetooth/__init__.py @@ -1,3 +1,650 @@ -from ._plugin import BluetoothPlugin +import base64 +import os +import re +from queue import Empty, Queue +import threading +import time +from typing import ( + Any, + Collection, + Dict, + Final, + List, + Optional, + Union, + Type, +) + +from platypush.common import StoppableThread +from platypush.context import get_bus, get_plugin +from platypush.entities import ( + EnumSwitchEntityManager, + get_entities_engine, +) +from platypush.entities.bluetooth import BluetoothDevice, BluetoothService +from platypush.message.event.bluetooth import ( + BluetoothScanPausedEvent, + BluetoothScanResumedEvent, +) +from platypush.plugins import RunnablePlugin, action +from platypush.plugins.db import DbPlugin + +from ._ble import BLEManager +from ._cache import EntityCache +from ._legacy import LegacyManager +from ._types import DevicesBlacklist, RawServiceClass +from ._manager import BaseBluetoothManager + + +# pylint: disable=too-many-ancestors +class BluetoothPlugin(RunnablePlugin, EnumSwitchEntityManager): + """ + Plugin to interact with Bluetooth devices. + + This plugin uses `Bleak `_ to interact + with the Bluetooth stack and `Theengs `_ + to map the services exposed by the devices into native entities. + + The full list of devices natively supported can be found + `here `_. + + It also supports legacy Bluetooth services, as well as the transfer of + files. + + Note that the support for Bluetooth low-energy devices requires a Bluetooth + adapter compatible with the Bluetooth 5.0 specification or higher. + """ + + _default_connect_timeout: Final[int] = 20 + """ Default connection timeout (in seconds) """ + + _default_scan_duration: Final[float] = 10.0 + """ Default duration of a discovery session (in seconds) """ + + def __init__( + self, + interface: Optional[str] = None, + connect_timeout: float = _default_connect_timeout, + service_uuids: Optional[Collection[RawServiceClass]] = None, + scan_paused_on_start: bool = False, + poll_interval: float = _default_scan_duration, + exclude_known_noisy_beacons: bool = True, + ignored_device_addresses: Optional[Collection[str]] = None, + ignored_device_names: Optional[Collection[str]] = None, + ignored_device_manufacturers: Optional[Collection[str]] = None, + **kwargs, + ): + """ + :param interface: Name of the Bluetooth interface to use (e.g. ``hci0`` + on Linux). Default: first available interface. + :param connect_timeout: Timeout in seconds for the connection to a + Bluetooth device. Default: 20 seconds. + :param service_uuids: List of service UUIDs to discover. + Default: all. + :param scan_paused_on_start: If ``True``, the plugin will not the + scanning thread until :meth:`.scan_resume` is called (default: + ``False``). + :param exclude_known_noisy_beacons: Exclude BLE beacons from devices + known for being very noisy. It mainly includes tracking services on + Google, Apple, Microsoft and Samsung devices. These devices are + also known for refreshing their MAC address very frequently, which + may result in a large (and constantly increasing) list of devices. + Disable this flag if you need to track BLE beacons from these + devices, but beware that you may need periodically clean up your + list of scanned devices. + :param ignored_device_addresses: List of device addresses to ignore. + :param ignored_device_names: List of device names to ignore. + :param ignored_device_manufacturers: List of device manufacturers to + ignore. + """ + kwargs['poll_interval'] = poll_interval + super().__init__(**kwargs) + + self._interface: Optional[str] = interface + """ Default Bluetooth interface to use """ + self._connect_timeout: float = connect_timeout + """ Connection timeout in seconds """ + self._service_uuids: Collection[RawServiceClass] = service_uuids or [] + """ UUIDs to discover """ + self._scan_lock = threading.RLock() + """ Lock to synchronize scanning access to the Bluetooth device """ + self._scan_enabled = threading.Event() + """ Event used to enable/disable scanning """ + self._device_queue: Queue[BluetoothDevice] = Queue() + """ + Queue used by the Bluetooth managers to published the discovered + Bluetooth devices. + """ + self._device_cache = EntityCache() + """ + Cache of the devices discovered by the plugin. + """ + self._excluded_known_noisy_beacons = exclude_known_noisy_beacons + """ Exclude known noisy BLE beacons. """ + + self._blacklist = DevicesBlacklist( + addresses=set(ignored_device_addresses or []), + names=set(ignored_device_names or []), + manufacturers=set(ignored_device_manufacturers or []), + ) + """ Blacklist rules for the devices to ignore. """ + + self._managers: Dict[Type[BaseBluetoothManager], BaseBluetoothManager] = {} + """ + Bluetooth managers threads, one for BLE devices and one for non-BLE + devices. + """ + + self._scan_controller_timer: Optional[threading.Timer] = None + """ Timer used to temporarily pause the discovery process """ + + if not scan_paused_on_start: + self._scan_enabled.set() + + def _refresh_cache(self) -> None: + # Wait for the entities engine to start + get_entities_engine().wait_start() + + with get_plugin(DbPlugin).get_session( + autoflush=False, autocommit=False, expire_on_commit=False + ) as session: + existing_devices = [d.copy() for d in session.query(BluetoothDevice).all()] + + for dev in existing_devices: + self._device_cache.add(dev) + + def _init_bluetooth_managers(self): + """ + Initializes the Bluetooth managers threads. + """ + manager_args = { + 'interface': self._interface, + 'poll_interval': self.poll_interval, + 'connect_timeout': self._connect_timeout, + 'stop_event': self._should_stop, + 'scan_lock': self._scan_lock, + 'scan_enabled': self._scan_enabled, + 'device_queue': self._device_queue, + 'service_uuids': list(map(BluetoothService.to_uuid, self._service_uuids)), + 'device_cache': self._device_cache, + 'exclude_known_noisy_beacons': self._excluded_known_noisy_beacons, + 'blacklist': self._blacklist, + } + + self._managers = { + BLEManager: BLEManager(**manager_args), + LegacyManager: LegacyManager(**manager_args), + } + + def _scan_state_set(self, state: bool, duration: Optional[float] = None): + """ + Set the state of the scanning process. + + :param state: ``True`` to enable the scanning process, ``False`` to + disable it. + :param duration: The duration of the pause (in seconds) or ``None``. + """ + + def timer_callback(): + if state: + self.scan_pause() + else: + self.scan_resume() + + self._scan_controller_timer = None + + with self._scan_lock: + if not state and self._scan_enabled.is_set(): + get_bus().post(BluetoothScanPausedEvent(duration=duration)) + elif state and not self._scan_enabled.is_set(): + get_bus().post(BluetoothScanResumedEvent(duration=duration)) + + if state: + self._scan_enabled.set() + else: + self._scan_enabled.clear() + + if duration and not self._scan_controller_timer: + self._scan_controller_timer = threading.Timer(duration, timer_callback) + self._scan_controller_timer.start() + + def _cancel_scan_controller_timer(self): + """ + Cancels a scan controller timer if scheduled. + """ + if self._scan_controller_timer: + self._scan_controller_timer.cancel() + + def _manager_by_device( + self, + device: BluetoothDevice, + port: Optional[int] = None, + service_uuid: Optional[Union[str, RawServiceClass]] = None, + ) -> BaseBluetoothManager: + """ + :param device: A discovered Bluetooth device. + :param port: The port to connect to. + :param service_uuid: The UUID of the service to connect to. + :return: The manager associated with the device (BLE or legacy). + """ + # No port nor service UUID -> use the BLE manager for direct connection + if not (port or service_uuid): + return self._managers[BLEManager] + + uuid = BluetoothService.to_uuid(service_uuid) if service_uuid else None + matching_services = ( + [srv for srv in device.services if srv.port == port] + if port + else [srv for srv in device.services if srv.uuid == uuid] + ) + + if not matching_services: + # It could be a GATT characteristic, so try BLE + return self._managers[BLEManager] + + srv = matching_services[0] + return ( + self._managers[BLEManager] if srv.is_ble else self._managers[LegacyManager] + ) + + def _get_device(self, device: str, _fail_if_not_cached=False) -> BluetoothDevice: + """ + Get a device by its address or name, and scan for it if it's not + cached. + """ + # If device is a compound entity ID in the format + # ``:``, then split the MAC address part + m = re.match(r'^(([0-9a-f]{2}:){6}):.*', device, re.IGNORECASE) + if m: + device = m.group(1).rstrip(':') + + dev = self._device_cache.get(device) + if dev: + return dev + + assert not _fail_if_not_cached, f'Device {device} not found' + self.logger.info('Scanning for unknown device %s', device) + self.scan() + return self._get_device(device, _fail_if_not_cached=True) + + @action + def connect( + self, + device: str, + port: Optional[int] = None, + service_uuid: Optional[Union[RawServiceClass, str]] = None, + interface: Optional[str] = None, + timeout: Optional[float] = None, + ): + """ + Pair and connect to a device by address or name. + + :param device: The device address or name. + :param port: The port to connect to. Either ``port`` or + ``service_uuid`` is required for non-BLE devices. + :param service_uuid: The UUID of the service to connect to. Either + ``port`` or ``service_uuid`` is required for non-BLE devices. + :param interface: The Bluetooth interface to use (it overrides the + default ``interface``). + :param timeout: The connection timeout in seconds (it overrides the + default ``connect_timeout``). + """ + dev = self._get_device(device) + manager = self._manager_by_device(dev, port=port, service_uuid=service_uuid) + uuid = BluetoothService.to_uuid(service_uuid) if service_uuid else None + manager.connect( + dev.address, + port=port, + service_uuid=uuid, + interface=interface, + timeout=timeout, + ) + + @action + def disconnect( + self, + device: str, + port: Optional[int] = None, + service_uuid: Optional[RawServiceClass] = None, + ): + """ + Close an active connection to a device. + + Note that this method can only close connections that have been + initiated by the application. It can't close connections owned by + other applications or agents. + + :param device: The device address or name. + :param port: If connected to a non-BLE device, the optional port to + disconnect. + :param service_uuid: The optional UUID of the service to disconnect + from, for non-BLE devices. + """ + dev = self._get_device(device) + uuid = BluetoothService.to_uuid(service_uuid) if service_uuid else None + err = None + success = False + + for manager in self._managers.values(): + try: + manager.disconnect(dev.address, port=port, service_uuid=uuid) + success = True + except Exception as e: + err = e + + assert success, f'Could not disconnect from {device}: {err}' + + @action + def scan_pause(self, duration: Optional[float] = None): + """ + Pause the scanning thread. + + :param duration: For how long the scanning thread should be paused + (default: null = indefinitely). + """ + self._scan_state_set(False, duration) + + @action + def scan_resume(self, duration: Optional[float] = None): + """ + Resume the scanning thread, if inactive. + + :param duration: For how long the scanning thread should be running + (default: null = indefinitely). + """ + self._scan_state_set(True, duration) + + @action + def scan( + self, + duration: Optional[float] = None, + devices: Optional[Collection[str]] = None, + service_uuids: Optional[Collection[RawServiceClass]] = None, + ) -> List[BluetoothDevice]: + """ + Scan for Bluetooth devices nearby and return the results as a list of + entities. + + :param duration: Scan duration in seconds (default: same as the plugin's + `poll_interval` configuration parameter) + :param devices: List of device addresses or names to scan for. + :param service_uuids: List of service UUIDs to discover. Default: all. + """ + scanned_device_addresses = set() + duration = duration or self.poll_interval or self._default_scan_duration + uuids = {BluetoothService.to_uuid(uuid) for uuid in (service_uuids or [])} + + for manager in self._managers.values(): + scanned_device_addresses.update( + [ + device.address + for device in manager.scan(duration=duration // len(self._managers)) + if (not uuids or any(srv.uuid in uuids for srv in device.services)) + and ( + not devices + or device.address in devices + or device.name in devices + ) + ] + ) + + with get_plugin(DbPlugin).get_session( + autoflush=False, autocommit=False, expire_on_commit=False + ) as session: + return [ + d.copy() + for d in session.query(BluetoothDevice).all() + if d.address in scanned_device_addresses + ] + + @action + def read( + self, + device: str, + service_uuid: RawServiceClass, + interface: Optional[str] = None, + connect_timeout: Optional[float] = None, + ) -> str: + """ + Read a message from a device. + + :param device: Name or address of the device to read from. + :param service_uuid: Service UUID. + :param interface: Bluetooth adapter name to use (default configured if None). + :param connect_timeout: Connection timeout in seconds (default: same as the + configured `connect_timeout`). + :return: The base64-encoded response received from the device. + """ + dev = self._get_device(device) + uuid = BluetoothService.to_uuid(service_uuid) + manager = self._manager_by_device(dev, service_uuid=uuid) + data = manager.read( + dev.address, uuid, interface=interface, connect_timeout=connect_timeout + ) + return base64.b64encode(data).decode() + + @action + def write( + self, + device: str, + data: str, + service_uuid: RawServiceClass, + interface: Optional[str] = None, + connect_timeout: Optional[float] = None, + ): + """ + Writes data to a device + + :param device: Name or address of the device to read from. + :param data: Data to be written, as a base64-encoded string. + :param service_uuid: Service UUID. + :param interface: Bluetooth adapter name to use (default configured if None) + :param connect_timeout: Connection timeout in seconds (default: same as the + configured `connect_timeout`). + """ + binary_data = base64.b64decode(data.encode()) + dev = self._get_device(device) + uuid = BluetoothService.to_uuid(service_uuid) + manager = self._manager_by_device(dev, service_uuid=uuid) + manager.write( + dev.address, + binary_data, + service_uuid=uuid, + interface=interface, + connect_timeout=connect_timeout, + ) + + @action + def set(self, entity: str, value: Any, **_): + """ + Set the value of an entity. + + This is currently only supported for SwitchBot devices, where the value + can be one among ``on``, ``off`` and ``press``. + + :param entity: The entity to set the value for. It can be the full + entity ID in the format ``::``, or just + the MAC address if the plugin supports it. + :param value: The value to set the entity to. + """ + device = self._get_device(entity) + matching_plugin = next( + iter( + plugin + for manager in self._managers.values() + for plugin in manager.plugins + if plugin.supports_device(device) + ), + None, + ) + + assert ( + matching_plugin is not None + ), f'Action `set` not supported on device {entity}' + + method = getattr(matching_plugin, 'set', None) + assert method, f'The plugin {matching_plugin} does not support `set`' + return method(device, value) + + @action + def send_file( + self, + file: str, + device: str, + data: Optional[Union[str, bytes, bytearray]] = None, + binary: bool = False, + ): + """ + Send a file to a device that exposes an OBEX Object Push service. + + :param file: Path of the file to be sent. If ``data`` is specified + then ``file`` should include the proposed file on the + receiving host. + :param data: Alternatively to a file on disk you can send raw (string + or binary) content. + :param device: Device address or name. + :param binary: Set to true if data is a base64-encoded binary string. + """ + from ._file import FileSender + + if not data: + file = os.path.abspath(os.path.expanduser(file)) + with open(file, 'rb') as f: + binary_data = f.read() + elif binary: + binary_data = base64.b64decode( + data.encode() if isinstance(data, str) else data + ) + elif isinstance(data, str): + binary_data = data.encode() + else: + binary_data = data + + sender = FileSender(self._managers[LegacyManager]) # type: ignore + sender.send_file(file, device, binary_data) + + @action + def status( + self, + *_, + duration: Optional[float] = None, + devices: Optional[Collection[str]] = None, + service_uuids: Optional[Collection[RawServiceClass]] = None, + **__, + ) -> List[BluetoothDevice]: + """ + Retrieve the status of all the devices, or the matching + devices/services. + + If scanning is currently disabled, it will enable it and perform a + scan. + + The differences between this method and :meth:`.scan` are: + + 1. :meth:`.status` will return the status of all the devices known + to the application, while :meth:`.scan` will return the status + only of the devices discovered in the provided time window. + + 2. :meth:`.status` will not initiate a new scan if scanning is + already enabled (it will only return the status of the known + devices), while :meth:`.scan` will initiate a new scan. + + :param duration: Scan duration in seconds, if scanning is disabled + (default: same as the plugin's `poll_interval` configuration + parameter) + :param devices: List of device addresses or names to filter for. + Default: all. + :param service_uuids: List of service UUIDs to filter for. Default: + all. + """ + if not self._scan_enabled.is_set(): + self.scan( + duration=duration, + devices=devices, + service_uuids=service_uuids, + ) + + with get_plugin(DbPlugin).get_session( + autoflush=False, autocommit=False, expire_on_commit=False + ) as session: + known_devices = [ + d.copy() + for d in session.query(BluetoothDevice).all() + if (not devices or d.address in devices or d.name in devices) + and ( + not service_uuids + or any(str(srv.uuid) in service_uuids for srv in d.services) + ) + ] + + # Send entity update events to keep any asynchronous clients in sync + get_entities_engine().notify(*known_devices) + return known_devices + + def transform_entities( + self, entities: Collection[BluetoothDevice] + ) -> Collection[BluetoothDevice]: + return super().transform_entities(entities) + + def main(self): + self._refresh_cache() + self._init_bluetooth_managers() + + for manager in self._managers.values(): + manager.start() + + try: + while not self.should_stop(): + try: + device = self._device_queue.get(timeout=1) + except Empty: + continue + + device = self._device_cache.add(device) + self.publish_entities([device], callback=self._device_cache.add) + finally: + self.stop() + + def stop(self): + """ + Upon stop request, it stops any pending scans and closes all active + connections. + """ + super().stop() + + self._cancel_scan_controller_timer() + self._stop_threads(self._managers.values()) + + def _stop_threads(self, threads: Collection[StoppableThread], timeout: float = 5): + """ + Set the stop events on active threads and wait for them to stop. + """ + # Set the stop events and call `.stop` + for thread in threads: + if thread and thread.is_alive(): + self.logger.info('Waiting for %s to stop', thread.name) + try: + thread.stop() + except Exception as e: + self.logger.exception('Error while stopping %s: %s', thread.name, e) + + # Wait for the manager threads to stop + wait_start = time.time() + + for thread in threads: + if ( + thread + and thread.ident != threading.current_thread().ident + and thread.is_alive() + ): + thread.join(timeout=max(0, int(timeout - (time.time() - wait_start)))) + + if thread and thread.is_alive(): + self.logger.warning( + 'Timeout while waiting for %s to stop', thread.name + ) + __all__ = ["BluetoothPlugin"] + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/bluetooth/_plugin.py b/platypush/plugins/bluetooth/_plugin.py deleted file mode 100644 index c9ea3be2d..000000000 --- a/platypush/plugins/bluetooth/_plugin.py +++ /dev/null @@ -1,674 +0,0 @@ -import base64 -import os -import re -from queue import Empty, Queue -import threading -import time -from typing import ( - Any, - Collection, - Dict, - Final, - List, - Optional, - Union, - Type, -) - -from platypush.common import StoppableThread -from platypush.context import get_bus, get_plugin -from platypush.entities import ( - EnumSwitchEntityManager, - get_entities_engine, -) -from platypush.entities.bluetooth import BluetoothDevice, BluetoothService -from platypush.message.event.bluetooth import ( - BluetoothScanPausedEvent, - BluetoothScanResumedEvent, -) -from platypush.plugins import RunnablePlugin, action -from platypush.plugins.db import DbPlugin - -from ._ble import BLEManager -from ._cache import EntityCache -from ._legacy import LegacyManager -from ._types import DevicesBlacklist, RawServiceClass -from ._manager import BaseBluetoothManager - - -# pylint: disable=too-many-ancestors -class BluetoothPlugin(RunnablePlugin, EnumSwitchEntityManager): - """ - Plugin to interact with Bluetooth devices. - - This plugin uses `_Bleak_ `_ to interact - with the Bluetooth stack and `_Theengs_ `_ - to map the services exposed by the devices into native entities. - - The full list of devices natively supported can be found - `here `_. - - It also supports legacy Bluetooth services, as well as the transfer of - files. - - Note that the support for Bluetooth low-energy devices requires a Bluetooth - adapter compatible with the Bluetooth 5.0 specification or higher. - - Requires: - - * **bleak** (``pip install bleak``) - * **bluetooth-numbers** (``pip install bluetooth-numbers``) - * **TheengsDecoder** (``pip install TheengsDecoder``) - * **pydbus** (``pip install pydbus``) - * **pybluez** (``pip install git+https://github.com/pybluez/pybluez``) - - Triggers: - - * :class:`platypush.message.event.bluetooth.BluetoothConnectionFailedEvent` - * :class:`platypush.message.event.bluetooth.BluetoothDeviceConnectedEvent` - * :class:`platypush.message.event.bluetooth.BluetoothDeviceDisconnectedEvent` - * :class:`platypush.message.event.bluetooth.BluetoothDeviceFoundEvent` - * :class:`platypush.message.event.bluetooth.BluetoothDeviceLostEvent` - * :class:`platypush.message.event.bluetooth.BluetoothFileReceivedEvent` - * :class:`platypush.message.event.bluetooth.BluetoothFileSentEvent` - * :class:`platypush.message.event.bluetooth.BluetoothFileTransferStartedEvent` - * :class:`platypush.message.event.bluetooth.BluetoothScanPausedEvent` - * :class:`platypush.message.event.bluetooth.BluetoothScanResumedEvent` - * :class:`platypush.message.event.entities.EntityUpdateEvent` - - """ - - _default_connect_timeout: Final[int] = 20 - """ Default connection timeout (in seconds) """ - - _default_scan_duration: Final[float] = 10.0 - """ Default duration of a discovery session (in seconds) """ - - def __init__( - self, - interface: Optional[str] = None, - connect_timeout: float = _default_connect_timeout, - service_uuids: Optional[Collection[RawServiceClass]] = None, - scan_paused_on_start: bool = False, - poll_interval: float = _default_scan_duration, - exclude_known_noisy_beacons: bool = True, - ignored_device_addresses: Optional[Collection[str]] = None, - ignored_device_names: Optional[Collection[str]] = None, - ignored_device_manufacturers: Optional[Collection[str]] = None, - **kwargs, - ): - """ - :param interface: Name of the Bluetooth interface to use (e.g. ``hci0`` - on Linux). Default: first available interface. - :param connect_timeout: Timeout in seconds for the connection to a - Bluetooth device. Default: 20 seconds. - :param service_uuids: List of service UUIDs to discover. - Default: all. - :param scan_paused_on_start: If ``True``, the plugin will not the - scanning thread until :meth:`.scan_resume` is called (default: - ``False``). - :param exclude_known_noisy_beacons: Exclude BLE beacons from devices - known for being very noisy. It mainly includes tracking services on - Google, Apple, Microsoft and Samsung devices. These devices are - also known for refreshing their MAC address very frequently, which - may result in a large (and constantly increasing) list of devices. - Disable this flag if you need to track BLE beacons from these - devices, but beware that you may need periodically clean up your - list of scanned devices. - :param ignored_device_addresses: List of device addresses to ignore. - :param ignored_device_names: List of device names to ignore. - :param ignored_device_manufacturers: List of device manufacturers to - ignore. - """ - kwargs['poll_interval'] = poll_interval - super().__init__(**kwargs) - - self._interface: Optional[str] = interface - """ Default Bluetooth interface to use """ - self._connect_timeout: float = connect_timeout - """ Connection timeout in seconds """ - self._service_uuids: Collection[RawServiceClass] = service_uuids or [] - """ UUIDs to discover """ - self._scan_lock = threading.RLock() - """ Lock to synchronize scanning access to the Bluetooth device """ - self._scan_enabled = threading.Event() - """ Event used to enable/disable scanning """ - self._device_queue: Queue[BluetoothDevice] = Queue() - """ - Queue used by the Bluetooth managers to published the discovered - Bluetooth devices. - """ - self._device_cache = EntityCache() - """ - Cache of the devices discovered by the plugin. - """ - self._excluded_known_noisy_beacons = exclude_known_noisy_beacons - """ Exclude known noisy BLE beacons. """ - - self._blacklist = DevicesBlacklist( - addresses=set(ignored_device_addresses or []), - names=set(ignored_device_names or []), - manufacturers=set(ignored_device_manufacturers or []), - ) - """ Blacklist rules for the devices to ignore. """ - - self._managers: Dict[Type[BaseBluetoothManager], BaseBluetoothManager] = {} - """ - Bluetooth managers threads, one for BLE devices and one for non-BLE - devices. - """ - - self._scan_controller_timer: Optional[threading.Timer] = None - """ Timer used to temporarily pause the discovery process """ - - if not scan_paused_on_start: - self._scan_enabled.set() - - def _refresh_cache(self) -> None: - # Wait for the entities engine to start - get_entities_engine().wait_start() - - with get_plugin(DbPlugin).get_session( - autoflush=False, autocommit=False, expire_on_commit=False - ) as session: - existing_devices = [d.copy() for d in session.query(BluetoothDevice).all()] - - for dev in existing_devices: - self._device_cache.add(dev) - - def _init_bluetooth_managers(self): - """ - Initializes the Bluetooth managers threads. - """ - manager_args = { - 'interface': self._interface, - 'poll_interval': self.poll_interval, - 'connect_timeout': self._connect_timeout, - 'stop_event': self._should_stop, - 'scan_lock': self._scan_lock, - 'scan_enabled': self._scan_enabled, - 'device_queue': self._device_queue, - 'service_uuids': list(map(BluetoothService.to_uuid, self._service_uuids)), - 'device_cache': self._device_cache, - 'exclude_known_noisy_beacons': self._excluded_known_noisy_beacons, - 'blacklist': self._blacklist, - } - - self._managers = { - BLEManager: BLEManager(**manager_args), - LegacyManager: LegacyManager(**manager_args), - } - - def _scan_state_set(self, state: bool, duration: Optional[float] = None): - """ - Set the state of the scanning process. - - :param state: ``True`` to enable the scanning process, ``False`` to - disable it. - :param duration: The duration of the pause (in seconds) or ``None``. - """ - - def timer_callback(): - if state: - self.scan_pause() - else: - self.scan_resume() - - self._scan_controller_timer = None - - with self._scan_lock: - if not state and self._scan_enabled.is_set(): - get_bus().post(BluetoothScanPausedEvent(duration=duration)) - elif state and not self._scan_enabled.is_set(): - get_bus().post(BluetoothScanResumedEvent(duration=duration)) - - if state: - self._scan_enabled.set() - else: - self._scan_enabled.clear() - - if duration and not self._scan_controller_timer: - self._scan_controller_timer = threading.Timer(duration, timer_callback) - self._scan_controller_timer.start() - - def _cancel_scan_controller_timer(self): - """ - Cancels a scan controller timer if scheduled. - """ - if self._scan_controller_timer: - self._scan_controller_timer.cancel() - - def _manager_by_device( - self, - device: BluetoothDevice, - port: Optional[int] = None, - service_uuid: Optional[Union[str, RawServiceClass]] = None, - ) -> BaseBluetoothManager: - """ - :param device: A discovered Bluetooth device. - :param port: The port to connect to. - :param service_uuid: The UUID of the service to connect to. - :return: The manager associated with the device (BLE or legacy). - """ - # No port nor service UUID -> use the BLE manager for direct connection - if not (port or service_uuid): - return self._managers[BLEManager] - - uuid = BluetoothService.to_uuid(service_uuid) if service_uuid else None - matching_services = ( - [srv for srv in device.services if srv.port == port] - if port - else [srv for srv in device.services if srv.uuid == uuid] - ) - - if not matching_services: - # It could be a GATT characteristic, so try BLE - return self._managers[BLEManager] - - srv = matching_services[0] - return ( - self._managers[BLEManager] if srv.is_ble else self._managers[LegacyManager] - ) - - def _get_device(self, device: str, _fail_if_not_cached=False) -> BluetoothDevice: - """ - Get a device by its address or name, and scan for it if it's not - cached. - """ - # If device is a compound entity ID in the format - # ``:``, then split the MAC address part - m = re.match(r'^(([0-9a-f]{2}:){6}):.*', device, re.IGNORECASE) - if m: - device = m.group(1).rstrip(':') - - dev = self._device_cache.get(device) - if dev: - return dev - - assert not _fail_if_not_cached, f'Device {device} not found' - self.logger.info('Scanning for unknown device %s', device) - self.scan() - return self._get_device(device, _fail_if_not_cached=True) - - @action - def connect( - self, - device: str, - port: Optional[int] = None, - service_uuid: Optional[Union[RawServiceClass, str]] = None, - interface: Optional[str] = None, - timeout: Optional[float] = None, - ): - """ - Pair and connect to a device by address or name. - - :param device: The device address or name. - :param port: The port to connect to. Either ``port`` or - ``service_uuid`` is required for non-BLE devices. - :param service_uuid: The UUID of the service to connect to. Either - ``port`` or ``service_uuid`` is required for non-BLE devices. - :param interface: The Bluetooth interface to use (it overrides the - default ``interface``). - :param timeout: The connection timeout in seconds (it overrides the - default ``connect_timeout``). - """ - dev = self._get_device(device) - manager = self._manager_by_device(dev, port=port, service_uuid=service_uuid) - uuid = BluetoothService.to_uuid(service_uuid) if service_uuid else None - manager.connect( - dev.address, - port=port, - service_uuid=uuid, - interface=interface, - timeout=timeout, - ) - - @action - def disconnect( - self, - device: str, - port: Optional[int] = None, - service_uuid: Optional[RawServiceClass] = None, - ): - """ - Close an active connection to a device. - - Note that this method can only close connections that have been - initiated by the application. It can't close connections owned by - other applications or agents. - - :param device: The device address or name. - :param port: If connected to a non-BLE device, the optional port to - disconnect. - :param service_uuid: The optional UUID of the service to disconnect - from, for non-BLE devices. - """ - dev = self._get_device(device) - uuid = BluetoothService.to_uuid(service_uuid) if service_uuid else None - err = None - success = False - - for manager in self._managers.values(): - try: - manager.disconnect(dev.address, port=port, service_uuid=uuid) - success = True - except Exception as e: - err = e - - assert success, f'Could not disconnect from {device}: {err}' - - @action - def scan_pause(self, duration: Optional[float] = None): - """ - Pause the scanning thread. - - :param duration: For how long the scanning thread should be paused - (default: null = indefinitely). - """ - self._scan_state_set(False, duration) - - @action - def scan_resume(self, duration: Optional[float] = None): - """ - Resume the scanning thread, if inactive. - - :param duration: For how long the scanning thread should be running - (default: null = indefinitely). - """ - self._scan_state_set(True, duration) - - @action - def scan( - self, - duration: Optional[float] = None, - devices: Optional[Collection[str]] = None, - service_uuids: Optional[Collection[RawServiceClass]] = None, - ) -> List[BluetoothDevice]: - """ - Scan for Bluetooth devices nearby and return the results as a list of - entities. - - :param duration: Scan duration in seconds (default: same as the plugin's - `poll_interval` configuration parameter) - :param devices: List of device addresses or names to scan for. - :param service_uuids: List of service UUIDs to discover. Default: all. - """ - scanned_device_addresses = set() - duration = duration or self.poll_interval or self._default_scan_duration - uuids = {BluetoothService.to_uuid(uuid) for uuid in (service_uuids or [])} - - for manager in self._managers.values(): - scanned_device_addresses.update( - [ - device.address - for device in manager.scan(duration=duration // len(self._managers)) - if (not uuids or any(srv.uuid in uuids for srv in device.services)) - and ( - not devices - or device.address in devices - or device.name in devices - ) - ] - ) - - with get_plugin(DbPlugin).get_session( - autoflush=False, autocommit=False, expire_on_commit=False - ) as session: - return [ - d.copy() - for d in session.query(BluetoothDevice).all() - if d.address in scanned_device_addresses - ] - - @action - def read( - self, - device: str, - service_uuid: RawServiceClass, - interface: Optional[str] = None, - connect_timeout: Optional[float] = None, - ) -> str: - """ - Read a message from a device. - - :param device: Name or address of the device to read from. - :param service_uuid: Service UUID. - :param interface: Bluetooth adapter name to use (default configured if None). - :param connect_timeout: Connection timeout in seconds (default: same as the - configured `connect_timeout`). - :return: The base64-encoded response received from the device. - """ - dev = self._get_device(device) - uuid = BluetoothService.to_uuid(service_uuid) - manager = self._manager_by_device(dev, service_uuid=uuid) - data = manager.read( - dev.address, uuid, interface=interface, connect_timeout=connect_timeout - ) - return base64.b64encode(data).decode() - - @action - def write( - self, - device: str, - data: str, - service_uuid: RawServiceClass, - interface: Optional[str] = None, - connect_timeout: Optional[float] = None, - ): - """ - Writes data to a device - - :param device: Name or address of the device to read from. - :param data: Data to be written, as a base64-encoded string. - :param service_uuid: Service UUID. - :param interface: Bluetooth adapter name to use (default configured if None) - :param connect_timeout: Connection timeout in seconds (default: same as the - configured `connect_timeout`). - """ - binary_data = base64.b64decode(data.encode()) - dev = self._get_device(device) - uuid = BluetoothService.to_uuid(service_uuid) - manager = self._manager_by_device(dev, service_uuid=uuid) - manager.write( - dev.address, - binary_data, - service_uuid=uuid, - interface=interface, - connect_timeout=connect_timeout, - ) - - @action - def set(self, entity: str, value: Any, **_): - """ - Set the value of an entity. - - This is currently only supported for Switchbot devices, where the value - can be one among ``on``, ``off`` and ``press``. - - :param entity: The entity to set the value for. It can be the full - entity ID in the format ``::``, or just - the MAC address if the plugin supports it. - :param value: The value to set the entity to. - """ - device = self._get_device(entity) - matching_plugin = next( - iter( - plugin - for manager in self._managers.values() - for plugin in manager.plugins - if plugin.supports_device(device) - ), - None, - ) - - assert ( - matching_plugin is not None - ), f'Action `set` not supported on device {entity}' - - method = getattr(matching_plugin, 'set', None) - assert method, f'The plugin {matching_plugin} does not support `set`' - return method(device, value) - - @action - def send_file( - self, - file: str, - device: str, - data: Optional[Union[str, bytes, bytearray]] = None, - binary: bool = False, - ): - """ - Send a file to a device that exposes an OBEX Object Push service. - - :param file: Path of the file to be sent. If ``data`` is specified - then ``file`` should include the proposed file on the - receiving host. - :param data: Alternatively to a file on disk you can send raw (string - or binary) content. - :param device: Device address or name. - :param binary: Set to true if data is a base64-encoded binary string. - """ - from ._file import FileSender - - if not data: - file = os.path.abspath(os.path.expanduser(file)) - with open(file, 'rb') as f: - binary_data = f.read() - elif binary: - binary_data = base64.b64decode( - data.encode() if isinstance(data, str) else data - ) - elif isinstance(data, str): - binary_data = data.encode() - else: - binary_data = data - - sender = FileSender(self._managers[LegacyManager]) # type: ignore - sender.send_file(file, device, binary_data) - - @action - def status( - self, - *_, - duration: Optional[float] = None, - devices: Optional[Collection[str]] = None, - service_uuids: Optional[Collection[RawServiceClass]] = None, - **__, - ) -> List[BluetoothDevice]: - """ - Retrieve the status of all the devices, or the matching - devices/services. - - If scanning is currently disabled, it will enable it and perform a - scan. - - The differences between this method and :meth:`.scan` are: - - 1. :meth:`.status` will return the status of all the devices known - to the application, while :meth:`.scan` will return the status - only of the devices discovered in the provided time window. - - 2. :meth:`.status` will not initiate a new scan if scanning is - already enabled (it will only returned the status of the known - devices), while :meth:`.scan` will initiate a new scan. - - :param duration: Scan duration in seconds, if scanning is disabled - (default: same as the plugin's `poll_interval` configuration - parameter) - :param devices: List of device addresses or names to filter for. - Default: all. - :param service_uuids: List of service UUIDs to filter for. Default: - all. - """ - if not self._scan_enabled.is_set(): - self.scan( - duration=duration, - devices=devices, - service_uuids=service_uuids, - ) - - with get_plugin(DbPlugin).get_session( - autoflush=False, autocommit=False, expire_on_commit=False - ) as session: - known_devices = [ - d.copy() - for d in session.query(BluetoothDevice).all() - if (not devices or d.address in devices or d.name in devices) - and ( - not service_uuids - or any(str(srv.uuid) in service_uuids for srv in d.services) - ) - ] - - # Send entity update events to keep any asynchronous clients in sync - get_entities_engine().notify(*known_devices) - return known_devices - - def transform_entities( - self, entities: Collection[BluetoothDevice] - ) -> Collection[BluetoothDevice]: - return super().transform_entities(entities) - - def main(self): - self._refresh_cache() - self._init_bluetooth_managers() - - for manager in self._managers.values(): - manager.start() - - try: - while not self.should_stop(): - try: - device = self._device_queue.get(timeout=1) - except Empty: - continue - - device = self._device_cache.add(device) - self.publish_entities([device], callback=self._device_cache.add) - finally: - self.stop() - - def stop(self): - """ - Upon stop request, it stops any pending scans and closes all active - connections. - """ - super().stop() - - self._cancel_scan_controller_timer() - self._stop_threads(self._managers.values()) - - def _stop_threads(self, threads: Collection[StoppableThread], timeout: float = 5): - """ - Set the stop events on active threads and wait for them to stop. - """ - # Set the stop events and call `.stop` - for thread in threads: - if thread and thread.is_alive(): - self.logger.info('Waiting for %s to stop', thread.name) - try: - thread.stop() - except Exception as e: - self.logger.exception('Error while stopping %s: %s', thread.name, e) - - # Wait for the manager threads to stop - wait_start = time.time() - - for thread in threads: - if ( - thread - and thread.ident != threading.current_thread().ident - and thread.is_alive() - ): - thread.join(timeout=max(0, timeout - (time.time() - wait_start))) - - if thread and thread.is_alive(): - self.logger.warning( - 'Timeout while waiting for %s to stop', thread.name - ) - - -__all__ = ["BluetoothPlugin"] - - -# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/calendar/ical/__init__.py b/platypush/plugins/calendar/ical/__init__.py index bfe4ec20a..c5a74c933 100644 --- a/platypush/plugins/calendar/ical/__init__.py +++ b/platypush/plugins/calendar/ical/__init__.py @@ -9,11 +9,6 @@ from platypush.plugins.calendar import CalendarInterface class CalendarIcalPlugin(Plugin, CalendarInterface): """ iCal calendars plugin. Interact with remote calendars in iCal format. - - Requires: - - * **icalendar** (``pip install icalendar``) - """ def __init__(self, url, *args, **kwargs): diff --git a/platypush/plugins/camera/__init__.py b/platypush/plugins/camera/__init__.py index 632a0978a..c2d4f0490 100644 --- a/platypush/plugins/camera/__init__.py +++ b/platypush/plugins/camera/__init__.py @@ -60,25 +60,6 @@ class CameraPlugin(Plugin, ABC): Both the endpoints support the same parameters of the constructor of this class (e.g. ``device``, ``warmup_frames``, ``duration`` etc.) as ``GET`` parameters. - - Requires: - - * **Pillow** (``pip install Pillow``) [optional] default handler for image transformations. - * **wxPython** (``pip install wxPython``) [optional] default handler for camera previews (``ffplay`` will be - used as a fallback if ``wxPython`` is not installed). - * **ffmpeg** (see installation instructions for your OS) for rendering/streaming videos. - - Triggers: - - * :class:`platypush.message.event.camera.CameraRecordingStartedEvent` - when a new video recording/photo burst starts - * :class:`platypush.message.event.camera.CameraRecordingStoppedEvent` - when a video recording/photo burst ends - * :class:`platypush.message.event.camera.CameraVideoRenderedEvent` - when a sequence of captured is successfully rendered into a video - * :class:`platypush.message.event.camera.CameraPictureTakenEvent` - when a snapshot is captured and stored to an image file - """ _camera_class = Camera diff --git a/platypush/plugins/camera/cv/__init__.py b/platypush/plugins/camera/cv/__init__.py index 6ed063b6f..a2afe403f 100644 --- a/platypush/plugins/camera/cv/__init__.py +++ b/platypush/plugins/camera/cv/__init__.py @@ -7,16 +7,15 @@ from platypush.plugins.camera.model.writer.cv import CvFileWriter class CameraCvPlugin(CameraPlugin): """ Plugin to control generic cameras over OpenCV. - - Requires: - - * **opencv** (``pip install opencv-python``) - * **Pillow** (``pip install Pillow``) - """ - def __init__(self, color_transform: Optional[str] = 'COLOR_BGR2RGB', video_type: str = 'XVID', - video_writer: str = 'ffmpeg', **kwargs): + def __init__( + self, + color_transform: Optional[str] = 'COLOR_BGR2RGB', + video_type: str = 'XVID', + video_writer: str = 'ffmpeg', + **kwargs + ): """ :param device: Device ID (0 for the first camera, 1 for the second etc.) or path (e.g. ``/dev/video0``). :param video_type: Default video type to use when exporting captured frames to camera (default: 0, infers the @@ -38,7 +37,9 @@ class CameraCvPlugin(CameraPlugin): :param kwargs: Extra arguments to be passed up to :class:`platypush.plugins.camera.CameraPlugin`. """ - super().__init__(color_transform=color_transform, video_type=video_type, **kwargs) + super().__init__( + color_transform=color_transform, video_type=video_type, **kwargs + ) if video_writer == 'cv': self._video_writer_class = CvFileWriter @@ -60,12 +61,15 @@ class CameraCvPlugin(CameraPlugin): def capture_frame(self, camera: Camera, *args, **kwargs): import cv2 from PIL import Image + ret, frame = camera.object.read() assert ret, 'Cannot retrieve frame from {}'.format(camera.info.device) color_transform = camera.info.color_transform if isinstance(color_transform, str): - color_transform = getattr(cv2, color_transform or self.camera_info.color_transform) + color_transform = getattr( + cv2, color_transform or self.camera_info.color_transform + ) if color_transform: frame = cv2.cvtColor(frame, color_transform) diff --git a/platypush/plugins/camera/ffmpeg/__init__.py b/platypush/plugins/camera/ffmpeg/__init__.py index e2149efb0..14801bc8d 100644 --- a/platypush/plugins/camera/ffmpeg/__init__.py +++ b/platypush/plugins/camera/ffmpeg/__init__.py @@ -12,18 +12,18 @@ from platypush.plugins.camera.ffmpeg.model import FFmpegCamera, FFmpegCameraInfo class CameraFfmpegPlugin(CameraPlugin): """ Plugin to interact with a camera over FFmpeg. - - Requires: - - * **ffmpeg** package installed on the system. - """ _camera_class = FFmpegCamera _camera_info_class = FFmpegCameraInfo - def __init__(self, device: Optional[str] = '/dev/video0', input_format: str = 'v4l2', ffmpeg_args: Tuple[str] = (), - **opts): + def __init__( + self, + device: Optional[str] = '/dev/video0', + input_format: str = 'v4l2', + ffmpeg_args: Tuple[str] = (), + **opts + ): """ :param device: Path to the camera device (default: ``/dev/video0``). :param input_format: FFmpeg input format for the the camera device (default: ``v4l2``). @@ -35,10 +35,25 @@ class CameraFfmpegPlugin(CameraPlugin): def prepare_device(self, camera: FFmpegCamera) -> subprocess.Popen: warmup_seconds = self._get_warmup_seconds(camera) - ffmpeg = [camera.info.ffmpeg_bin, '-y', '-f', camera.info.input_format, '-i', camera.info.device, '-s', - '{}x{}'.format(*camera.info.resolution), '-ss', str(warmup_seconds), - *(('-r', str(camera.info.fps)) if camera.info.fps else ()), - '-pix_fmt', 'rgb24', '-f', 'rawvideo', *camera.info.ffmpeg_args, '-'] + ffmpeg = [ + camera.info.ffmpeg_bin, + '-y', + '-f', + camera.info.input_format, + '-i', + camera.info.device, + '-s', + '{}x{}'.format(*camera.info.resolution), + '-ss', + str(warmup_seconds), + *(('-r', str(camera.info.fps)) if camera.info.fps else ()), + '-pix_fmt', + 'rgb24', + '-f', + 'rawvideo', + *camera.info.ffmpeg_args, + '-', + ] self.logger.info('Running FFmpeg: {}'.format(' '.join(ffmpeg))) proc = subprocess.Popen(ffmpeg, stdout=subprocess.PIPE) @@ -46,7 +61,9 @@ class CameraFfmpegPlugin(CameraPlugin): proc.send_signal(signal.SIGSTOP) return proc - def start_camera(self, camera: FFmpegCamera, preview: bool = False, *args, **kwargs): + def start_camera( + self, camera: FFmpegCamera, preview: bool = False, *args, **kwargs + ): super().start_camera(*args, camera=camera, preview=preview, **kwargs) if camera.object: camera.object.send_signal(signal.SIGCONT) @@ -65,7 +82,9 @@ class CameraFfmpegPlugin(CameraPlugin): except Exception as e: self.logger.warning('Error on FFmpeg capture wait: {}'.format(str(e))) - def capture_frame(self, camera: FFmpegCamera, *args, **kwargs) -> Optional[ImageType]: + def capture_frame( + self, camera: FFmpegCamera, *args, **kwargs + ) -> Optional[ImageType]: raw_size = camera.info.resolution[0] * camera.info.resolution[1] * 3 data = camera.object.stdout.read(raw_size) if len(data) < raw_size: diff --git a/platypush/plugins/camera/gstreamer/__init__.py b/platypush/plugins/camera/gstreamer/__init__.py index bb745b3c8..f7a206e74 100644 --- a/platypush/plugins/camera/gstreamer/__init__.py +++ b/platypush/plugins/camera/gstreamer/__init__.py @@ -11,20 +11,6 @@ from platypush.common.gstreamer import Pipeline class CameraGstreamerPlugin(CameraPlugin): """ Plugin to interact with a camera over GStreamer. - - Requires: - - * **gst-python** - * **pygobject** - - On Debian and derived systems: - - * ``[sudo] apt-get install python3-gi python3-gst-1.0`` - - On Arch and derived systems: - - * ``[sudo] pacman -S gst-python`` - """ _camera_class = GStreamerCamera diff --git a/platypush/plugins/camera/ir/mlx90640/__init__.py b/platypush/plugins/camera/ir/mlx90640/__init__.py index 7a2c1ce4d..db67b2833 100644 --- a/platypush/plugins/camera/ir/mlx90640/__init__.py +++ b/platypush/plugins/camera/ir/mlx90640/__init__.py @@ -25,15 +25,15 @@ class CameraIrMlx90640Plugin(CameraPlugin): $ make bcm2835 $ make examples/rawrgb I2C_MODE=LINUX - Requires: - - * **mlx90640-library** installation (see instructions above) - * **PIL** image library (``pip install Pillow``) - """ - def __init__(self, rawrgb_path: Optional[str] = None, resolution: Tuple[int, int] = (32, 24), - warmup_frames: Optional[int] = 5, **kwargs): + def __init__( + self, + rawrgb_path: Optional[str] = None, + resolution: Tuple[int, int] = (32, 24), + warmup_frames: Optional[int] = 5, + **kwargs + ): """ :param rawrgb_path: Specify it if the rawrgb executable compiled from https://github.com/pimoroni/mlx90640-library is in another folder than @@ -42,14 +42,22 @@ class CameraIrMlx90640Plugin(CameraPlugin): :param warmup_frames: Number of frames to be skipped on sensor initialization/warmup (default: 2). :param kwargs: Extra parameters to be passed to :class:`platypush.plugins.camera.CameraPlugin`. """ - super().__init__(device='mlx90640', resolution=resolution, warmup_frames=warmup_frames, **kwargs) + super().__init__( + device='mlx90640', + resolution=resolution, + warmup_frames=warmup_frames, + **kwargs + ) if not rawrgb_path: - rawrgb_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'lib', 'examples', 'rawrgb') + rawrgb_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), 'lib', 'examples', 'rawrgb' + ) rawrgb_path = os.path.abspath(os.path.expanduser(rawrgb_path)) - assert os.path.isfile(rawrgb_path),\ - 'rawrgb executable not found. Please follow the documentation of this plugin to build it' + assert os.path.isfile( + rawrgb_path + ), 'rawrgb executable not found. Please follow the documentation of this plugin to build it' self.rawrgb_path = rawrgb_path self._capture_proc = None @@ -59,8 +67,11 @@ class CameraIrMlx90640Plugin(CameraPlugin): def prepare_device(self, device: Camera): if not self._is_capture_running(): - self._capture_proc = subprocess.Popen([self.rawrgb_path, '{}'.format(device.info.fps)], - stdin=subprocess.PIPE, stdout=subprocess.PIPE) + self._capture_proc = subprocess.Popen( + [self.rawrgb_path, '{}'.format(device.info.fps)], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + ) return self._capture_proc @@ -77,11 +88,14 @@ class CameraIrMlx90640Plugin(CameraPlugin): from PIL import Image camera = self.prepare_device(device) - frame = camera.stdout.read(device.info.resolution[0] * device.info.resolution[1] * 3) + frame = camera.stdout.read( + device.info.resolution[0] * device.info.resolution[1] * 3 + ) return Image.frombytes('RGB', device.info.resolution, frame) def to_grayscale(self, image): from PIL import Image + new_image = Image.new('L', image.size) for i in range(0, image.size[0]): diff --git a/platypush/plugins/camera/pi/__init__.py b/platypush/plugins/camera/pi/__init__.py index 8e98d2a73..cd5582211 100644 --- a/platypush/plugins/camera/pi/__init__.py +++ b/platypush/plugins/camera/pi/__init__.py @@ -12,30 +12,45 @@ class CameraPiPlugin(CameraPlugin): """ Plugin to control a Pi camera. - Requires: - - * **picamera** (``pip install picamera``) - * **numpy** (``pip install numpy``) - * **Pillow** (``pip install Pillow``) + .. warning:: + This plugin is **DEPRECATED**, as it relies on the old ``picamera`` module. + On recent systems, it should be possible to access the Pi Camera through + the ffmpeg or gstreamer integrations. """ _camera_class = PiCamera _camera_info_class = PiCameraInfo - def __init__(self, device: int = 0, fps: float = 30., warmup_seconds: float = 2., sharpness: int = 0, - contrast: int = 0, brightness: int = 50, video_stabilization: bool = False, iso: int = 0, - exposure_compensation: int = 0, exposure_mode: str = 'auto', meter_mode: str = 'average', - awb_mode: str = 'auto', image_effect: str = 'none', led_pin: Optional[int] = None, - color_effects: Optional[Union[str, List[str]]] = None, - zoom: Tuple[float, float, float, float] = (0.0, 0.0, 1.0, 1.0), **camera): + def __init__( + self, + device: int = 0, + fps: float = 30.0, + warmup_seconds: float = 2.0, + sharpness: int = 0, + contrast: int = 0, + brightness: int = 50, + video_stabilization: bool = False, + iso: int = 0, + exposure_compensation: int = 0, + exposure_mode: str = 'auto', + meter_mode: str = 'average', + awb_mode: str = 'auto', + image_effect: str = 'none', + led_pin: Optional[int] = None, + color_effects: Optional[Union[str, List[str]]] = None, + zoom: Tuple[float, float, float, float] = (0.0, 0.0, 1.0, 1.0), + **camera + ): """ See https://www.raspberrypi.org/documentation/usage/camera/python/README.md for a detailed reference about the Pi camera options. :param camera: Options for the base camera plugin (see :class:`platypush.plugins.camera.CameraPlugin`). """ - super().__init__(device=device, fps=fps, warmup_seconds=warmup_seconds, **camera) + super().__init__( + device=device, fps=fps, warmup_seconds=warmup_seconds, **camera + ) self.camera_info.sharpness = sharpness self.camera_info.contrast = contrast @@ -56,8 +71,12 @@ class CameraPiPlugin(CameraPlugin): # noinspection PyUnresolvedReferences import picamera - camera = picamera.PiCamera(camera_num=device.info.device, resolution=device.info.resolution, - framerate=device.info.fps, led_pin=device.info.led_pin) + camera = picamera.PiCamera( + camera_num=device.info.device, + resolution=device.info.resolution, + framerate=device.info.fps, + led_pin=device.info.led_pin, + ) camera.hflip = device.info.horizontal_flip camera.vflip = device.info.vertical_flip @@ -97,9 +116,11 @@ class CameraPiPlugin(CameraPlugin): import numpy as np from PIL import Image - shape = (camera.info.resolution[1] + (camera.info.resolution[1] % 16), - camera.info.resolution[0] + (camera.info.resolution[0] % 32), - 3) + shape = ( + camera.info.resolution[1] + (camera.info.resolution[1] % 16), + camera.info.resolution[0] + (camera.info.resolution[0] % 32), + 3, + ) frame = np.empty(shape, dtype=np.uint8) camera.object.capture(frame, 'rgb') @@ -121,7 +142,9 @@ class CameraPiPlugin(CameraPlugin): self.logger.warning(str(e)) @action - def capture_preview(self, duration: Optional[float] = None, n_frames: Optional[int] = None, **camera) -> dict: + def capture_preview( + self, duration: Optional[float] = None, n_frames: Optional[int] = None, **camera + ) -> dict: camera = self.open_device(**camera) self.start_preview(camera) @@ -132,11 +155,15 @@ class CameraPiPlugin(CameraPlugin): return self.status() - def streaming_thread(self, camera: PiCamera, stream_format: str, duration: Optional[float] = None): + def streaming_thread( + self, camera: PiCamera, stream_format: str, duration: Optional[float] = None + ): server_socket = self._prepare_server_socket(camera) sock = None streaming_started_time = time.time() - self.logger.info('Starting streaming on port {}'.format(camera.info.listen_port)) + self.logger.info( + 'Starting streaming on port {}'.format(camera.info.listen_port) + ) try: while camera.stream_event.is_set(): @@ -161,7 +188,9 @@ class CameraPiPlugin(CameraPlugin): try: sock.close() except Exception as e: - self.logger.warning('Error while closing client socket: {}'.format(str(e))) + self.logger.warning( + 'Error while closing client socket: {}'.format(str(e)) + ) self.close_device(camera) finally: @@ -169,7 +198,9 @@ class CameraPiPlugin(CameraPlugin): self.logger.info('Stopped camera stream') @action - def start_streaming(self, duration: Optional[float] = None, stream_format: str = 'h264', **camera) -> dict: + def start_streaming( + self, duration: Optional[float] = None, stream_format: str = 'h264', **camera + ) -> dict: camera = self.open_device(stream_format=stream_format, **camera) return self._start_streaming(camera, duration, stream_format) diff --git a/platypush/plugins/chat/irc/__init__.py b/platypush/plugins/chat/irc/__init__.py index 681c59159..e5e11ec7b 100644 --- a/platypush/plugins/chat/irc/__init__.py +++ b/platypush/plugins/chat/irc/__init__.py @@ -18,32 +18,6 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin): This plugin allows you to easily create IRC bots with custom logic that reacts to IRC events and interact with IRC sessions. - - Triggers: - - * :class:`platypush.message.event.irc.IRCChannelJoinEvent` when a user joins a channel. - * :class:`platypush.message.event.irc.IRCChannelKickEvent` when a user is kicked from a channel. - * :class:`platypush.message.event.irc.IRCModeEvent` when a user/channel mode change event occurs. - * :class:`platypush.message.event.irc.IRCPartEvent` when a user parts a channel. - * :class:`platypush.message.event.irc.IRCQuitEvent` when a user quits. - * :class:`platypush.message.event.irc.IRCNickChangeEvent` when a user nick changes. - * :class:`platypush.message.event.irc.IRCConnectEvent` when the bot connects to a server. - * :class:`platypush.message.event.irc.IRCDisconnectEvent` when the bot disconnects from a server. - * :class:`platypush.message.event.irc.IRCPrivateMessageEvent` when a private message is received. - * :class:`platypush.message.event.irc.IRCPublicMessageEvent` when a public message is received. - * :class:`platypush.message.event.irc.IRCDCCRequestEvent` when a DCC connection request is received. - * :class:`platypush.message.event.irc.IRCDCCMessageEvent` when a DCC message is received. - * :class:`platypush.message.event.irc.IRCCTCPMessageEvent` when a CTCP message is received. - * :class:`platypush.message.event.irc.IRCDCCFileRequestEvent` when a DCC file request is received. - * :class:`platypush.message.event.irc.IRCDCCFileRecvCompletedEvent` when a DCC file download is completed. - * :class:`platypush.message.event.irc.IRCDCCFileRecvCancelledEvent` when a DCC file download is cancelled. - * :class:`platypush.message.event.irc.IRCDCCFileSendCompletedEvent` when a DCC file upload is completed. - * :class:`platypush.message.event.irc.IRCDCCFileSendCancelledEvent` when a DCC file upload is cancelled. - - Requires: - - * **irc** (``pip install irc``) - """ def __init__(self, servers: Sequence[dict], **kwargs): diff --git a/platypush/plugins/chat/telegram/__init__.py b/platypush/plugins/chat/telegram/__init__.py index 0d0d8826f..504f21217 100644 --- a/platypush/plugins/chat/telegram/__init__.py +++ b/platypush/plugins/chat/telegram/__init__.py @@ -4,21 +4,28 @@ import os from threading import RLock from typing import Optional, Union -# noinspection PyPackageRequirements from telegram.ext import Updater -# noinspection PyPackageRequirements from telegram.message import Message as TelegramMessage -# noinspection PyPackageRequirements from telegram.user import User as TelegramUser -from platypush.message.response.chat.telegram import TelegramMessageResponse, TelegramFileResponse, \ - TelegramChatResponse, TelegramUserResponse, TelegramUsersResponse +from platypush.message.response.chat.telegram import ( + TelegramMessageResponse, + TelegramFileResponse, + TelegramChatResponse, + TelegramUserResponse, + TelegramUsersResponse, +) from platypush.plugins import action from platypush.plugins.chat import ChatPlugin class Resource: - def __init__(self, file_id: Optional[int] = None, url: Optional[str] = None, path: Optional[str] = None): + def __init__( + self, + file_id: Optional[int] = None, + url: Optional[str] = None, + path: Optional[str] = None, + ): assert file_id or url or path, 'You need to specify either file_id, url or path' self.file_id = file_id self.url = url @@ -27,12 +34,14 @@ class Resource: def __enter__(self): if self.path: - self._file = open(os.path.abspath(os.path.expanduser(self.path)), 'rb') + self._file = open( # noqa + os.path.abspath(os.path.expanduser(self.path)), 'rb' + ) return self._file return self.file_id or self.url - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__(self, *_, **__): if self._file: self._file.close() @@ -47,10 +56,6 @@ class ChatTelegramPlugin(ChatPlugin): 3. Copy the provided API token in the configuration of this plugin. 4. Open a conversation with your newly created bot. - Requires: - - * **python-telegram-bot** (``pip install python-telegram-bot``) - """ def __init__(self, api_token: str, **kwargs): @@ -117,7 +122,7 @@ class ChatTelegramPlugin(ChatPlugin): contact_user_id=msg.contact.user_id if msg.contact else None, contact_vcard=msg.contact.vcard if msg.contact else None, link=msg.link, - media_group_id=msg.media_group_id + media_group_id=msg.media_group_id, ) @staticmethod @@ -129,13 +134,19 @@ class ChatTelegramPlugin(ChatPlugin): first_name=user.first_name, last_name=user.last_name, language_code=user.language_code, - link=user.link + link=user.link, ) @action - def send_message(self, chat_id: Union[str, int], text: str, parse_mode: Optional[str] = None, - disable_web_page_preview: bool = False, disable_notification: bool = False, - reply_to_message_id: Optional[int] = None) -> TelegramMessageResponse: + def send_message( + self, + chat_id: Union[str, int], + text: str, + parse_mode: Optional[str] = None, + disable_web_page_preview: bool = False, + disable_notification: bool = False, + reply_to_message_id: Optional[int] = None, + ) -> TelegramMessageResponse: """ Send a message to a chat. @@ -152,25 +163,30 @@ class ChatTelegramPlugin(ChatPlugin): """ telegram = self.get_telegram() - msg = telegram.bot.send_message(chat_id=chat_id, - text=text, - parse_mode=parse_mode, - disable_web_page_preview=disable_web_page_preview, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id) + msg = telegram.bot.send_message( + chat_id=chat_id, + text=text, + parse_mode=parse_mode, + disable_web_page_preview=disable_web_page_preview, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + ) return self.parse_msg(msg) @action - def send_photo(self, chat_id: Union[str, int], - file_id: Optional[int] = None, - url: Optional[str] = None, - path: Optional[str] = None, - caption: Optional[str] = None, - parse_mode: Optional[str] = None, - disable_notification: bool = False, - reply_to_message_id: Optional[int] = None, - timeout: int = 20) -> TelegramMessageResponse: + def send_photo( + self, + chat_id: Union[str, int], + file_id: Optional[int] = None, + url: Optional[str] = None, + path: Optional[str] = None, + caption: Optional[str] = None, + parse_mode: Optional[str] = None, + disable_notification: bool = False, + reply_to_message_id: Optional[int] = None, + timeout: int = 20, + ) -> TelegramMessageResponse: """ Send a picture to a chat. @@ -198,28 +214,34 @@ class ChatTelegramPlugin(ChatPlugin): telegram = self.get_telegram() with Resource(file_id=file_id, url=url, path=path) as resource: - msg = telegram.bot.send_photo(chat_id=chat_id, - photo=resource, - caption=caption, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - timeout=timeout, parse_mode=parse_mode) + msg = telegram.bot.send_photo( + chat_id=chat_id, + photo=resource, + caption=caption, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + timeout=timeout, + parse_mode=parse_mode, + ) return self.parse_msg(msg) @action - def send_audio(self, chat_id: Union[str, int], - file_id: Optional[int] = None, - url: Optional[str] = None, - path: Optional[str] = None, - caption: Optional[str] = None, - performer: Optional[str] = None, - title: Optional[str] = None, - duration: Optional[float] = None, - parse_mode: Optional[str] = None, - disable_notification: bool = False, - reply_to_message_id: Optional[int] = None, - timeout: int = 20) -> TelegramMessageResponse: + def send_audio( + self, + chat_id: Union[str, int], + file_id: Optional[int] = None, + url: Optional[str] = None, + path: Optional[str] = None, + caption: Optional[str] = None, + performer: Optional[str] = None, + title: Optional[str] = None, + duration: Optional[float] = None, + parse_mode: Optional[str] = None, + disable_notification: bool = False, + reply_to_message_id: Optional[int] = None, + timeout: int = 20, + ) -> TelegramMessageResponse: """ Send audio to a chat. @@ -250,30 +272,35 @@ class ChatTelegramPlugin(ChatPlugin): telegram = self.get_telegram() with Resource(file_id=file_id, url=url, path=path) as resource: - msg = telegram.bot.send_audio(chat_id=chat_id, - audio=resource, - caption=caption, - disable_notification=disable_notification, - performer=performer, - title=title, - duration=duration, - reply_to_message_id=reply_to_message_id, - timeout=timeout, - parse_mode=parse_mode) + msg = telegram.bot.send_audio( + chat_id=chat_id, + audio=resource, + caption=caption, + disable_notification=disable_notification, + performer=performer, + title=title, + duration=duration, + reply_to_message_id=reply_to_message_id, + timeout=timeout, + parse_mode=parse_mode, + ) return self.parse_msg(msg) @action - def send_document(self, chat_id: Union[str, int], - file_id: Optional[int] = None, - url: Optional[str] = None, - path: Optional[str] = None, - filename: Optional[str] = None, - caption: Optional[str] = None, - parse_mode: Optional[str] = None, - disable_notification: bool = False, - reply_to_message_id: Optional[int] = None, - timeout: int = 20) -> TelegramMessageResponse: + def send_document( + self, + chat_id: Union[str, int], + file_id: Optional[int] = None, + url: Optional[str] = None, + path: Optional[str] = None, + filename: Optional[str] = None, + caption: Optional[str] = None, + parse_mode: Optional[str] = None, + disable_notification: bool = False, + reply_to_message_id: Optional[int] = None, + timeout: int = 20, + ) -> TelegramMessageResponse: """ Send a document to a chat. @@ -302,30 +329,35 @@ class ChatTelegramPlugin(ChatPlugin): telegram = self.get_telegram() with Resource(file_id=file_id, url=url, path=path) as resource: - msg = telegram.bot.send_document(chat_id=chat_id, - document=resource, - filename=filename, - caption=caption, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - timeout=timeout, - parse_mode=parse_mode) + msg = telegram.bot.send_document( + chat_id=chat_id, + document=resource, + filename=filename, + caption=caption, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + timeout=timeout, + parse_mode=parse_mode, + ) return self.parse_msg(msg) @action - def send_video(self, chat_id: Union[str, int], - file_id: Optional[int] = None, - url: Optional[str] = None, - path: Optional[str] = None, - duration: Optional[int] = None, - caption: Optional[str] = None, - width: Optional[int] = None, - height: Optional[int] = None, - parse_mode: Optional[str] = None, - disable_notification: bool = False, - reply_to_message_id: Optional[int] = None, - timeout: int = 20) -> TelegramMessageResponse: + def send_video( + self, + chat_id: Union[str, int], + file_id: Optional[int] = None, + url: Optional[str] = None, + path: Optional[str] = None, + duration: Optional[int] = None, + caption: Optional[str] = None, + width: Optional[int] = None, + height: Optional[int] = None, + parse_mode: Optional[str] = None, + disable_notification: bool = False, + reply_to_message_id: Optional[int] = None, + timeout: int = 20, + ) -> TelegramMessageResponse: """ Send a video to a chat. @@ -356,32 +388,37 @@ class ChatTelegramPlugin(ChatPlugin): telegram = self.get_telegram() with Resource(file_id=file_id, url=url, path=path) as resource: - msg = telegram.bot.send_video(chat_id=chat_id, - video=resource, - duration=duration, - caption=caption, - width=width, - height=height, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - timeout=timeout, - parse_mode=parse_mode) + msg = telegram.bot.send_video( + chat_id=chat_id, + video=resource, + duration=duration, + caption=caption, + width=width, + height=height, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + timeout=timeout, + parse_mode=parse_mode, + ) return self.parse_msg(msg) @action - def send_animation(self, chat_id: Union[str, int], - file_id: Optional[int] = None, - url: Optional[str] = None, - path: Optional[str] = None, - duration: Optional[int] = None, - caption: Optional[str] = None, - width: Optional[int] = None, - height: Optional[int] = None, - parse_mode: Optional[str] = None, - disable_notification: bool = False, - reply_to_message_id: Optional[int] = None, - timeout: int = 20) -> TelegramMessageResponse: + def send_animation( + self, + chat_id: Union[str, int], + file_id: Optional[int] = None, + url: Optional[str] = None, + path: Optional[str] = None, + duration: Optional[int] = None, + caption: Optional[str] = None, + width: Optional[int] = None, + height: Optional[int] = None, + parse_mode: Optional[str] = None, + disable_notification: bool = False, + reply_to_message_id: Optional[int] = None, + timeout: int = 20, + ) -> TelegramMessageResponse: """ Send an animation (GIF or H.264/MPEG-4 AVC video without sound) to a chat. @@ -412,30 +449,35 @@ class ChatTelegramPlugin(ChatPlugin): telegram = self.get_telegram() with Resource(file_id=file_id, url=url, path=path) as resource: - msg = telegram.bot.send_animation(chat_id=chat_id, - animation=resource, - duration=duration, - caption=caption, - width=width, - height=height, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - timeout=timeout, - parse_mode=parse_mode) + msg = telegram.bot.send_animation( + chat_id=chat_id, + animation=resource, + duration=duration, + caption=caption, + width=width, + height=height, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + timeout=timeout, + parse_mode=parse_mode, + ) return self.parse_msg(msg) @action - def send_voice(self, chat_id: Union[str, int], - file_id: Optional[int] = None, - url: Optional[str] = None, - path: Optional[str] = None, - caption: Optional[str] = None, - duration: Optional[float] = None, - parse_mode: Optional[str] = None, - disable_notification: bool = False, - reply_to_message_id: Optional[int] = None, - timeout: int = 20) -> TelegramMessageResponse: + def send_voice( + self, + chat_id: Union[str, int], + file_id: Optional[int] = None, + url: Optional[str] = None, + path: Optional[str] = None, + caption: Optional[str] = None, + duration: Optional[float] = None, + parse_mode: Optional[str] = None, + disable_notification: bool = False, + reply_to_message_id: Optional[int] = None, + timeout: int = 20, + ) -> TelegramMessageResponse: """ Send audio to a chat as a voice file. For this to work, your audio must be in an .ogg file encoded with OPUS (other formats may be sent as Audio or Document). @@ -465,25 +507,31 @@ class ChatTelegramPlugin(ChatPlugin): telegram = self.get_telegram() with Resource(file_id=file_id, url=url, path=path) as resource: - msg = telegram.bot.send_voice(chat_id=chat_id, - voice=resource, - caption=caption, - disable_notification=disable_notification, - duration=duration, - reply_to_message_id=reply_to_message_id, - timeout=timeout, parse_mode=parse_mode) + msg = telegram.bot.send_voice( + chat_id=chat_id, + voice=resource, + caption=caption, + disable_notification=disable_notification, + duration=duration, + reply_to_message_id=reply_to_message_id, + timeout=timeout, + parse_mode=parse_mode, + ) return self.parse_msg(msg) @action - def send_video_note(self, chat_id: Union[str, int], - file_id: Optional[int] = None, - url: Optional[str] = None, - path: Optional[str] = None, - duration: Optional[int] = None, - disable_notification: bool = False, - reply_to_message_id: Optional[int] = None, - timeout: int = 20) -> TelegramMessageResponse: + def send_video_note( + self, + chat_id: Union[str, int], + file_id: Optional[int] = None, + url: Optional[str] = None, + path: Optional[str] = None, + duration: Optional[int] = None, + disable_notification: bool = False, + reply_to_message_id: Optional[int] = None, + timeout: int = 20, + ) -> TelegramMessageResponse: """ Send a video note to a chat. As of v.4.0, Telegram clients support rounded square mp4 videos of up to 1 minute long. @@ -511,22 +559,27 @@ class ChatTelegramPlugin(ChatPlugin): telegram = self.get_telegram() with Resource(file_id=file_id, url=url, path=path) as resource: - msg = telegram.bot.send_video_note(chat_id=chat_id, - video=resource, - duration=duration, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - timeout=timeout) + msg = telegram.bot.send_video_note( + chat_id=chat_id, + video=resource, + duration=duration, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + timeout=timeout, + ) return self.parse_msg(msg) @action - def send_location(self, chat_id: Union[str, int], - latitude: float, - longitude: float, - disable_notification: bool = False, - reply_to_message_id: Optional[int] = None, - timeout: int = 20) -> TelegramMessageResponse: + def send_location( + self, + chat_id: Union[str, int], + latitude: float, + longitude: float, + disable_notification: bool = False, + reply_to_message_id: Optional[int] = None, + timeout: int = 20, + ) -> TelegramMessageResponse: """ Send a location to a chat. @@ -543,26 +596,31 @@ class ChatTelegramPlugin(ChatPlugin): """ telegram = self.get_telegram() - msg = telegram.bot.send_location(chat_id=chat_id, - latitude=latitude, - longitude=longitude, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - timeout=timeout) + msg = telegram.bot.send_location( + chat_id=chat_id, + latitude=latitude, + longitude=longitude, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + timeout=timeout, + ) return self.parse_msg(msg) @action - def send_venue(self, chat_id: Union[str, int], - latitude: float, - longitude: float, - title: str, - address: str, - foursquare_id: Optional[str] = None, - foursquare_type: Optional[str] = None, - disable_notification: bool = False, - reply_to_message_id: Optional[int] = None, - timeout: int = 20) -> TelegramMessageResponse: + def send_venue( + self, + chat_id: Union[str, int], + latitude: float, + longitude: float, + title: str, + address: str, + foursquare_id: Optional[str] = None, + foursquare_type: Optional[str] = None, + disable_notification: bool = False, + reply_to_message_id: Optional[int] = None, + timeout: int = 20, + ) -> TelegramMessageResponse: """ Send the address of a venue to a chat. @@ -583,28 +641,33 @@ class ChatTelegramPlugin(ChatPlugin): """ telegram = self.get_telegram() - msg = telegram.bot.send_venue(chat_id=chat_id, - latitude=latitude, - longitude=longitude, - title=title, - address=address, - foursquare_id=foursquare_id, - foursquare_type=foursquare_type, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - timeout=timeout) + msg = telegram.bot.send_venue( + chat_id=chat_id, + latitude=latitude, + longitude=longitude, + title=title, + address=address, + foursquare_id=foursquare_id, + foursquare_type=foursquare_type, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + timeout=timeout, + ) return self.parse_msg(msg) @action - def send_contact(self, chat_id: Union[str, int], - phone_number: str, - first_name: str, - last_name: Optional[str] = None, - vcard: Optional[str] = None, - disable_notification: bool = False, - reply_to_message_id: Optional[int] = None, - timeout: int = 20) -> TelegramMessageResponse: + def send_contact( + self, + chat_id: Union[str, int], + phone_number: str, + first_name: str, + last_name: Optional[str] = None, + vcard: Optional[str] = None, + disable_notification: bool = False, + reply_to_message_id: Optional[int] = None, + timeout: int = 20, + ) -> TelegramMessageResponse: """ Send a contact to a chat. @@ -623,14 +686,16 @@ class ChatTelegramPlugin(ChatPlugin): """ telegram = self.get_telegram() - msg = telegram.bot.send_contact(chat_id=chat_id, - phone_number=phone_number, - first_name=first_name, - last_name=last_name, - vcard=vcard, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - timeout=timeout) + msg = telegram.bot.send_contact( + chat_id=chat_id, + phone_number=phone_number, + first_name=first_name, + last_name=last_name, + vcard=vcard, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + timeout=timeout, + ) return self.parse_msg(msg) @@ -645,10 +710,14 @@ class ChatTelegramPlugin(ChatPlugin): telegram = self.get_telegram() file = telegram.bot.get_file(file_id, timeout=timeout) - return TelegramFileResponse(file_id=file.file_id, file_path=file.file_path, file_size=file.file_size) + return TelegramFileResponse( + file_id=file.file_id, file_path=file.file_path, file_size=file.file_size + ) @action - def get_chat(self, chat_id: Union[int, str], timeout: int = 20) -> TelegramChatResponse: + def get_chat( + self, chat_id: Union[int, str], timeout: int = 20 + ) -> TelegramChatResponse: """ Get the info about a Telegram chat. @@ -658,18 +727,22 @@ class ChatTelegramPlugin(ChatPlugin): telegram = self.get_telegram() chat = telegram.bot.get_chat(chat_id, timeout=timeout) - return TelegramChatResponse(chat_id=chat.id, - link=chat.link, - username=chat.username, - invite_link=chat.invite_link, - title=chat.title, - description=chat.description, - type=chat.type, - first_name=chat.first_name, - last_name=chat.last_name) + return TelegramChatResponse( + chat_id=chat.id, + link=chat.link, + username=chat.username, + invite_link=chat.invite_link, + title=chat.title, + description=chat.description, + type=chat.type, + first_name=chat.first_name, + last_name=chat.last_name, + ) @action - def get_chat_user(self, chat_id: Union[int, str], user_id: int, timeout: int = 20) -> TelegramUserResponse: + def get_chat_user( + self, chat_id: Union[int, str], user_id: int, timeout: int = 20 + ) -> TelegramUserResponse: """ Get the info about a user connected to a chat. @@ -680,16 +753,20 @@ class ChatTelegramPlugin(ChatPlugin): telegram = self.get_telegram() user = telegram.bot.get_chat_member(chat_id, user_id, timeout=timeout) - return TelegramUserResponse(user_id=user.user.id, - link=user.user.link, - username=user.user.username, - first_name=user.user.first_name, - last_name=user.user.last_name, - is_bot=user.user.is_bot, - language_code=user.user.language_code) + return TelegramUserResponse( + user_id=user.user.id, + link=user.user.link, + username=user.user.username, + first_name=user.user.first_name, + last_name=user.user.last_name, + is_bot=user.user.is_bot, + language_code=user.user.language_code, + ) @action - def get_chat_administrators(self, chat_id: Union[int, str], timeout: int = 20) -> TelegramUsersResponse: + def get_chat_administrators( + self, chat_id: Union[int, str], timeout: int = 20 + ) -> TelegramUsersResponse: """ Get the list of the administrators of a chat. @@ -699,20 +776,25 @@ class ChatTelegramPlugin(ChatPlugin): telegram = self.get_telegram() admins = telegram.bot.get_chat_administrators(chat_id, timeout=timeout) - return TelegramUsersResponse([ - TelegramUserResponse( - user_id=user.user.id, - link=user.user.link, - username=user.user.username, - first_name=user.user.first_name, - last_name=user.user.last_name, - is_bot=user.user.is_bot, - language_code=user.user.language_code, - ) for user in admins - ]) + return TelegramUsersResponse( + [ + TelegramUserResponse( + user_id=user.user.id, + link=user.user.link, + username=user.user.username, + first_name=user.user.first_name, + last_name=user.user.last_name, + is_bot=user.user.is_bot, + language_code=user.user.language_code, + ) + for user in admins + ] + ) @action - def get_chat_members_count(self, chat_id: Union[int, str], timeout: int = 20) -> int: + def get_chat_members_count( + self, chat_id: Union[int, str], timeout: int = 20 + ) -> int: """ Get the number of users in a chat. @@ -723,10 +805,13 @@ class ChatTelegramPlugin(ChatPlugin): return telegram.bot.get_chat_members_count(chat_id, timeout=timeout) @action - def kick_chat_member(self, chat_id: Union[str, int], - user_id: int, - until_date: Optional[datetime.datetime] = None, - timeout: int = 20): + def kick_chat_member( + self, + chat_id: Union[str, int], + user_id: int, + until_date: Optional[datetime.datetime] = None, + timeout: int = 20, + ): """ Kick a user from a chat. @@ -742,15 +827,13 @@ class ChatTelegramPlugin(ChatPlugin): telegram = self.get_telegram() telegram.bot.kick_chat_member( - chat_id=chat_id, - user_id=user_id, - until_date=until_date, - timeout=timeout) + chat_id=chat_id, user_id=user_id, until_date=until_date, timeout=timeout + ) @action - def unban_chat_member(self, chat_id: Union[str, int], - user_id: int, - timeout: int = 20): + def unban_chat_member( + self, chat_id: Union[str, int], user_id: int, timeout: int = 20 + ): """ Lift the ban from a chat member. @@ -765,22 +848,24 @@ class ChatTelegramPlugin(ChatPlugin): telegram = self.get_telegram() telegram.bot.unban_chat_member( - chat_id=chat_id, - user_id=user_id, - timeout=timeout) + chat_id=chat_id, user_id=user_id, timeout=timeout + ) @action - def promote_chat_member(self, chat_id: Union[str, int], - user_id: int, - can_change_info: Optional[bool] = None, - can_post_messages: Optional[bool] = None, - can_edit_messages: Optional[bool] = None, - can_delete_messages: Optional[bool] = None, - can_invite_users: Optional[bool] = None, - can_restrict_members: Optional[bool] = None, - can_promote_members: Optional[bool] = None, - can_pin_messages: Optional[bool] = None, - timeout: int = 20): + def promote_chat_member( + self, + chat_id: Union[str, int], + user_id: int, + can_change_info: Optional[bool] = None, + can_post_messages: Optional[bool] = None, + can_edit_messages: Optional[bool] = None, + can_delete_messages: Optional[bool] = None, + can_invite_users: Optional[bool] = None, + can_restrict_members: Optional[bool] = None, + can_promote_members: Optional[bool] = None, + can_pin_messages: Optional[bool] = None, + timeout: int = 20, + ): """ Promote or demote a member. @@ -813,12 +898,11 @@ class ChatTelegramPlugin(ChatPlugin): can_restrict_members=can_restrict_members, can_promote_members=can_promote_members, can_pin_messages=can_pin_messages, - timeout=timeout) + timeout=timeout, + ) @action - def set_chat_title(self, chat_id: Union[str, int], - title: str, - timeout: int = 20): + def set_chat_title(self, chat_id: Union[str, int], title: str, timeout: int = 20): """ Set the title of a channel/group. @@ -832,15 +916,12 @@ class ChatTelegramPlugin(ChatPlugin): """ telegram = self.get_telegram() - telegram.bot.set_chat_title( - chat_id=chat_id, - description=title, - timeout=timeout) + telegram.bot.set_chat_title(chat_id=chat_id, description=title, timeout=timeout) @action - def set_chat_description(self, chat_id: Union[str, int], - description: str, - timeout: int = 20): + def set_chat_description( + self, chat_id: Union[str, int], description: str, timeout: int = 20 + ): """ Set the description of a channel/group. @@ -855,14 +936,11 @@ class ChatTelegramPlugin(ChatPlugin): telegram = self.get_telegram() telegram.bot.set_chat_description( - chat_id=chat_id, - description=description, - timeout=timeout) + chat_id=chat_id, description=description, timeout=timeout + ) @action - def set_chat_photo(self, chat_id: Union[str, int], - path: str, - timeout: int = 20): + def set_chat_photo(self, chat_id: Union[str, int], path: str, timeout: int = 20): """ Set the photo of a channel/group. @@ -879,13 +957,11 @@ class ChatTelegramPlugin(ChatPlugin): with Resource(path=path) as resource: telegram.bot.set_chat_photo( - chat_id=chat_id, - photo=resource, - timeout=timeout) + chat_id=chat_id, photo=resource, timeout=timeout + ) @action - def delete_chat_photo(self, chat_id: Union[str, int], - timeout: int = 20): + def delete_chat_photo(self, chat_id: Union[str, int], timeout: int = 20): """ Delete the photo of a channel/group. @@ -898,15 +974,16 @@ class ChatTelegramPlugin(ChatPlugin): """ telegram = self.get_telegram() - telegram.bot.delete_chat_photo( - chat_id=chat_id, - timeout=timeout) + telegram.bot.delete_chat_photo(chat_id=chat_id, timeout=timeout) @action - def pin_chat_message(self, chat_id: Union[str, int], - message_id: int, - disable_notification: Optional[bool] = None, - timeout: int = 20): + def pin_chat_message( + self, + chat_id: Union[str, int], + message_id: int, + disable_notification: Optional[bool] = None, + timeout: int = 20, + ): """ Pin a message in a chat. @@ -925,11 +1002,11 @@ class ChatTelegramPlugin(ChatPlugin): chat_id=chat_id, message_id=message_id, disable_notification=disable_notification, - timeout=timeout) + timeout=timeout, + ) @action - def unpin_chat_message(self, chat_id: Union[str, int], - timeout: int = 20): + def unpin_chat_message(self, chat_id: Union[str, int], timeout: int = 20): """ Unpin the message of a chat. @@ -942,13 +1019,10 @@ class ChatTelegramPlugin(ChatPlugin): """ telegram = self.get_telegram() - telegram.bot.unpin_chat_message( - chat_id=chat_id, - timeout=timeout) + telegram.bot.unpin_chat_message(chat_id=chat_id, timeout=timeout) @action - def leave_chat(self, chat_id: Union[str, int], - timeout: int = 20): + def leave_chat(self, chat_id: Union[str, int], timeout: int = 20): """ Leave a chat. @@ -961,9 +1035,7 @@ class ChatTelegramPlugin(ChatPlugin): """ telegram = self.get_telegram() - telegram.bot.leave_chat( - chat_id=chat_id, - timeout=timeout) + telegram.bot.leave_chat(chat_id=chat_id, timeout=timeout) # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/clipboard/__init__.py b/platypush/plugins/clipboard/__init__.py index c9cef49d5..c3b32ddbb 100644 --- a/platypush/plugins/clipboard/__init__.py +++ b/platypush/plugins/clipboard/__init__.py @@ -10,15 +10,6 @@ class ClipboardPlugin(RunnablePlugin): """ Plugin to programmatically copy strings to your system clipboard, monitor and get the current clipboard content. - - Requires: - - - **pyclip** (``pip install pyclip``) - - Triggers: - - - :class:`platypush.message.event.clipboard.ClipboardEvent` on clipboard update. - """ def __init__(self, *args, **kwargs): diff --git a/platypush/plugins/dbus/__init__.py b/platypush/plugins/dbus/__init__.py index 8f588a922..86bd6301d 100644 --- a/platypush/plugins/dbus/__init__.py +++ b/platypush/plugins/dbus/__init__.py @@ -2,7 +2,7 @@ import enum import json from typing import Set, Dict, Optional, Iterable, Callable, Union -from gi.repository import GLib # type: ignore +from gi.repository import GLib # type: ignore from pydbus import SessionBus, SystemBus from pydbus.bus import Bus from defusedxml import ElementTree @@ -27,7 +27,7 @@ class BusType(enum.Enum): SESSION = 'session' -class DBusService(): +class DBusService: """ @@ -94,21 +94,14 @@ class DbusPlugin(RunnablePlugin): * It can be used to execute methods exponsed by D-Bus objects through the :meth:`.execute` method. - Requires: - - * **pydbus** (``pip install pydbus``) - * **defusedxml** (``pip install defusedxml``) - - Triggers: - - * :class:`platypush.message.event.dbus.DbusSignalEvent` when a signal is received. - """ def __init__( - self, signals: Optional[Iterable[dict]] = None, - service_name: Optional[str] = _default_service_name, - service_path: Optional[str] = _default_service_path, **kwargs + self, + signals: Optional[Iterable[dict]] = None, + service_name: Optional[str] = _default_service_name, + service_path: Optional[str] = _default_service_path, + **kwargs, ): """ :param signals: Specify this if you want to subscribe to specific DBus @@ -138,8 +131,7 @@ class DbusPlugin(RunnablePlugin): self._loop = None self._signals = DbusSignalSchema().load(signals or [], many=True) self._signal_handlers = [ - self._get_signal_handler(**signal) - for signal in self._signals + self._get_signal_handler(**signal) for signal in self._signals ] self.service_name = service_name @@ -150,8 +142,12 @@ class DbusPlugin(RunnablePlugin): def handler(sender, path, interface, signal, params): get_bus().post( DbusSignalEvent( - bus=bus, signal=signal, path=path, - interface=interface, sender=sender, params=params + bus=bus, + signal=signal, + path=path, + interface=interface, + sender=sender, + params=params, ) ) @@ -201,7 +197,9 @@ class DbusPlugin(RunnablePlugin): def _get_bus_names(bus: Bus) -> Set[str]: return {str(name) for name in bus.dbus.ListNames() if not name.startswith(':')} - def path_names(self, bus: Bus, service: str, object_path='/', paths=None, service_dict=None): + def path_names( + self, bus: Bus, service: str, object_path='/', paths=None, service_dict=None + ): if paths is None: paths = {} if service_dict is None: @@ -212,10 +210,14 @@ class DbusPlugin(RunnablePlugin): obj = bus.get(service, object_path) interface = obj['org.freedesktop.DBus.Introspectable'] except GLib.GError as e: - self.logger.warning(f'Could not inspect D-Bus object {service}, path={object_path}: {e}') + self.logger.warning( + f'Could not inspect D-Bus object {service}, path={object_path}: {e}' + ) return {} except KeyError as e: - self.logger.warning(f'Could not get interfaces on the D-Bus object {service}, path={object_path}: {e}') + self.logger.warning( + f'Could not get interfaces on the D-Bus object {service}, path={object_path}: {e}' + ) return {} xml_string = interface.Introspect() @@ -226,7 +228,9 @@ class DbusPlugin(RunnablePlugin): if object_path == '/': object_path = '' new_path = '/'.join((object_path, child.attrib['name'])) - self.path_names(bus, service, new_path, paths, service_dict=service_dict) + self.path_names( + bus, service, new_path, paths, service_dict=service_dict + ) else: if not object_path: object_path = '/' @@ -253,8 +257,9 @@ class DbusPlugin(RunnablePlugin): return service_dict @action - def query(self, service: Optional[str] = None, bus=tuple(t.value for t in BusType)) \ - -> Dict[str, dict]: + def query( + self, service: Optional[str] = None, bus=tuple(t.value for t in BusType) + ) -> Dict[str, dict]: """ Query DBus for a specific service or for the full list of services. @@ -427,13 +432,13 @@ class DbusPlugin(RunnablePlugin): @action def execute( - self, - service: str, - interface: str, - method_name: str, - bus: str = BusType.SESSION.value, - path: str = '/', - args: Optional[list] = None + self, + service: str, + interface: str, + method_name: str, + bus: str = BusType.SESSION.value, + path: str = '/', + args: Optional[list] = None, ): """ Execute a method exposed on DBus. diff --git a/platypush/plugins/dropbox/__init__.py b/platypush/plugins/dropbox/__init__.py index 615e36f53..2cf1a2285 100644 --- a/platypush/plugins/dropbox/__init__.py +++ b/platypush/plugins/dropbox/__init__.py @@ -7,10 +7,6 @@ from platypush.plugins import Plugin, action class DropboxPlugin(Plugin): """ Plugin to manage a Dropbox account and its files and folders. - - Requires: - - * **dropbox** (``pip install dropbox``) """ def __init__(self, access_token, **kwargs): @@ -101,15 +97,26 @@ class DropboxPlugin(Plugin): for item in files: entry = { attr: getattr(item, attr) - for attr in ['id', 'name', 'path_display', 'path_lower', - 'parent_shared_folder_id', 'property_groups'] + for attr in [ + 'id', + 'name', + 'path_display', + 'path_lower', + 'parent_shared_folder_id', + 'property_groups', + ] } if item.sharing_info: entry['sharing_info'] = { attr: getattr(item.sharing_info, attr) - for attr in ['no_access', 'parent_shared_folder_id', 'read_only', - 'shared_folder_id', 'traverse_only'] + for attr in [ + 'no_access', + 'parent_shared_folder_id', + 'read_only', + 'shared_folder_id', + 'traverse_only', + ] } else: entry['sharing_info'] = {} @@ -118,7 +125,13 @@ class DropboxPlugin(Plugin): entry['client_modified'] = item.client_modified.isoformat() entry['server_modified'] = item.server_modified.isoformat() - for attr in ['content_hash', 'has_explicit_shared_members', 'is_downloadable', 'rev', 'size']: + for attr in [ + 'content_hash', + 'has_explicit_shared_members', + 'is_downloadable', + 'rev', + 'size', + ]: if hasattr(item, attr): entry[attr] = getattr(item, attr) @@ -127,8 +140,14 @@ class DropboxPlugin(Plugin): return entries @action - def copy(self, from_path: str, to_path: str, allow_shared_folder=True, autorename=False, - allow_ownership_transfer=False): + def copy( + self, + from_path: str, + to_path: str, + allow_shared_folder=True, + autorename=False, + allow_ownership_transfer=False, + ): """ Copy a file or folder to a different location in the user's Dropbox. If the source path is a folder all its contents will be copied. @@ -148,12 +167,23 @@ class DropboxPlugin(Plugin): """ dbx = self._get_instance() - dbx.files_copy_v2(from_path, to_path, allow_shared_folder=allow_shared_folder, - autorename=autorename, allow_ownership_transfer=allow_ownership_transfer) + dbx.files_copy_v2( + from_path, + to_path, + allow_shared_folder=allow_shared_folder, + autorename=autorename, + allow_ownership_transfer=allow_ownership_transfer, + ) @action - def move(self, from_path: str, to_path: str, allow_shared_folder=True, autorename=False, - allow_ownership_transfer=False): + def move( + self, + from_path: str, + to_path: str, + allow_shared_folder=True, + autorename=False, + allow_ownership_transfer=False, + ): """ Move a file or folder to a different location in the user's Dropbox. If the source path is a folder all its contents will be moved. @@ -173,8 +203,13 @@ class DropboxPlugin(Plugin): """ dbx = self._get_instance() - dbx.files_move_v2(from_path, to_path, allow_shared_folder=allow_shared_folder, - autorename=autorename, allow_ownership_transfer=allow_ownership_transfer) + dbx.files_move_v2( + from_path, + to_path, + allow_shared_folder=allow_shared_folder, + autorename=autorename, + allow_ownership_transfer=allow_ownership_transfer, + ) @action def delete(self, path: str): @@ -251,7 +286,9 @@ class DropboxPlugin(Plugin): if download_path: if os.path.isdir(download_path): - download_path = os.path.join(download_path, result.metadata.name + '.zip') + download_path = os.path.join( + download_path, result.metadata.name + '.zip' + ) with open(download_path, 'wb') as f: f.write(response.content) @@ -350,8 +387,13 @@ class DropboxPlugin(Plugin): from dropbox.files import SearchMode dbx = self._get_instance() - response = dbx.files_search(query=query, path=path, start=start, max_results=max_results, - mode=SearchMode.filename_and_content if content else SearchMode.filename) + response = dbx.files_search( + query=query, + path=path, + start=start, + max_results=max_results, + mode=SearchMode.filename_and_content if content else SearchMode.filename, + ) results = [self._parse_metadata(match.metadata) for match in response.matches] @@ -397,8 +439,12 @@ class DropboxPlugin(Plugin): else: raise SyntaxError('Please specify either a file or text to be uploaded') - metadata = dbx.files_upload(content, path, autorename=autorename, - mode=WriteMode.overwrite if overwrite else WriteMode.add) + metadata = dbx.files_upload( + content, + path, + autorename=autorename, + mode=WriteMode.overwrite if overwrite else WriteMode.add, + ) return self._parse_metadata(metadata) diff --git a/platypush/plugins/ffmpeg/__init__.py b/platypush/plugins/ffmpeg/__init__.py index 2942eadc4..95473e264 100644 --- a/platypush/plugins/ffmpeg/__init__.py +++ b/platypush/plugins/ffmpeg/__init__.py @@ -9,15 +9,11 @@ from platypush.plugins import Plugin, action class FfmpegPlugin(Plugin): """ Generic FFmpeg plugin to interact with media files and devices. - - Requires: - - * **ffmpeg-python** (``pip install ffmpeg-python``) - * The **ffmpeg** package installed on the system. - """ - def __init__(self, ffmpeg_cmd: str = 'ffmpeg', ffprobe_cmd: str = 'ffprobe', **kwargs): + def __init__( + self, ffmpeg_cmd: str = 'ffmpeg', ffprobe_cmd: str = 'ffprobe', **kwargs + ): super().__init__(**kwargs) self.ffmpeg_cmd = ffmpeg_cmd self.ffprobe_cmd = ffprobe_cmd @@ -102,14 +98,19 @@ class FfmpegPlugin(Plugin): """ # noinspection PyPackageRequirements import ffmpeg + filename = os.path.abspath(os.path.expanduser(filename)) info = ffmpeg.probe(filename, cmd=self.ffprobe_cmd, **kwargs) return info @staticmethod - def _poll_thread(proc: subprocess.Popen, packet_size: int, on_packet: Callable[[bytes], None], - on_open: Optional[Callable[[], None]] = None, - on_close: Optional[Callable[[], None]] = None): + def _poll_thread( + proc: subprocess.Popen, + packet_size: int, + on_packet: Callable[[bytes], None], + on_open: Optional[Callable[[], None]] = None, + on_close: Optional[Callable[[], None]] = None, + ): try: if on_open: on_open() @@ -122,25 +123,49 @@ class FfmpegPlugin(Plugin): on_close() @action - def start(self, pipeline: List[dict], pipe_stdin: bool = False, pipe_stdout: bool = False, - pipe_stderr: bool = False, quiet: bool = False, overwrite_output: bool = False, - on_packet: Callable[[bytes], None] = None, packet_size: int = 4096): + def start( + self, + pipeline: List[dict], + pipe_stdin: bool = False, + pipe_stdout: bool = False, + pipe_stderr: bool = False, + quiet: bool = False, + overwrite_output: bool = False, + on_packet: Callable[[bytes], None] = None, + packet_size: int = 4096, + ): # noinspection PyPackageRequirements import ffmpeg + stream = ffmpeg for step in pipeline: args = step.pop('args') if 'args' in step else [] stream = getattr(stream, step.pop('method'))(*args, **step) - self.logger.info('Executing {cmd} {args}'.format(cmd=self.ffmpeg_cmd, args=stream.get_args())) - proc = stream.run_async(cmd=self.ffmpeg_cmd, pipe_stdin=pipe_stdin, pipe_stdout=pipe_stdout, - pipe_stderr=pipe_stderr, quiet=quiet, overwrite_output=overwrite_output) + self.logger.info( + 'Executing {cmd} {args}'.format(cmd=self.ffmpeg_cmd, args=stream.get_args()) + ) + proc = stream.run_async( + cmd=self.ffmpeg_cmd, + pipe_stdin=pipe_stdin, + pipe_stdout=pipe_stdout, + pipe_stderr=pipe_stderr, + quiet=quiet, + overwrite_output=overwrite_output, + ) if on_packet: with self._thread_lock: - self._threads[self._next_thread_id] = threading.Thread(target=self._poll_thread, kwargs=dict( - proc=proc, on_packet=on_packet, packet_size=packet_size)) + self._threads[self._next_thread_id] = threading.Thread( + target=self._poll_thread, + kwargs={ + 'proc': proc, + 'on_packet': on_packet, + 'packet_size': packet_size, + }, + ) + self._threads[self._next_thread_id].start() self._next_thread_id += 1 diff --git a/platypush/plugins/google/__init__.py b/platypush/plugins/google/__init__.py index 310efcb8d..39cbb1f18 100644 --- a/platypush/plugins/google/__init__.py +++ b/platypush/plugins/google/__init__.py @@ -22,11 +22,6 @@ class GooglePlugin(Plugin): python -m platypush.plugins.google.credentials \ 'https://www.googleapis.com/auth/gmail.compose' ~/client_secret.json - Requires: - - * **google-api-python-client** (``pip install google-api-python-client``) - * **oauth2client** (``pip install oauth2client``) - """ def __init__(self, scopes=None, **kwargs): diff --git a/platypush/plugins/google/calendar/__init__.py b/platypush/plugins/google/calendar/__init__.py index 5a3ac74fe..0a0032d84 100644 --- a/platypush/plugins/google/calendar/__init__.py +++ b/platypush/plugins/google/calendar/__init__.py @@ -7,13 +7,7 @@ from platypush.plugins.calendar import CalendarInterface class GoogleCalendarPlugin(GooglePlugin, CalendarInterface): """ - Google calendar plugin. - - Requires: - - * **google-api-python-client** (``pip install google-api-python-client``) - * **oauth2client** (``pip install oauth2client``) - + Google Calendar plugin. """ scopes = ['https://www.googleapis.com/auth/calendar.readonly'] diff --git a/platypush/plugins/google/drive/__init__.py b/platypush/plugins/google/drive/__init__.py index 959e4830e..ddee09073 100644 --- a/platypush/plugins/google/drive/__init__.py +++ b/platypush/plugins/google/drive/__init__.py @@ -10,17 +10,13 @@ from platypush.message.response.google.drive import GoogleDriveFile class GoogleDrivePlugin(GooglePlugin): """ Google Drive plugin. - - Requires: - - * **google-api-python-client** (``pip install google-api-python-client``) - * **oauth2client** (``pip install oauth2client``) - """ - scopes = ['https://www.googleapis.com/auth/drive', - 'https://www.googleapis.com/auth/drive.appfolder', - 'https://www.googleapis.com/auth/drive.photos.readonly'] + scopes = [ + 'https://www.googleapis.com/auth/drive', + 'https://www.googleapis.com/auth/drive.appfolder', + 'https://www.googleapis.com/auth/drive.photos.readonly', + ] def __init__(self, *args, **kwargs): super().__init__(scopes=self.scopes, *args, **kwargs) @@ -30,13 +26,15 @@ class GoogleDrivePlugin(GooglePlugin): # noinspection PyShadowingBuiltins @action - def files(self, - filter: Optional[str] = None, - folder_id: Optional[str] = None, - limit: Optional[int] = 100, - drive_id: Optional[str] = None, - spaces: Optional[Union[str, List[str]]] = None, - order_by: Optional[Union[str, List[str]]] = None) -> Union[GoogleDriveFile, List[GoogleDriveFile]]: + def files( + self, + filter: Optional[str] = None, + folder_id: Optional[str] = None, + limit: Optional[int] = 100, + drive_id: Optional[str] = None, + spaces: Optional[Union[str, List[str]]] = None, + order_by: Optional[Union[str, List[str]]] = None, + ) -> Union[GoogleDriveFile, List[GoogleDriveFile]]: """ Get the list of files. @@ -90,25 +88,32 @@ class GoogleDrivePlugin(GooglePlugin): filter += "'{}' in parents".format(folder_id) while True: - results = service.files().list( - q=filter, - driveId=drive_id, - pageSize=limit, - orderBy=order_by, - fields="nextPageToken, files(id, name, kind, mimeType)", - pageToken=page_token, - spaces=spaces, - ).execute() + results = ( + service.files() + .list( + q=filter, + driveId=drive_id, + pageSize=limit, + orderBy=order_by, + fields="nextPageToken, files(id, name, kind, mimeType)", + pageToken=page_token, + spaces=spaces, + ) + .execute() + ) page_token = results.get('nextPageToken') - files.extend([ - GoogleDriveFile( - id=f.get('id'), - name=f.get('name'), - type=f.get('kind').split('#')[1], - mime_type=f.get('mimeType'), - ) for f in results.get('files', []) - ]) + files.extend( + [ + GoogleDriveFile( + id=f.get('id'), + name=f.get('name'), + type=f.get('kind').split('#')[1], + mime_type=f.get('mimeType'), + ) + for f in results.get('files', []) + ] + ) if not page_token or (limit and len(files) >= limit): break @@ -131,14 +136,16 @@ class GoogleDrivePlugin(GooglePlugin): ) @action - def upload(self, - path: str, - mime_type: Optional[str] = None, - name: Optional[str] = None, - description: Optional[str] = None, - parents: Optional[List[str]] = None, - starred: bool = False, - target_mime_type: Optional[str] = None) -> GoogleDriveFile: + def upload( + self, + path: str, + mime_type: Optional[str] = None, + name: Optional[str] = None, + description: Optional[str] = None, + parents: Optional[List[str]] = None, + starred: bool = False, + target_mime_type: Optional[str] = None, + ) -> GoogleDriveFile: """ Upload a file to Google Drive. @@ -171,11 +178,11 @@ class GoogleDrivePlugin(GooglePlugin): media = MediaFileUpload(path, mimetype=mime_type) service = self.get_service() - file = service.files().create( - body=metadata, - media_body=media, - fields='*' - ).execute() + file = ( + service.files() + .create(body=metadata, media_body=media, fields='*') + .execute() + ) return GoogleDriveFile( type=file.get('kind').split('#')[1], @@ -216,12 +223,14 @@ class GoogleDrivePlugin(GooglePlugin): return path @action - def create(self, - name: str, - description: Optional[str] = None, - mime_type: Optional[str] = None, - parents: Optional[List[str]] = None, - starred: bool = False) -> GoogleDriveFile: + def create( + self, + name: str, + description: Optional[str] = None, + mime_type: Optional[str] = None, + parents: Optional[List[str]] = None, + starred: bool = False, + ) -> GoogleDriveFile: """ Create a file. @@ -242,10 +251,7 @@ class GoogleDrivePlugin(GooglePlugin): metadata['mimeType'] = mime_type service = self.get_service() - file = service.files().create( - body=metadata, - fields='*' - ).execute() + file = service.files().create(body=metadata, fields='*').execute() return GoogleDriveFile( type=file.get('kind').split('#')[1], @@ -255,15 +261,17 @@ class GoogleDrivePlugin(GooglePlugin): ) @action - def update(self, - file_id: str, - name: Optional[str] = None, - description: Optional[str] = None, - add_parents: Optional[List[str]] = None, - remove_parents: Optional[List[str]] = None, - mime_type: Optional[str] = None, - starred: bool = None, - trashed: bool = None) -> GoogleDriveFile: + def update( + self, + file_id: str, + name: Optional[str] = None, + description: Optional[str] = None, + add_parents: Optional[List[str]] = None, + remove_parents: Optional[List[str]] = None, + mime_type: Optional[str] = None, + starred: bool = None, + trashed: bool = None, + ) -> GoogleDriveFile: """ Update the metadata or the content of a file. @@ -293,11 +301,9 @@ class GoogleDrivePlugin(GooglePlugin): metadata['trashed'] = trashed service = self.get_service() - file = service.files().update( - fileId=file_id, - body=metadata, - fields='*' - ).execute() + file = ( + service.files().update(fileId=file_id, body=metadata, fields='*').execute() + ) return GoogleDriveFile( type=file.get('kind').split('#')[1], diff --git a/platypush/plugins/google/fit/__init__.py b/platypush/plugins/google/fit/__init__.py index ec66ffcc8..f5043a316 100644 --- a/platypush/plugins/google/fit/__init__.py +++ b/platypush/plugins/google/fit/__init__.py @@ -5,20 +5,16 @@ from platypush.plugins.google import GooglePlugin class GoogleFitPlugin(GooglePlugin): """ Google Fit plugin. - - Requires: - - * **google-api-python-client** (``pip install google-api-python-client``) - * **oauth2client** (``pip install oauth2client``) - """ - scopes = ['https://www.googleapis.com/auth/fitness.activity.read', - 'https://www.googleapis.com/auth/fitness.body.read', - 'https://www.googleapis.com/auth/fitness.body_temperature.read', - 'https://www.googleapis.com/auth/fitness.heart_rate.read', - 'https://www.googleapis.com/auth/fitness.sleep.read', - 'https://www.googleapis.com/auth/fitness.location.read'] + scopes = [ + 'https://www.googleapis.com/auth/fitness.activity.read', + 'https://www.googleapis.com/auth/fitness.body.read', + 'https://www.googleapis.com/auth/fitness.body_temperature.read', + 'https://www.googleapis.com/auth/fitness.heart_rate.read', + 'https://www.googleapis.com/auth/fitness.sleep.read', + 'https://www.googleapis.com/auth/fitness.location.read', + ] def __init__(self, user_id='me', *args, **kwargs): """ @@ -30,7 +26,6 @@ class GoogleFitPlugin(GooglePlugin): super().__init__(scopes=self.scopes, *args, **kwargs) self.user_id = user_id - @action def get_data_sources(self, user_id=None): """ @@ -38,8 +33,9 @@ class GoogleFitPlugin(GooglePlugin): """ service = self.get_service(service='fitness', version='v1') - sources = service.users().dataSources(). \ - list(userId=user_id or self.user_id).execute() + sources = ( + service.users().dataSources().list(userId=user_id or self.user_id).execute() + ) return sources['dataSource'] @@ -64,11 +60,19 @@ class GoogleFitPlugin(GooglePlugin): kwargs['limit'] = limit data_points = [] - for data_point in service.users().dataSources().dataPointChanges(). \ - list(**kwargs).execute().get('insertedDataPoint', []): - data_point['startTime'] = float(data_point.pop('startTimeNanos'))/1e9 - data_point['endTime'] = float(data_point.pop('endTimeNanos'))/1e9 - data_point['modifiedTime'] = float(data_point.pop('modifiedTimeMillis'))/1e6 + for data_point in ( + service.users() + .dataSources() + .dataPointChanges() + .list(**kwargs) + .execute() + .get('insertedDataPoint', []) + ): + data_point['startTime'] = float(data_point.pop('startTimeNanos')) / 1e9 + data_point['endTime'] = float(data_point.pop('endTimeNanos')) / 1e9 + data_point['modifiedTime'] = ( + float(data_point.pop('modifiedTimeMillis')) / 1e6 + ) values = [] for value in data_point.pop('value'): @@ -81,9 +85,11 @@ class GoogleFitPlugin(GooglePlugin): elif value.get('mapVal'): value = { v['key']: v['value'].get( - 'intVal', v['value'].get( - 'fpVal', v['value'].get('stringVal'))) - for v in value['mapVal'] } + 'intVal', + v['value'].get('fpVal', v['value'].get('stringVal')), + ) + for v in value['mapVal'] + } values.append(value) diff --git a/platypush/plugins/google/mail/__init__.py b/platypush/plugins/google/mail/__init__.py index 081c07b18..0941d95d5 100644 --- a/platypush/plugins/google/mail/__init__.py +++ b/platypush/plugins/google/mail/__init__.py @@ -17,12 +17,6 @@ from platypush.plugins.google import GooglePlugin class GoogleMailPlugin(GooglePlugin): """ GMail plugin. It allows you to programmatically compose and (TODO) get emails - - Requires: - - * **google-api-python-client** (``pip install google-api-python-client``) - * **oauth2client** (``pip install oauth2client``) - """ scopes = ['https://www.googleapis.com/auth/gmail.modify'] diff --git a/platypush/plugins/google/maps/__init__.py b/platypush/plugins/google/maps/__init__.py index 73a5e39c6..cc234c29d 100644 --- a/platypush/plugins/google/maps/__init__.py +++ b/platypush/plugins/google/maps/__init__.py @@ -14,12 +14,6 @@ datetime_types = Union[str, int, float, datetime] class GoogleMapsPlugin(GooglePlugin): """ Plugins that provides utilities to interact with Google Maps API services. - - Requires: - - * **google-api-python-client** (``pip install google-api-python-client``) - * **oauth2client** (``pip install oauth2client``) - """ scopes = [] diff --git a/platypush/plugins/google/pubsub/__init__.py b/platypush/plugins/google/pubsub/__init__.py index 1f7521131..1484cdd4b 100644 --- a/platypush/plugins/google/pubsub/__init__.py +++ b/platypush/plugins/google/pubsub/__init__.py @@ -19,19 +19,13 @@ class GooglePubsubPlugin(Plugin): 3. Download the JSON service credentials file. By default platypush will look for the credentials file under ~/.credentials/platypush/google/pubsub.json. - Requires: - - * **google-api-python-client** (``pip install google-api-python-client``) - * **oauth2client** (``pip install oauth2client``) - * **google-cloud-pubsub** (``pip install google-cloud-pubsub``) - - """ publisher_audience = 'https://pubsub.googleapis.com/google.pubsub.v1.Publisher' subscriber_audience = 'https://pubsub.googleapis.com/google.pubsub.v1.Subscriber' - default_credentials_file = os.path.join(os.path.expanduser('~'), - '.credentials', 'platypush', 'google', 'pubsub.json') + default_credentials_file = os.path.join( + os.path.expanduser('~'), '.credentials', 'platypush', 'google', 'pubsub.json' + ) def __init__(self, credentials_file: str = default_credentials_file, **kwargs): """ @@ -43,13 +37,15 @@ class GooglePubsubPlugin(Plugin): self.project_id = self.get_project_id() def get_project_id(self): - credentials = json.load(open(self.credentials_file)) - return credentials.get('project_id') + with open(self.credentials_file) as f: + return json.load(f).get('project_id') def get_credentials(self, audience: str): - # noinspection PyPackageRequirements from google.auth import jwt - return jwt.Credentials.from_service_account_file(self.credentials_file, audience=audience) + + return jwt.Credentials.from_service_account_file( + self.credentials_file, audience=audience + ) @action def send_message(self, topic: str, msg, **kwargs): @@ -63,9 +59,7 @@ class GooglePubsubPlugin(Plugin): :param msg: Message to be sent. It can be a list, a dict, or a Message object :param kwargs: Extra arguments to be passed to .publish() """ - # noinspection PyPackageRequirements from google.cloud import pubsub_v1 - # noinspection PyPackageRequirements from google.api_core.exceptions import AlreadyExists credentials = self.get_credentials(self.publisher_audience) @@ -79,9 +73,9 @@ class GooglePubsubPlugin(Plugin): except AlreadyExists: pass - if isinstance(msg, int) or isinstance(msg, float): + if isinstance(msg, (int, float)): msg = str(msg) - if isinstance(msg, dict) or isinstance(msg, list): + if isinstance(msg, (dict, list)): msg = json.dumps(msg) if isinstance(msg, str): msg = msg.encode() diff --git a/platypush/plugins/google/translate/__init__.py b/platypush/plugins/google/translate/__init__.py index 28d173a2a..632a26cc3 100644 --- a/platypush/plugins/google/translate/__init__.py +++ b/platypush/plugins/google/translate/__init__.py @@ -24,19 +24,19 @@ class GoogleTranslatePlugin(Plugin): 4. Create a new private JSON key for the service account and download it. By default platypush will look for the credentials file under ``~/.credentials/platypush/google/translate.json``. - Requires: - - * **google-api-python-client** (``pip install google-api-python-client``) - * **oauth2client** (``pip install oauth2client``) - * **google-cloud-translate** (``pip install google-cloud-translate``) - """ _maximum_text_length = 2000 - default_credentials_file = os.path.join(os.path.expanduser('~'), '.credentials', 'platypush', 'google', - 'translate.json') + default_credentials_file = os.path.join( + os.path.expanduser('~'), '.credentials', 'platypush', 'google', 'translate.json' + ) - def __init__(self, target_language: str = 'en', credentials_file: Optional[str] = None, **kwargs): + def __init__( + self, + target_language: str = 'en', + credentials_file: Optional[str] = None, + **kwargs + ): """ :param target_language: Default target language (default: 'en'). :param credentials_file: Google service account JSON credentials file. If none is specified then the plugin will @@ -50,7 +50,9 @@ class GoogleTranslatePlugin(Plugin): self.credentials_file = None if credentials_file: - self.credentials_file = os.path.abspath(os.path.expanduser(credentials_file)) + self.credentials_file = os.path.abspath( + os.path.expanduser(credentials_file) + ) elif os.path.isfile(self.default_credentials_file): self.credentials_file = self.default_credentials_file @@ -59,11 +61,11 @@ class GoogleTranslatePlugin(Plugin): @staticmethod def _nearest_delimiter_index(text: str, pos: int) -> int: - for i in range(min(pos, len(text)-1), -1, -1): + for i in range(min(pos, len(text) - 1), -1, -1): if text[i] in [' ', '\t', ',', '.', ')', '>']: return i elif text[i] in ['(', '<']: - return i-1 if i > 0 else 0 + return i - 1 if i > 0 else 0 return 0 @@ -77,17 +79,22 @@ class GoogleTranslatePlugin(Plugin): parts.append(text) text = '' else: - part = text[:i+1] + part = text[: i + 1] if part: parts.append(part.strip()) - text = text[i+1:] + text = text[i + 1 :] return parts # noinspection PyShadowingBuiltins @action - def translate(self, text: str, target_language: Optional[str] = None, source_language: Optional[str] = None, - format: Optional[str] = None) -> TranslateResponse: + def translate( + self, + text: str, + target_language: Optional[str] = None, + source_language: Optional[str] = None, + format: Optional[str] = None, + ) -> TranslateResponse: """ Translate a piece of text or HTML. diff --git a/platypush/plugins/google/youtube/__init__.py b/platypush/plugins/google/youtube/__init__.py index 5f150206d..82dfe0f03 100644 --- a/platypush/plugins/google/youtube/__init__.py +++ b/platypush/plugins/google/youtube/__init__.py @@ -5,12 +5,6 @@ from platypush.plugins.google import GooglePlugin class GoogleYoutubePlugin(GooglePlugin): """ YouTube plugin. - - Requires: - - * **google-api-python-client** (``pip install google-api-python-client``) - * **oauth2client** (``pip install oauth2client``) - """ scopes = ['https://www.googleapis.com/auth/youtube.readonly'] diff --git a/platypush/plugins/gotify/__init__.py b/platypush/plugins/gotify/__init__.py index bd041e34f..8928cb71b 100644 --- a/platypush/plugins/gotify/__init__.py +++ b/platypush/plugins/gotify/__init__.py @@ -17,11 +17,6 @@ class GotifyPlugin(RunnablePlugin): `Gotify `_ allows you process messages and notifications asynchronously over your own devices without relying on 3rd-party cloud services. - - Triggers: - - * :class:`platypush.message.event.gotify.GotifyMessageEvent` when a new message is received. - """ def __init__(self, server_url: str, app_token: str, client_token: str, **kwargs): @@ -47,11 +42,13 @@ class GotifyPlugin(RunnablePlugin): rs = getattr(requests, method)( f'{self.server_url}/{endpoint}', headers={ - 'X-Gotify-Key': self.app_token if method == 'post' else self.client_token, + 'X-Gotify-Key': self.app_token + if method == 'post' + else self.client_token, 'Content-Type': 'application/json', **kwargs.pop('headers', {}), }, - **kwargs + **kwargs, ) rs.raise_for_status() @@ -65,7 +62,9 @@ class GotifyPlugin(RunnablePlugin): stop_events = [] while not any(stop_events): - stop_events = self._should_stop.wait(timeout=1), self._disconnected_event.wait(timeout=1) + stop_events = self._should_stop.wait( + timeout=1 + ), self._disconnected_event.wait(timeout=1) def stop(self): if self._ws_app: @@ -78,7 +77,9 @@ class GotifyPlugin(RunnablePlugin): self._ws_listener.join(5) if self._ws_listener and self._ws_listener.is_alive(): - self.logger.warning('Terminating the websocket process failed, killing the process') + self.logger.warning( + 'Terminating the websocket process failed, killing the process' + ) self._ws_listener.kill() if self._ws_listener: @@ -92,13 +93,18 @@ class GotifyPlugin(RunnablePlugin): if self.should_stop() or self._connected_event.is_set(): return - ws_url = '/'.join([self.server_url.split('/')[0].replace('http', 'ws'), *self.server_url.split('/')[1:]]) + ws_url = '/'.join( + [ + self.server_url.split('/')[0].replace('http', 'ws'), + *self.server_url.split('/')[1:], + ] + ) self._ws_app = websocket.WebSocketApp( f'{ws_url}/stream?token={self.client_token}', on_open=self._on_open(), on_message=self._on_msg(), on_error=self._on_error(), - on_close=self._on_close() + on_close=self._on_close(), ) def server(): @@ -144,7 +150,13 @@ class GotifyPlugin(RunnablePlugin): return hndl @action - def send_message(self, message: str, title: Optional[str] = None, priority: int = 0, extras: Optional[dict] = None): + def send_message( + self, + message: str, + title: Optional[str] = None, + priority: int = 0, + extras: Optional[dict] = None, + ): """ Send a message to the server. @@ -155,12 +167,16 @@ class GotifyPlugin(RunnablePlugin): :return: .. schema:: gotify.GotifyMessageSchema """ return GotifyMessageSchema().dump( - self._execute('post', 'message', json={ - 'message': message, - 'title': title, - 'priority': priority, - 'extras': extras or {}, - }) + self._execute( + 'post', + 'message', + json={ + 'message': message, + 'title': title, + 'priority': priority, + 'extras': extras or {}, + }, + ) ) @action @@ -174,11 +190,14 @@ class GotifyPlugin(RunnablePlugin): """ return GotifyMessageSchema().dump( self._execute( - 'get', 'message', params={ + 'get', + 'message', + params={ 'limit': limit, **({'since': since} if since else {}), - } - ).get('messages', []), many=True + }, + ).get('messages', []), + many=True, ) @action diff --git a/platypush/plugins/gpio/__init__.py b/platypush/plugins/gpio/__init__.py index 07f189890..d17a72421 100644 --- a/platypush/plugins/gpio/__init__.py +++ b/platypush/plugins/gpio/__init__.py @@ -10,16 +10,6 @@ class GpioPlugin(RunnablePlugin): """ This plugin can be used to interact with custom electronic devices connected to a Raspberry Pi (or compatible device) over GPIO pins. - - Requires: - - * **RPi.GPIO** (``pip install RPi.GPIO``) - - Triggers: - - * :class:`platypush.message.event.gpio.GPIOEvent` when the value of a - monitored PIN changes. - """ def __init__( diff --git a/platypush/plugins/gpio/zeroborg/__init__.py b/platypush/plugins/gpio/zeroborg/__init__.py index 5ab97e260..44c6a216d 100644 --- a/platypush/plugins/gpio/zeroborg/__init__.py +++ b/platypush/plugins/gpio/zeroborg/__init__.py @@ -22,12 +22,6 @@ class GpioZeroborgPlugin(Plugin): ZeroBorg plugin. It allows you to control a ZeroBorg (https://www.piborg.org/motor-control-1135/zeroborg) motor controller and infrared sensor circuitry for Raspberry Pi - - Triggers: - - * :class:`platypush.message.event.zeroborg.ZeroborgDriveEvent` when motors direction changes - * :class:`platypush.message.event.zeroborg.ZeroborgStopEvent` upon motors stop - """ def __init__(self, directions: Dict[str, List[float]] = None, **kwargs): @@ -72,6 +66,7 @@ class GpioZeroborgPlugin(Plugin): directions = {} import platypush.plugins.gpio.zeroborg.lib as ZeroBorg + super().__init__(**kwargs) self.directions = directions @@ -109,13 +104,19 @@ class GpioZeroborgPlugin(Plugin): if self._direction in self.directions: self._motors = self.directions[self._direction] else: - self.logger.warning('Invalid direction {}: stopping motors'.format(self._direction)) + self.logger.warning( + 'Invalid direction {}: stopping motors'.format( + self._direction + ) + ) except Exception as e: - self.logger.error('Error on _get_direction_from_sensors: {}'.format(str(e))) + self.logger.error( + 'Error on _get_direction_from_sensors: {}'.format(str(e)) + ) break for i, power in enumerate(self._motors): - method = getattr(self.zb, 'SetMotor{}'.format(i+1)) + method = getattr(self.zb, 'SetMotor{}'.format(i + 1)) method(power) finally: self.zb.MotorsOff() @@ -129,7 +130,11 @@ class GpioZeroborgPlugin(Plugin): drive_thread.start() self._drive_thread = drive_thread - get_bus().post(ZeroborgDriveEvent(direction=self._direction, motors=self.directions[self._direction])) + get_bus().post( + ZeroborgDriveEvent( + direction=self._direction, motors=self.directions[self._direction] + ) + ) return {'status': 'running', 'direction': direction} @action @@ -163,7 +168,9 @@ class GpioZeroborgPlugin(Plugin): return { 'status': 'running' if self._direction else 'stopped', 'direction': self._direction, - 'motors': [getattr(self.zb, 'GetMotor{}'.format(i+1))() for i in range(4)], + 'motors': [ + getattr(self.zb, 'GetMotor{}'.format(i + 1))() for i in range(4) + ], } diff --git a/platypush/plugins/hid/__init__.py b/platypush/plugins/hid/__init__.py index 7c9ac5fb6..78716dbc3 100644 --- a/platypush/plugins/hid/__init__.py +++ b/platypush/plugins/hid/__init__.py @@ -46,15 +46,6 @@ class HidPlugin(RunnablePlugin): # udevadm control --reload && udevadm trigger - Triggers: - - * :class:`platypush.message.event.hid.HidDeviceConnectedEvent` when a - device is connected - * :class:`platypush.message.event.hid.HidDeviceDisconnectedEvent` when - a previously available device is disconnected - * :class:`platypush.message.event.hid.HidDeviceDataEvent` when a - monitored device sends some data - """ def __init__( diff --git a/platypush/plugins/http/request/rss/__init__.py b/platypush/plugins/http/request/rss/__init__.py deleted file mode 100644 index f1ec501f0..000000000 --- a/platypush/plugins/http/request/rss/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -from platypush.plugins import action -from platypush.plugins.http.request import HttpRequestPlugin - - -class HttpRequestRssPlugin(HttpRequestPlugin): - """ - Plugin to programmatically retrieve and parse an RSS feed URL. - - Requires: - - * **feedparser** (``pip install feedparser``) - """ - - @action - def get(self, url, **_): - import feedparser - - response = super().get(url, output='text').output - feed = feedparser.parse(response) - return feed.entries - - -# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/http/request/rss/manifest.yaml b/platypush/plugins/http/request/rss/manifest.yaml deleted file mode 100644 index fbe46f14b..000000000 --- a/platypush/plugins/http/request/rss/manifest.yaml +++ /dev/null @@ -1,15 +0,0 @@ -manifest: - events: {} - install: - apk: - - py3-feedparser - apt: - - python3-feedparser - dnf: - - python-feedparser - pacman: - - python-feedparser - pip: - - feedparser - package: platypush.plugins.http.request.rss - type: plugin diff --git a/platypush/plugins/http/webpage/__init__.py b/platypush/plugins/http/webpage/__init__.py index 425fbade4..20b07bb06 100644 --- a/platypush/plugins/http/webpage/__init__.py +++ b/platypush/plugins/http/webpage/__init__.py @@ -71,8 +71,6 @@ class HttpWebpagePlugin(Plugin): Requires: - * **weasyprint** (``pip install weasyprint``), optional, for HTML->PDF conversion - * **node** and **npm** installed on your system (to use the mercury-parser interface) * The mercury-parser library installed (``npm install -g @postlight/mercury-parser``) """ diff --git a/platypush/plugins/inputs/__init__.py b/platypush/plugins/inputs/__init__.py index 24787a79e..7919f3f46 100644 --- a/platypush/plugins/inputs/__init__.py +++ b/platypush/plugins/inputs/__init__.py @@ -7,23 +7,20 @@ class InputsPlugin(Plugin): """ This plugin emulates user input on a keyboard/mouse. It requires the a graphical server (X server or Mac/Win interface) to be running - it won't work in console mode. - - Requires: - - * **pyuserinput** (``pip install pyuserinput``) - """ @staticmethod def _get_keyboard(): # noinspection PyPackageRequirements from pykeyboard import PyKeyboard + return PyKeyboard() @staticmethod def _get_mouse(): # noinspection PyPackageRequirements from pymouse import PyMouse + return PyMouse() @classmethod diff --git a/platypush/plugins/kafka/__init__.py b/platypush/plugins/kafka/__init__.py index 0ed8481c7..376a6b9f9 100644 --- a/platypush/plugins/kafka/__init__.py +++ b/platypush/plugins/kafka/__init__.py @@ -8,14 +8,6 @@ from platypush.plugins import Plugin, action class KafkaPlugin(Plugin): """ Plugin to send messages to an Apache Kafka instance (https://kafka.apache.org/) - - Triggers: - - * :class:`platypush.message.event.kafka.KafkaMessageEvent` when a new message is received on the consumer topic. - - Requires: - - * **kafka** (``pip install kafka-python``) """ def __init__(self, server=None, port=9092, **kwargs): @@ -30,8 +22,9 @@ class KafkaPlugin(Plugin): super().__init__(**kwargs) - self.server = '{server}:{port}'.format(server=server, port=port) \ - if server else None + self.server = ( + '{server}:{port}'.format(server=server, port=port) if server else None + ) self.producer = None @@ -60,13 +53,15 @@ class KafkaPlugin(Plugin): kafka_backend = get_backend('kafka') server = kafka_backend.server except Exception as e: - raise RuntimeError(f'No Kafka server nor default server specified: {str(e)}') + raise RuntimeError( + f'No Kafka server nor default server specified: {str(e)}' + ) else: server = self.server - if isinstance(msg, dict) or isinstance(msg, list): + if isinstance(msg, (dict, list)): msg = json.dumps(msg) - msg = str(msg).encode('utf-8') + msg = str(msg).encode() producer = KafkaProducer(bootstrap_servers=server) producer.send(topic, msg) diff --git a/platypush/plugins/lastfm/__init__.py b/platypush/plugins/lastfm/__init__.py index b34d25419..ff414d910 100644 --- a/platypush/plugins/lastfm/__init__.py +++ b/platypush/plugins/lastfm/__init__.py @@ -8,10 +8,6 @@ class LastfmPlugin(Plugin): """ Plugin to interact with your Last.FM (https://last.fm) account, update your current track and your scrobbles. - - Requires: - - * **pylast** (``pip install pylast``) """ def __init__(self, api_key, api_secret, username, password): diff --git a/platypush/plugins/lcd/__init__.py b/platypush/plugins/lcd/__init__.py index 933ff2fd0..4d07d3c3c 100644 --- a/platypush/plugins/lcd/__init__.py +++ b/platypush/plugins/lcd/__init__.py @@ -7,13 +7,8 @@ from platypush.plugins import Plugin, action class LcdPlugin(Plugin, ABC): """ Abstract class for plugins to communicate with LCD displays. - - Requires: - - * **RPLCD** (``pip install RPLCD``) - * **RPi.GPIO** (``pip install RPi.GPIO``) - """ + def __init__(self, **kwargs): super().__init__(**kwargs) self.lcd = None @@ -21,9 +16,12 @@ class LcdPlugin(Plugin, ABC): @staticmethod def _get_pin_mode(pin_mode: str) -> int: import RPi.GPIO + pin_modes = ['BOARD', 'BCM'] pin_mode = pin_mode.upper() - assert pin_mode in pin_modes, 'Invalid pin_mode: {}. Supported modes: {}'.format(pin_mode, pin_modes) + assert ( + pin_mode in pin_modes + ), 'Invalid pin_mode: {}. Supported modes: {}'.format(pin_mode, pin_modes) return getattr(RPi.GPIO, pin_mode).value @abstractmethod @@ -105,7 +103,8 @@ class LcdPlugin(Plugin, ABC): modes = ['left', 'right'] mode = mode.lower() assert mode in modes, 'Unsupported text mode: {}. Supported modes: {}'.format( - mode, modes) + mode, modes + ) self._init_lcd() self.lcd.text_align_mode = mode diff --git a/platypush/plugins/lcd/gpio/__init__.py b/platypush/plugins/lcd/gpio/__init__.py index 1fe9f024f..46c7c2a70 100644 --- a/platypush/plugins/lcd/gpio/__init__.py +++ b/platypush/plugins/lcd/gpio/__init__.py @@ -6,23 +6,26 @@ from platypush.plugins.lcd import LcdPlugin class LcdGpioPlugin(LcdPlugin): """ Plugin to write to an LCD display connected via GPIO. - - Requires: - - * **RPLCD** (``pip install RPLCD``) - * **RPi.GPIO** (``pip install RPi.GPIO``) - """ - def __init__(self, pin_rs: int, pin_e: int, pins_data: List[int], - pin_rw: Optional[int] = None, pin_mode: str = 'BOARD', - pin_backlight: Optional[int] = None, - cols: int = 16, rows: int = 2, - backlight_enabled: bool = True, - backlight_mode: str = 'active_low', - dotsize: int = 8, charmap: str = 'A02', - auto_linebreaks: bool = True, - compat_mode: bool = False, **kwargs): + def __init__( + self, + pin_rs: int, + pin_e: int, + pins_data: List[int], + pin_rw: Optional[int] = None, + pin_mode: str = 'BOARD', + pin_backlight: Optional[int] = None, + cols: int = 16, + rows: int = 2, + backlight_enabled: bool = True, + backlight_mode: str = 'active_low', + dotsize: int = 8, + charmap: str = 'A02', + auto_linebreaks: bool = True, + compat_mode: bool = False, + **kwargs + ): """ :param pin_rs: Pin for register select (RS). :param pin_e: Pin to start data read or write (E). @@ -70,15 +73,23 @@ class LcdGpioPlugin(LcdPlugin): def _get_lcd(self): from RPLCD.gpio import CharLCD - return CharLCD(cols=self.cols, rows=self.rows, pin_rs=self.pin_rs, - pin_e=self.pin_e, pins_data=self.pins_data, - numbering_mode=self.pin_mode, pin_rw=self.pin_rw, - pin_backlight=self.pin_backlight, - backlight_enabled=self.backlight_enabled, - backlight_mode=self.backlight_mode, - dotsize=self.dotsize, charmap=self.charmap, - auto_linebreaks=self.auto_linebreaks, - compat_mode=self.compat_mode) + + return CharLCD( + cols=self.cols, + rows=self.rows, + pin_rs=self.pin_rs, + pin_e=self.pin_e, + pins_data=self.pins_data, + numbering_mode=self.pin_mode, + pin_rw=self.pin_rw, + pin_backlight=self.pin_backlight, + backlight_enabled=self.backlight_enabled, + backlight_mode=self.backlight_mode, + dotsize=self.dotsize, + charmap=self.charmap, + auto_linebreaks=self.auto_linebreaks, + compat_mode=self.compat_mode, + ) # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/lcd/i2c/__init__.py b/platypush/plugins/lcd/i2c/__init__.py index 92fa03e69..77ed68f62 100644 --- a/platypush/plugins/lcd/i2c/__init__.py +++ b/platypush/plugins/lcd/i2c/__init__.py @@ -8,47 +8,63 @@ class LcdI2cPlugin(LcdPlugin): Plugin to write to an LCD display connected via I2C. Adafruit I2C/SPI LCD Backback is supported. - Warning: You might need a level shifter (that supports i2c) - between the SCL/SDA connections on the MCP chip / backpack and the Raspberry Pi. - Or you might damage the Pi and possibly any other 3.3V i2c devices - connected on the i2c bus. Or cause reliability issues. The SCL/SDA are rated 0.7*VDD - on the MCP23008, so it needs 3.5V on the SCL/SDA when 5V is applied to drive the LCD. - The MCP23008 and MCP23017 needs to be connected exactly the same way as the backpack. + Warning: You might need a level shifter (that supports i2c) between the + SCL/SDA connections on the MCP chip / backpack and the Raspberry Pi. + + Otherwise, you might damage the Pi and possibly any other 3.3V i2c devices + connected on the i2c bus. Or cause reliability issues. + + The SCL/SDA are rated 0.7*VDD on the MCP23008, so it needs 3.5V on the + SCL/SDA when 5V is applied to drive the LCD. + + The MCP23008 and MCP23017 needs to be connected exactly the same way as the + backpack. + For complete schematics see the adafruit page at: https://learn.adafruit.com/i2c-spi-lcd-backpack/ - 4-bit operation. I2C only supported. + + 4-bit operations. I2C only supported. Pin mapping:: 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 BL | D7 | D6 | D5 | D4 | E | RS | - - Requires: - - * **RPLCD** (``pip install RPLCD``) - * **RPi.GPIO** (``pip install RPi.GPIO``) - """ - def __init__(self, i2c_expander: str, address: int, - expander_params: Optional[dict] = None, - port: int = 1, cols: int = 16, rows: int = 2, - backlight_enabled: bool = True, - dotsize: int = 8, charmap: str = 'A02', - auto_linebreaks: bool = True, **kwargs): + def __init__( + self, + i2c_expander: str, + address: int, + expander_params: Optional[dict] = None, + port: int = 1, + cols: int = 16, + rows: int = 2, + backlight_enabled: bool = True, + dotsize: int = 8, + charmap: str = 'A02', + auto_linebreaks: bool = True, + **kwargs + ): """ - :param i2c_expander: Set your I²C chip type. Supported: "PCF8574", "MCP23008", "MCP23017". + :param i2c_expander: Set your I²C chip type. Supported: "PCF8574", + "MCP23008", "MCP23017". :param address: The I2C address of your LCD. - :param expander_params: Parameters for expanders, in a dictionary. Only needed for MCP23017 - gpio_bank - This must be either ``A`` or ``B``. If you have a HAT, A is usually marked 1 and B is 2. - Example: ``expander_params={'gpio_bank': 'A'}`` + :param expander_params: Parameters for expanders, in a dictionary. Only + needed for MCP23017 gpio_bank - This must be either ``A`` or ``B``. + If you have a HAT, A is usually marked 1 and B is 2. Example: + ``expander_params={'gpio_bank': 'A'}`` :param port: The I2C port number. Default: ``1``. :param cols: Number of columns per row (usually 16 or 20). Default: ``16``. :param rows: Number of display rows (usually 1, 2 or 4). Default: ``2``. - :param backlight_enabled: Whether the backlight is enabled initially. Default: ``True``. Has no effect if pin_backlight is ``None`` - :param dotsize: Some 1 line displays allow a font height of 10px. Allowed: ``8`` or ``10``. Default: ``8``. - :param charmap: The character map used. Depends on your LCD. This must be either ``A00`` or ``A02`` or ``ST0B``. Default: ``A02``. - :param auto_linebreaks: Whether or not to automatically insert line breaks. Default: ``True``. + :param backlight_enabled: Whether the backlight is enabled initially. + Default: ``True``. Has no effect if pin_backlight is ``None`` + :param dotsize: Some 1 line displays allow a font height of 10px. + Allowed: ``8`` or ``10``. Default: ``8``. + :param charmap: The character map used. Depends on your LCD. This must + be either ``A00`` or ``A02`` or ``ST0B``. Default: ``A02``. + :param auto_linebreaks: Whether or not to automatically insert line + breaks. Default: ``True``. """ super().__init__(**kwargs) @@ -65,12 +81,18 @@ class LcdI2cPlugin(LcdPlugin): def _get_lcd(self): from RPLCD.i2c import CharLCD - return CharLCD(cols=self.cols, rows=self.rows, - i2c_expander=self.i2c_expander, - address=self.address, port=self.port, - backlight_enabled=self.backlight_enabled, - dotsize=self.dotsize, charmap=self.charmap, - auto_linebreaks=self.auto_linebreaks) + + return CharLCD( + cols=self.cols, + rows=self.rows, + i2c_expander=self.i2c_expander, + address=self.address, + port=self.port, + backlight_enabled=self.backlight_enabled, + dotsize=self.dotsize, + charmap=self.charmap, + auto_linebreaks=self.auto_linebreaks, + ) class LcdI2CPlugin(LcdI2cPlugin): diff --git a/platypush/plugins/light/hue/__init__.py b/platypush/plugins/light/hue/__init__.py index a901f82e9..1bc8769f1 100644 --- a/platypush/plugins/light/hue/__init__.py +++ b/platypush/plugins/light/hue/__init__.py @@ -34,18 +34,6 @@ from platypush.plugins import RunnablePlugin, action class LightHuePlugin(RunnablePlugin, LightEntityManager): """ Philips Hue lights plugin. - - Requires: - - * **phue** (``pip install phue``) - - Triggers: - - - :class:`platypush.message.event.light.LightAnimationStartedEvent` when an animation is started. - - :class:`platypush.message.event.light.LightAnimationStoppedEvent` when an animation is stopped. - - :class:`platypush.message.event.light.LightStatusChangeEvent` when the status of a lightbulb - changes. - """ MAX_BRI = 255 @@ -88,7 +76,7 @@ class LightHuePlugin(RunnablePlugin, LightEntityManager): """ :param bridge: Bridge address or hostname :param lights: Default lights to be controlled (default: all) - :param groups Default groups to be controlled (default: all) + :param groups: Default groups to be controlled (default: all) :param poll_interval: How often the plugin should check the bridge for light updates (default: 20 seconds). :param config_file: Path to the phue configuration file containing the diff --git a/platypush/plugins/linode/__init__.py b/platypush/plugins/linode/__init__.py index dbc371287..2214e7cc3 100644 --- a/platypush/plugins/linode/__init__.py +++ b/platypush/plugins/linode/__init__.py @@ -26,14 +26,6 @@ class LinodePlugin(RunnablePlugin, CloudInstanceEntityManager, EnumSwitchEntityM - Go to My Profile -> API Tokens -> Add a Personal Access Token. - Select the scopes that you want to provide to your new token. - Requires: - - * **linode_api4** (``pip install linode_api4``) - - Triggers: - - * :class:`platypush.message.event.linode.LinodeInstanceStatusChanged` when the status of an instance changes. - """ def __init__(self, token: str, poll_interval: float = 60.0, **kwargs): diff --git a/platypush/plugins/luma/oled/__init__.py b/platypush/plugins/luma/oled/__init__.py index 842e5afa3..02052c143 100644 --- a/platypush/plugins/luma/oled/__init__.py +++ b/platypush/plugins/luma/oled/__init__.py @@ -27,46 +27,48 @@ class DeviceRotation(enum.IntEnum): class LumaOledPlugin(Plugin): """ Plugin to interact with small OLED-based RaspberryPi displays through the luma.oled driver. - - Requires: - - * **luma.oled** (``pip install git+https://github.com/rm-hull/luma.oled``) - """ - def __init__(self, - interface: str, - device: str, - port: int = 0, - slot: int = DeviceSlot.BACK.value, - width: int = 128, - height: int = 64, - rotate: int = DeviceRotation.ROTATE_0.value, - gpio_DC: int = 24, - gpio_RST: int = 25, - bus_speed_hz: int = 8000000, - address: int = 0x3c, - cs_high: bool = False, - transfer_size: int = 4096, - spi_mode: Optional[int] = None, - font: Optional[str] = None, - font_size: int = 10, - **kwargs): + def __init__( + self, + interface: str, + device: str, + port: int = 0, + slot: int = DeviceSlot.BACK.value, + width: int = 128, + height: int = 64, + rotate: int = DeviceRotation.ROTATE_0.value, + gpio_DC: int = 24, + gpio_RST: int = 25, + bus_speed_hz: int = 8000000, + address: int = 0x3C, + cs_high: bool = False, + transfer_size: int = 4096, + spi_mode: Optional[int] = None, + font: Optional[str] = None, + font_size: int = 10, + **kwargs + ): """ - :param interface: Serial interface the display is connected to (``spi`` or ``i2c``). - :param device: Display chipset type (supported: ssd1306 ssd1309, ssd1322, ssd1325, ssd1327, ssd1331, ssd1351, ssd1362, sh1106). - :param port: Device port (usually 0 or 1). + :param interface: Serial interface the display is connected to (``spi`` + or ``i2c``). + :param device: Display chipset type (supported: ssd1306 ssd1309, + ssd1322, ssd1325, ssd1327, ssd1331, ssd1351, ssd1362, sh1106). + :param port: Device port (usually 0 or 1). :param slot: Device slot (0 for back, 1 for front). :param width: Display width. :param height: Display height. - :param rotate: Display rotation (0 for no rotation, 1 for 90 degrees, 2 for 180 degrees, 3 for 270 degrees). + :param rotate: Display rotation (0 for no rotation, 1 for 90 degrees, 2 + for 180 degrees, 3 for 270 degrees). :param gpio_DC: [SPI only] GPIO PIN used for data (default: 24). :param gpio_RST: [SPI only] GPIO PIN used for RST (default: 25). :param bus_speed_hz: [SPI only] Bus speed in Hz (default: 8 MHz). :param address: [I2C only] Device address (default: 0x3c). :param cs_high: [SPI only] Set to True if the SPI chip select is high. - :param transfer_size: [SPI only] Maximum amount of bytes to transfer in one go (default: 4096). - :param spi_mode: [SPI only] SPI mode as two bit pattern of clock polarity and phase [CPOL|CPHA], 0-3 (default:None). + :param transfer_size: [SPI only] Maximum amount of bytes to transfer in + one go (default: 4096). + :param spi_mode: [SPI only] SPI mode as two bit pattern of clock + polarity and phase [CPOL|CPHA], 0-3 (default:None). :param font: Path to a default TTF font used to display the text. :param font_size: Font size - it only applies if ``font`` is set. """ @@ -80,9 +82,16 @@ class LumaOledPlugin(Plugin): interface = getattr(serial, DeviceInterface(interface).value) if iface_name == DeviceInterface.SPI.value: - self.serial = interface(port=port, device=slot, cs_high=cs_high, gpio_DC=gpio_DC, - gpio_RST=gpio_RST, bus_speed_hz=bus_speed_hz, - transfer_size=transfer_size, spi_mode=spi_mode) + self.serial = interface( + port=port, + device=slot, + cs_high=cs_high, + gpio_DC=gpio_DC, + gpio_RST=gpio_RST, + bus_speed_hz=bus_speed_hz, + transfer_size=transfer_size, + spi_mode=spi_mode, + ) else: self.serial = interface(port=port, address=address) @@ -95,7 +104,9 @@ class LumaOledPlugin(Plugin): def _get_font(self, font: Optional[str] = None, font_size: Optional[int] = None): if font: - return ImageFont.truetype(os.path.abspath(os.path.expanduser(font)), font_size or self.font_size) + return ImageFont.truetype( + os.path.abspath(os.path.expanduser(font)), font_size or self.font_size + ) return self.font @@ -105,13 +116,21 @@ class LumaOledPlugin(Plugin): clear the display canvas. """ from luma.core.render import canvas + self.device.clear() del self.canvas self.canvas = canvas(self.device) @action - def text(self, text: str, pos: Union[Tuple[int], List[int]] = (0, 0), - fill: str = 'white', font: Optional[str] = None, font_size: Optional[int] = None, clear: bool = False): + def text( + self, + text: str, + pos: Union[Tuple[int], List[int]] = (0, 0), + fill: str = 'white', + font: Optional[str] = None, + font_size: Optional[int] = None, + clear: bool = False, + ): """ Draw text on the canvas. @@ -120,7 +139,8 @@ class LumaOledPlugin(Plugin): :param fill: Text color (default: ``white``). :param font: ``font`` type override. :param font_size: ``font_size`` override. - :param clear: Set to True if you want to clear the canvas before writing the text (default: False). + :param clear: Set to True if you want to clear the canvas before + writing the text (default: False). """ if clear: self.clear() @@ -131,17 +151,25 @@ class LumaOledPlugin(Plugin): draw.text(pos, text, fill=fill, font=font) @action - def rectangle(self, xy: Optional[Union[Tuple[int], List[int]]] = None, - fill: Optional[str] = None, outline: Optional[str] = None, - width: int = 1, clear: bool = False): + def rectangle( + self, + xy: Optional[Union[Tuple[int], List[int]]] = None, + fill: Optional[str] = None, + outline: Optional[str] = None, + width: int = 1, + clear: bool = False, + ): """ Draw a rectangle on the canvas. - :param xy: Two points defining the bounding box, either as [(x0, y0), (x1, y1)] or [x0, y0, x1, y1]. Default: bounding box of the device. + :param xy: Two points defining the bounding box, either as ``[(x0, y0), + (x1, y1)]`` or ``[x0, y0, x1, y1]``. Default: bounding box of the + device. :param fill: Fill color - can be ``black`` or ``white``. :param outline: Outline color - can be ``black`` or ``white``. :param width: Figure width in pixels (default: 1). - :param clear: Set to True if you want to clear the canvas before writing the text (default: False). + :param clear: Set to True if you want to clear the canvas before + writing the text (default: False). """ if clear: self.clear() @@ -153,19 +181,31 @@ class LumaOledPlugin(Plugin): draw.rectangle(xy, outline=outline, fill=fill, width=width) @action - def arc(self, start: int, end: int, xy: Optional[Union[Tuple[int], List[int]]] = None, - fill: Optional[str] = None, outline: Optional[str] = None, - width: int = 1, clear: bool = False): + def arc( + self, + start: int, + end: int, + xy: Optional[Union[Tuple[int], List[int]]] = None, + fill: Optional[str] = None, + outline: Optional[str] = None, + width: int = 1, + clear: bool = False, + ): """ Draw an arc on the canvas. - :param start: Starting angle, in degrees (measured from 3 o' clock and increasing clockwise). - :param end: Ending angle, in degrees (measured from 3 o' clock and increasing clockwise). - :param xy: Two points defining the bounding box, either as [(x0, y0), (x1, y1)] or [x0, y0, x1, y1]. Default: bounding box of the device. + :param start: Starting angle, in degrees (measured from 3 o' clock and + increasing clockwise). + :param end: Ending angle, in degrees (measured from 3 o' clock and + increasing clockwise). + :param xy: Two points defining the bounding box, either as ``[(x0, y0), + (x1, y1)]`` or ``[x0, y0, x1, y1]``. Default: bounding box of the + device. :param fill: Fill color - can be ``black`` or ``white``. :param outline: Outline color - can be ``black`` or ``white``. :param width: Figure width in pixels (default: 1). - :param clear: Set to True if you want to clear the canvas before writing the text (default: False). + :param clear: Set to True if you want to clear the canvas before + writing the text (default: False). """ if clear: self.clear() @@ -177,19 +217,31 @@ class LumaOledPlugin(Plugin): draw.arc(xy, start=start, end=end, outline=outline, fill=fill, width=width) @action - def chord(self, start: int, end: int, xy: Optional[Union[Tuple[int], List[int]]] = None, - fill: Optional[str] = None, outline: Optional[str] = None, - width: int = 1, clear: bool = False): + def chord( + self, + start: int, + end: int, + xy: Optional[Union[Tuple[int], List[int]]] = None, + fill: Optional[str] = None, + outline: Optional[str] = None, + width: int = 1, + clear: bool = False, + ): """ Same as ``arc``, but it connects the end points with a straight line. - :param start: Starting angle, in degrees (measured from 3 o' clock and increasing clockwise). - :param end: Ending angle, in degrees (measured from 3 o' clock and increasing clockwise). - :param xy: Two points defining the bounding box, either as [(x0, y0), (x1, y1)] or [x0, y0, x1, y1]. Default: bounding box of the device. + :param start: Starting angle, in degrees (measured from 3 o' clock and + increasing clockwise). + :param end: Ending angle, in degrees (measured from 3 o' clock and + increasing clockwise). + :param xy: Two points defining the bounding box, either as ``[(x0, y0), + (x1, y1)]`` or ``[x0, y0, x1, y1]``. Default: bounding box of the + device. :param fill: Fill color - can be ``black`` or ``white``. :param outline: Outline color - can be ``black`` or ``white``. :param width: Figure width in pixels (default: 1). - :param clear: Set to True if you want to clear the canvas before writing the text (default: False). + :param clear: Set to True if you want to clear the canvas before + writing the text (default: False). """ if clear: self.clear() @@ -198,22 +250,37 @@ class LumaOledPlugin(Plugin): xy = self.device.bounding_box with self.canvas as draw: - draw.chord(xy, start=start, end=end, outline=outline, fill=fill, width=width) + draw.chord( + xy, start=start, end=end, outline=outline, fill=fill, width=width + ) @action - def pieslice(self, start: int, end: int, xy: Optional[Union[Tuple[int], List[int]]] = None, - fill: Optional[str] = None, outline: Optional[str] = None, - width: int = 1, clear: bool = False): + def pieslice( + self, + start: int, + end: int, + xy: Optional[Union[Tuple[int], List[int]]] = None, + fill: Optional[str] = None, + outline: Optional[str] = None, + width: int = 1, + clear: bool = False, + ): """ - Same as ``arc``, but it also draws straight lines between the end points and the center of the bounding box. + Same as ``arc``, but it also draws straight lines between the end + points and the center of the bounding box. - :param start: Starting angle, in degrees (measured from 3 o' clock and increasing clockwise). - :param end: Ending angle, in degrees (measured from 3 o' clock and increasing clockwise). - :param xy: Two points defining the bounding box, either as [(x0, y0), (x1, y1)] or [x0, y0, x1, y1]. Default: bounding box of the device. + :param start: Starting angle, in degrees (measured from 3 o' clock and + increasing clockwise). + :param end: Ending angle, in degrees (measured from 3 o' clock and + increasing clockwise). + :param xy: Two points defining the bounding box, either as ``[(x0, y0), + (x1, y1)]`` or ``[x0, y0, x1, y1]``. Default: bounding box of the + device. :param fill: Fill color - can be ``black`` or ``white``. :param outline: Outline color - can be ``black`` or ``white``. :param width: Figure width in pixels (default: 1). - :param clear: Set to True if you want to clear the canvas before writing the text (default: False). + :param clear: Set to True if you want to clear the canvas before + writing the text (default: False). """ if clear: self.clear() @@ -222,20 +289,30 @@ class LumaOledPlugin(Plugin): xy = self.device.bounding_box with self.canvas as draw: - draw.pieslice(xy, start=start, end=end, outline=outline, fill=fill, width=width) + draw.pieslice( + xy, start=start, end=end, outline=outline, fill=fill, width=width + ) @action - def ellipse(self, xy: Optional[Union[Tuple[int], List[int]]] = None, - fill: Optional[str] = None, outline: Optional[str] = None, - width: int = 1, clear: bool = False): + def ellipse( + self, + xy: Optional[Union[Tuple[int], List[int]]] = None, + fill: Optional[str] = None, + outline: Optional[str] = None, + width: int = 1, + clear: bool = False, + ): """ Draw an ellipse on the canvas. - :param xy: Two points defining the bounding box, either as [(x0, y0), (x1, y1)] or [x0, y0, x1, y1]. Default: bounding box of the device. + :param xy: Two points defining the bounding box, either as ``[(x0, y0), + (x1, y1)]`` or ``[x0, y0, x1, y1]``. Default: bounding box of the + device. :param fill: Fill color - can be ``black`` or ``white``. :param outline: Outline color - can be ``black`` or ``white``. :param width: Figure width in pixels (default: 1). - :param clear: Set to True if you want to clear the canvas before writing the text (default: False). + :param clear: Set to True if you want to clear the canvas before + writing the text (default: False). """ if clear: self.clear() @@ -247,18 +324,27 @@ class LumaOledPlugin(Plugin): draw.ellipse(xy, outline=outline, fill=fill, width=width) @action - def line(self, xy: Optional[Union[Tuple[int], List[int]]] = None, - fill: Optional[str] = None, outline: Optional[str] = None, - width: int = 1, curve: bool = False, clear: bool = False): + def line( + self, + xy: Optional[Union[Tuple[int], List[int]]] = None, + fill: Optional[str] = None, + outline: Optional[str] = None, + width: int = 1, + curve: bool = False, + clear: bool = False, + ): """ Draw a line on the canvas. - :param xy: Sequence of either 2-tuples like [(x, y), (x, y), ...] or numeric values like [x, y, x, y, ...]. + :param xy: Two points defining the bounding box, either as ``[(x0, y0), + (x1, y1)]`` or ``[x0, y0, x1, y1]``. Default: bounding box of the + device. :param fill: Fill color - can be ``black`` or ``white``. :param outline: Outline color - can be ``black`` or ``white``. :param width: Figure width in pixels (default: 1). :param curve: Set to True for rounded edges (default: False). - :param clear: Set to True if you want to clear the canvas before writing the text (default: False). + :param clear: Set to True if you want to clear the canvas before + writing the text (default: False). """ if clear: self.clear() @@ -267,17 +353,30 @@ class LumaOledPlugin(Plugin): xy = self.device.bounding_box with self.canvas as draw: - draw.line(xy, outline=outline, fill=fill, width=width, joint='curve' if curve else None) + draw.line( + xy, + outline=outline, + fill=fill, + width=width, + joint='curve' if curve else None, + ) @action - def point(self, xy: Optional[Union[Tuple[int], List[int]]] = None, - fill: Optional[str] = None, clear: bool = False): + def point( + self, + xy: Optional[Union[Tuple[int], List[int]]] = None, + fill: Optional[str] = None, + clear: bool = False, + ): """ Draw one or more points on the canvas. - :param xy: Sequence of either 2-tuples like [(x, y), (x, y), ...] or numeric values like [x, y, x, y, ...]. + :param xy: Two points defining the bounding box, either as ``[(x0, y0), + (x1, y1)]`` or ``[x0, y0, x1, y1]``. Default: bounding box of the + device. :param fill: Fill color - can be ``black`` or ``white``. - :param clear: Set to True if you want to clear the canvas before writing the text (default: False). + :param clear: Set to True if you want to clear the canvas before + writing the text (default: False). """ if clear: self.clear() @@ -289,16 +388,23 @@ class LumaOledPlugin(Plugin): draw.point(xy, fill=fill) @action - def polygon(self, xy: Optional[Union[Tuple[int], List[int]]] = None, - fill: Optional[str] = None, outline: Optional[str] = None, - clear: bool = False): + def polygon( + self, + xy: Optional[Union[Tuple[int], List[int]]] = None, + fill: Optional[str] = None, + outline: Optional[str] = None, + clear: bool = False, + ): """ Draw a polygon on the canvas. - :param xy: Sequence of either 2-tuples like [(x, y), (x, y), ...] or numeric values like [x, y, x, y, ...]. + :param xy: Two points defining the bounding box, either as ``[(x0, y0), + (x1, y1)]`` or ``[x0, y0, x1, y1]``. Default: bounding box of the + device. :param fill: Fill color - can be ``black`` or ``white``. :param outline: Outline color - can be ``black`` or ``white``. - :param clear: Set to True if you want to clear the canvas before writing the text (default: False). + :param clear: Set to True if you want to clear the canvas before + writing the text (default: False). """ if clear: self.clear() diff --git a/platypush/plugins/mail/imap/__init__.py b/platypush/plugins/mail/imap/__init__.py index 23dafcb1e..2a57daa4f 100644 --- a/platypush/plugins/mail/imap/__init__.py +++ b/platypush/plugins/mail/imap/__init__.py @@ -11,20 +11,27 @@ from platypush.plugins.mail import MailInPlugin, ServerInfo, Mail class MailImapPlugin(MailInPlugin): """ Plugin to interact with a mail server over IMAP. - - Requires: - - * **imapclient** (``pip install imapclient``) - """ _default_port = 143 _default_ssl_port = 993 - def __init__(self, server: str, port: Optional[int] = None, username: Optional[str] = None, - password: Optional[str] = None, password_cmd: Optional[str] = None, access_token: Optional[str] = None, - oauth_mechanism: Optional[str] = 'XOAUTH2', oauth_vendor: Optional[str] = None, ssl: bool = False, - keyfile: Optional[str] = None, certfile: Optional[str] = None, timeout: Optional[int] = 60, **kwargs): + def __init__( + self, + server: str, + port: Optional[int] = None, + username: Optional[str] = None, + password: Optional[str] = None, + password_cmd: Optional[str] = None, + access_token: Optional[str] = None, + oauth_mechanism: Optional[str] = 'XOAUTH2', + oauth_vendor: Optional[str] = None, + ssl: bool = False, + keyfile: Optional[str] = None, + certfile: Optional[str] = None, + timeout: Optional[int] = 60, + **kwargs + ): """ :param server: Server name/address. :param port: Port (default: 143 for plain, 993 for SSL). @@ -41,21 +48,53 @@ class MailImapPlugin(MailInPlugin): :param timeout: Server connect/read timeout in seconds (default: 60). """ super().__init__(**kwargs) - self.server_info = self._get_server_info(server=server, port=port, username=username, password=password, - password_cmd=password_cmd, ssl=ssl, keyfile=keyfile, certfile=certfile, - access_token=access_token, oauth_mechanism=oauth_mechanism, - oauth_vendor=oauth_vendor, timeout=timeout) + self.server_info = self._get_server_info( + server=server, + port=port, + username=username, + password=password, + password_cmd=password_cmd, + ssl=ssl, + keyfile=keyfile, + certfile=certfile, + access_token=access_token, + oauth_mechanism=oauth_mechanism, + oauth_vendor=oauth_vendor, + timeout=timeout, + ) - def _get_server_info(self, server: Optional[str] = None, port: Optional[int] = None, username: Optional[str] = None, - password: Optional[str] = None, password_cmd: Optional[str] = None, - access_token: Optional[str] = None, oauth_mechanism: Optional[str] = None, - oauth_vendor: Optional[str] = None, ssl: Optional[bool] = None, keyfile: Optional[str] = None, - certfile: Optional[str] = None, timeout: Optional[int] = None, **kwargs) -> ServerInfo: - return super()._get_server_info(server=server, port=port, username=username, password=password, - password_cmd=password_cmd, ssl=ssl, keyfile=keyfile, certfile=certfile, - default_port=self._default_port, default_ssl_port=self._default_ssl_port, - access_token=access_token, oauth_mechanism=oauth_mechanism, - oauth_vendor=oauth_vendor, timeout=timeout) + def _get_server_info( + self, + server: Optional[str] = None, + port: Optional[int] = None, + username: Optional[str] = None, + password: Optional[str] = None, + password_cmd: Optional[str] = None, + access_token: Optional[str] = None, + oauth_mechanism: Optional[str] = None, + oauth_vendor: Optional[str] = None, + ssl: Optional[bool] = None, + keyfile: Optional[str] = None, + certfile: Optional[str] = None, + timeout: Optional[int] = None, + **kwargs + ) -> ServerInfo: + return super()._get_server_info( + server=server, + port=port, + username=username, + password=password, + password_cmd=password_cmd, + ssl=ssl, + keyfile=keyfile, + certfile=certfile, + default_port=self._default_port, + default_ssl_port=self._default_ssl_port, + access_token=access_token, + oauth_mechanism=oauth_mechanism, + oauth_vendor=oauth_vendor, + timeout=timeout, + ) def connect(self, **connect_args) -> IMAPClient: info = self._get_server_info(**connect_args) @@ -64,15 +103,22 @@ class MailImapPlugin(MailInPlugin): if info.ssl: import ssl + context = ssl.create_default_context() context.load_cert_chain(certfile=info.certfile, keyfile=info.keyfile) - client = IMAPClient(host=info.server, port=info.port, ssl=info.ssl, ssl_context=context) + client = IMAPClient( + host=info.server, port=info.port, ssl=info.ssl, ssl_context=context + ) if info.password: client.login(info.username, info.password) elif info.access_token: - client.oauth2_login(info.username, access_token=info.access_token, mech=info.oauth_mechanism, - vendor=info.oauth_vendor) + client.oauth2_login( + info.username, + access_token=info.access_token, + mech=info.oauth_mechanism, + vendor=info.oauth_vendor, + ) return client @@ -81,16 +127,20 @@ class MailImapPlugin(MailInPlugin): folders = [] for line in data: (flags), delimiter, mailbox_name = line - folders.append({ - 'name': mailbox_name, - 'flags': [flag.decode() for flag in flags], - 'delimiter': delimiter.decode(), - }) + folders.append( + { + 'name': mailbox_name, + 'flags': [flag.decode() for flag in flags], + 'delimiter': delimiter.decode(), + } + ) return folders @action - def get_folders(self, folder: str = '', pattern: str = '*', **connect_args) -> List[Dict[str, str]]: + def get_folders( + self, folder: str = '', pattern: str = '*', **connect_args + ) -> List[Dict[str, str]]: """ Get the list of all the folders hosted on the server or those matching a pattern. @@ -126,7 +176,9 @@ class MailImapPlugin(MailInPlugin): return self._get_folders(data) @action - def get_sub_folders(self, folder: str = '', pattern: str = '*', **connect_args) -> List[Dict[str, str]]: + def get_sub_folders( + self, folder: str = '', pattern: str = '*', **connect_args + ) -> List[Dict[str, str]]: """ Get the list of all the sub-folders hosted on the server or those matching a pattern. @@ -166,11 +218,15 @@ class MailImapPlugin(MailInPlugin): return { 'name': imap_addr.name.decode() if imap_addr.name else None, 'route': imap_addr.route.decode() if imap_addr.route else None, - 'email': '{name}@{host}'.format(name=imap_addr.mailbox.decode(), host=imap_addr.host.decode()) + 'email': '{name}@{host}'.format( + name=imap_addr.mailbox.decode(), host=imap_addr.host.decode() + ), } @classmethod - def _parse_addresses(cls, addresses: Optional[Tuple[Address]] = None) -> Dict[str, Dict[str, str]]: + def _parse_addresses( + cls, addresses: Optional[Tuple[Address]] = None + ) -> Dict[str, Dict[str, str]]: ret = {} if addresses: for addr in addresses: @@ -198,8 +254,12 @@ class MailImapPlugin(MailInPlugin): message['cc'] = cls._parse_addresses(envelope.cc) message['date'] = envelope.date message['from'] = cls._parse_addresses(envelope.from_) - message['message_id'] = envelope.message_id.decode() if envelope.message_id else None - message['in_reply_to'] = envelope.in_reply_to.decode() if envelope.in_reply_to else None + message['message_id'] = ( + envelope.message_id.decode() if envelope.message_id else None + ) + message['in_reply_to'] = ( + envelope.in_reply_to.decode() if envelope.in_reply_to else None + ) message['reply_to'] = cls._parse_addresses(envelope.reply_to) message['sender'] = cls._parse_addresses(envelope.sender) message['subject'] = envelope.subject.decode() if envelope.subject else None @@ -208,8 +268,13 @@ class MailImapPlugin(MailInPlugin): return Mail(**message) @action - def search(self, criteria: Union[str, List[str]] = 'ALL', folder: str = 'INBOX', - attributes: Optional[List[str]] = None, **connect_args) -> List[Mail]: + def search( + self, + criteria: Union[str, List[str]] = 'ALL', + folder: str = 'INBOX', + attributes: Optional[List[str]] = None, + **connect_args + ) -> List[Mail]: """ Search for messages on the server that fit the specified criteria. @@ -302,34 +367,48 @@ class MailImapPlugin(MailInPlugin): data = client.fetch(list(ids), attributes) return [ - self._parse_message(msg_id, data[msg_id]) - for msg_id in sorted(data.keys()) + self._parse_message(msg_id, data[msg_id]) for msg_id in sorted(data.keys()) ] @action - def search_unseen_messages(self, folder: str = 'INBOX', **connect_args) -> List[Mail]: + def search_unseen_messages( + self, folder: str = 'INBOX', **connect_args + ) -> List[Mail]: """ Shortcut for :meth:`.search` that returns only the unread messages. """ - return self.search(criteria='UNSEEN', directory=folder, attributes=['ALL'], **connect_args) + return self.search( + criteria='UNSEEN', directory=folder, attributes=['ALL'], **connect_args + ) @action - def search_flagged_messages(self, folder: str = 'INBOX', **connect_args) -> List[Mail]: + def search_flagged_messages( + self, folder: str = 'INBOX', **connect_args + ) -> List[Mail]: """ Shortcut for :meth:`.search` that returns only the flagged/starred messages. """ - return self.search(criteria='Flagged', directory=folder, attributes=['ALL'], **connect_args) + return self.search( + criteria='Flagged', directory=folder, attributes=['ALL'], **connect_args + ) @action - def search_starred_messages(self, folder: str = 'INBOX', **connect_args) -> List[Mail]: + def search_starred_messages( + self, folder: str = 'INBOX', **connect_args + ) -> List[Mail]: """ Shortcut for :meth:`.search` that returns only the flagged/starred messages. """ return self.search_flagged_messages(folder, **connect_args) @action - def sort(self, folder: str = 'INBOX', sort_criteria: Union[str, List[str]] = 'ARRIVAL', - criteria: Union[str, List[str]] = 'ALL', **connect_args) -> List[int]: + def sort( + self, + folder: str = 'INBOX', + sort_criteria: Union[str, List[str]] = 'ARRIVAL', + criteria: Union[str, List[str]] = 'ALL', + **connect_args + ) -> List[int]: """ Return a list of message ids from the currently selected folder, sorted by ``sort_criteria`` and optionally filtered by ``criteria``. Note that SORT is an extension to the IMAP4 standard so it may not be supported by @@ -420,7 +499,13 @@ class MailImapPlugin(MailInPlugin): return [('\\' + flag).encode() for flag in flags] @action - def add_flags(self, messages: List[int], flags: Union[str, List[str]], folder: str = 'INBOX', **connect_args): + def add_flags( + self, + messages: List[int], + flags: Union[str, List[str]], + folder: str = 'INBOX', + **connect_args + ): """ Add a set of flags to the specified set of message IDs. @@ -441,7 +526,13 @@ class MailImapPlugin(MailInPlugin): client.add_flags(messages, self._convert_flags(flags)) @action - def set_flags(self, messages: List[int], flags: Union[str, List[str]], folder: str = 'INBOX', **connect_args): + def set_flags( + self, + messages: List[int], + flags: Union[str, List[str]], + folder: str = 'INBOX', + **connect_args + ): """ Set a set of flags to the specified set of message IDs. @@ -462,7 +553,13 @@ class MailImapPlugin(MailInPlugin): client.set_flags(messages, self._convert_flags(flags)) @action - def remove_flags(self, messages: List[int], flags: Union[str, List[str]], folder: str = 'INBOX', **connect_args): + def remove_flags( + self, + messages: List[int], + flags: Union[str, List[str]], + folder: str = 'INBOX', + **connect_args + ): """ Remove a set of flags to the specified set of message IDs. @@ -494,7 +591,9 @@ class MailImapPlugin(MailInPlugin): return self.add_flags(messages, ['Flagged'], folder=folder, **connect_args) @action - def unflag_messages(self, messages: List[int], folder: str = 'INBOX', **connect_args): + def unflag_messages( + self, messages: List[int], folder: str = 'INBOX', **connect_args + ): """ Remove a flag/star from the specified set of message IDs. @@ -527,7 +626,13 @@ class MailImapPlugin(MailInPlugin): return self.unflag_messages([message], folder=folder, **connect_args) @action - def delete_messages(self, messages: List[int], folder: str = 'INBOX', expunge: bool = True, **connect_args): + def delete_messages( + self, + messages: List[int], + folder: str = 'INBOX', + expunge: bool = True, + **connect_args + ): """ Set a specified set of message IDs as deleted. @@ -542,7 +647,9 @@ class MailImapPlugin(MailInPlugin): self.expunge_messages(folder=folder, messages=messages, **connect_args) @action - def undelete_messages(self, messages: List[int], folder: str = 'INBOX', **connect_args): + def undelete_messages( + self, messages: List[int], folder: str = 'INBOX', **connect_args + ): """ Remove the ``Deleted`` flag from the specified set of message IDs. @@ -553,7 +660,13 @@ class MailImapPlugin(MailInPlugin): return self.remove_flags(messages, ['Deleted'], folder=folder, **connect_args) @action - def copy_messages(self, messages: List[int], dest_folder: str, source_folder: str = 'INBOX', **connect_args): + def copy_messages( + self, + messages: List[int], + dest_folder: str, + source_folder: str = 'INBOX', + **connect_args + ): """ Copy a set of messages IDs from a folder to another. @@ -567,7 +680,13 @@ class MailImapPlugin(MailInPlugin): client.copy(messages, dest_folder) @action - def move_messages(self, messages: List[int], dest_folder: str, source_folder: str = 'INBOX', **connect_args): + def move_messages( + self, + messages: List[int], + dest_folder: str, + source_folder: str = 'INBOX', + **connect_args + ): """ Move a set of messages IDs from a folder to another. @@ -581,7 +700,12 @@ class MailImapPlugin(MailInPlugin): client.move(messages, dest_folder) @action - def expunge_messages(self, folder: str = 'INBOX', messages: Optional[List[int]] = None, **connect_args): + def expunge_messages( + self, + folder: str = 'INBOX', + messages: Optional[List[int]] = None, + **connect_args + ): """ When ``messages`` is not set, remove all the messages from ``folder`` marked as ``Deleted``. diff --git a/platypush/plugins/matrix/__init__.py b/platypush/plugins/matrix/__init__.py index 205da044d..45aeeddc4 100644 --- a/platypush/plugins/matrix/__init__.py +++ b/platypush/plugins/matrix/__init__.py @@ -62,13 +62,6 @@ class MatrixPlugin(AsyncRunnablePlugin): """ Matrix chat integration. - Requires: - - * **matrix-nio** (``pip install 'matrix-nio[e2e]'``) - * **libolm** (on Debian ```apt-get install libolm-devel``, on Arch - ``pacman -S libolm``) - * **async_lru** (``pip install async_lru``) - Note that ``libolm`` and the ``[e2e]`` module are only required if you want E2E encryption support. @@ -100,50 +93,6 @@ class MatrixPlugin(AsyncRunnablePlugin): ``mxc:///`` format. You can either convert them to HTTP through the :meth:`.mxc_to_http` method, or download them through the :meth:`.download` method. - - Triggers: - - * :class:`platypush.message.event.matrix.MatrixMessageEvent`: when a - message is received. - * :class:`platypush.message.event.matrix.MatrixMessageImageEvent`: when a - message containing an image is received. - * :class:`platypush.message.event.matrix.MatrixMessageAudioEvent`: when a - message containing an audio file is received. - * :class:`platypush.message.event.matrix.MatrixMessageVideoEvent`: when a - message containing a video file is received. - * :class:`platypush.message.event.matrix.MatrixMessageFileEvent`: when a - message containing a generic file is received. - * :class:`platypush.message.event.matrix.MatrixSyncEvent`: when the - startup synchronization has been completed and the plugin is ready to - use. - * :class:`platypush.message.event.matrix.MatrixRoomCreatedEvent`: when - a room is created. - * :class:`platypush.message.event.matrix.MatrixRoomJoinEvent`: when a - user joins a room. - * :class:`platypush.message.event.matrix.MatrixRoomLeaveEvent`: when a - user leaves a room. - * :class:`platypush.message.event.matrix.MatrixRoomInviteEvent`: when - the user is invited to a room. - * :class:`platypush.message.event.matrix.MatrixRoomTopicChangedEvent`: - when the topic/title of a room changes. - * :class:`platypush.message.event.matrix.MatrixCallInviteEvent`: when - the user is invited to a call. - * :class:`platypush.message.event.matrix.MatrixCallAnswerEvent`: when a - called user wishes to pick the call. - * :class:`platypush.message.event.matrix.MatrixCallHangupEvent`: when a - called user exits the call. - * :class:`platypush.message.event.matrix.MatrixEncryptedMessageEvent`: - when a message is received but the client doesn't have the E2E keys - to decrypt it, or encryption has not been enabled. - * :class:`platypush.message.event.matrix.MatrixRoomTypingStartEvent`: - when a user in a room starts typing. - * :class:`platypush.message.event.matrix.MatrixRoomTypingStopEvent`: - when a user in a room stops typing. - * :class:`platypush.message.event.matrix.MatrixRoomSeenReceiptEvent`: - when the last message seen by a user in a room is updated. - * :class:`platypush.message.event.matrix.MatrixUserPresenceEvent`: - when a user comes online or goes offline. - """ def __init__( diff --git a/platypush/plugins/matrix/manifest.yaml b/platypush/plugins/matrix/manifest.yaml index 9a3970847..5a771c1c4 100644 --- a/platypush/plugins/matrix/manifest.yaml +++ b/platypush/plugins/matrix/manifest.yaml @@ -1,57 +1,38 @@ manifest: - events: - platypush.message.event.matrix.MatrixMessageEvent: when a message is - received. - platypush.message.event.matrix.MatrixMessageImageEvent: when a message - containing an image is received. - platypush.message.event.matrix.MatrixMessageAudioEvent: when a message - containing an audio file is received. - platypush.message.event.matrix.MatrixMessageVideoEvent: when a message - containing a video file is received. - platypush.message.event.matrix.MatrixMessageFileEvent: when a message - containing a generic file is received. - platypush.message.event.matrix.MatrixSyncEvent: when the startup - synchronization has been completed and the plugin is ready to use. - platypush.message.event.matrix.MatrixRoomCreatedEvent: when a room is - created. - platypush.message.event.matrix.MatrixRoomJoinEvent: when a user joins a - room. - platypush.message.event.matrix.MatrixRoomLeaveEvent: when a user leaves a - room. - platypush.message.event.matrix.MatrixRoomInviteEvent: when the user is - invited to a room. - platypush.message.event.matrix.MatrixRoomTopicChangedEvent: when the - topic/title of a room changes. - platypush.message.event.matrix.MatrixCallInviteEvent: when the user is - invited to a call. - platypush.message.event.matrix.MatrixCallAnswerEvent: when a called user - wishes to pick the call. - platypush.message.event.matrix.MatrixCallHangupEvent: when a called user - exits the call. - platypush.message.event.matrix.MatrixEncryptedMessageEvent: when a message - is received but the client doesn't have the E2E keys to decrypt it, or - encryption has not been enabled. - platypush.message.event.matrix.MatrixRoomTypingStartEvent: when a user in a - room starts typing. - platypush.message.event.matrix.MatrixRoomTypingStopEvent: when a user in a - room stops typing. - platypush.message.event.matrix.MatrixRoomSeenReceiptEvent: when the last - message seen by a user in a room is updated. - platypush.message.event.matrix.MatrixUserPresenceEvent: when a user comes - online or goes offline. - apk: - - olm-dev - apt: - - libolm-devel - - python3-async-lru - dnf: - - libolm-devel - - python-async-lru - pacman: - - libolm - - python-async-lru - pip: - - matrix-nio[e2e] - - async_lru - package: platypush.plugins.matrix type: plugin + package: platypush.plugins.matrix + events: + - platypush.message.event.matrix.MatrixMessageEvent + - platypush.message.event.matrix.MatrixMessageImageEvent + - platypush.message.event.matrix.MatrixMessageAudioEvent + - platypush.message.event.matrix.MatrixMessageVideoEvent + - platypush.message.event.matrix.MatrixMessageFileEvent + - platypush.message.event.matrix.MatrixSyncEvent + - platypush.message.event.matrix.MatrixRoomCreatedEvent + - platypush.message.event.matrix.MatrixRoomJoinEvent + - platypush.message.event.matrix.MatrixRoomLeaveEvent + - platypush.message.event.matrix.MatrixRoomInviteEvent + - platypush.message.event.matrix.MatrixRoomTopicChangedEvent + - platypush.message.event.matrix.MatrixCallInviteEvent + - platypush.message.event.matrix.MatrixCallAnswerEvent + - platypush.message.event.matrix.MatrixCallHangupEvent + - platypush.message.event.matrix.MatrixEncryptedMessageEvent + - platypush.message.event.matrix.MatrixRoomTypingStartEvent + - platypush.message.event.matrix.MatrixRoomTypingStopEvent + - platypush.message.event.matrix.MatrixRoomSeenReceiptEvent + - platypush.message.event.matrix.MatrixUserPresenceEvent + install: + apk: + - olm-dev + apt: + - libolm-devel + - python3-async-lru + dnf: + - libolm-devel + - python-async-lru + pacman: + - libolm + - python-async-lru + pip: + - matrix-nio[e2e] + - async_lru diff --git a/platypush/plugins/media/__init__.py b/platypush/plugins/media/__init__.py index 679498570..e12b2c41a 100644 --- a/platypush/plugins/media/__init__.py +++ b/platypush/plugins/media/__init__.py @@ -27,15 +27,6 @@ class MediaPlugin(Plugin, ABC): """ Generic plugin to interact with a media player. - Requires: - - * A media player installed (supported so far: mplayer, vlc, mpv, omxplayer, chromecast) - * **python-libtorrent** (``pip install python-libtorrent``), optional, for torrent support over native - library - * *rtorrent* installed - optional, for torrent support over rtorrent - * **youtube-dl** installed on your system (see your distro instructions), optional for YouTube support - * **ffmpeg**,optional, to get media files metadata - To start the local media stream service over HTTP you will also need the :class:`platypush.backend.http.HttpBackend` backend enabled. """ diff --git a/platypush/plugins/media/chromecast/__init__.py b/platypush/plugins/media/chromecast/__init__.py index 7ba9cb1de..1092edca4 100644 --- a/platypush/plugins/media/chromecast/__init__.py +++ b/platypush/plugins/media/chromecast/__init__.py @@ -6,13 +6,23 @@ from platypush.context import get_bus from platypush.plugins import action from platypush.plugins.media import MediaPlugin from platypush.utils import get_mime_type -from platypush.message.event.media import MediaPlayEvent, MediaPlayRequestEvent, \ - MediaStopEvent, MediaPauseEvent, NewPlayingMediaEvent, MediaVolumeChangedEvent, MediaSeekEvent +from platypush.message.event.media import ( + MediaPlayEvent, + MediaPlayRequestEvent, + MediaStopEvent, + MediaPauseEvent, + NewPlayingMediaEvent, + MediaVolumeChangedEvent, + MediaSeekEvent, +) def convert_status(status): - attrs = [a for a in dir(status) if not a.startswith('_') - and not callable(getattr(status, a))] + attrs = [ + a + for a in dir(status) + if not a.startswith('_') and not callable(getattr(status, a)) + ] renamed_attrs = { 'current_time': 'position', @@ -60,9 +70,6 @@ class MediaChromecastPlugin(MediaPlugin): * YouTube URLs * Plex (through ``media.plex`` plugin, experimental) - Requires: - - * **pychromecast** (``pip install pychromecast``) """ STREAM_TYPE_UNKNOWN = "UNKNOWN" @@ -80,20 +87,35 @@ class MediaChromecastPlugin(MediaPlugin): status = convert_status(status) if status.get('url') and status.get('url') != self.status.get('url'): - post_event(NewPlayingMediaEvent, resource=status['url'], - title=status.get('title'), device=self.name) + post_event( + NewPlayingMediaEvent, + resource=status['url'], + title=status.get('title'), + device=self.name, + ) if status.get('state') != self.status.get('state'): if status.get('state') == 'play': post_event(MediaPlayEvent, resource=status['url'], device=self.name) elif status.get('state') == 'pause': - post_event(MediaPauseEvent, resource=status['url'], device=self.name) + post_event( + MediaPauseEvent, resource=status['url'], device=self.name + ) elif status.get('state') in ['stop', 'idle']: post_event(MediaStopEvent, device=self.name) if status.get('volume') != self.status.get('volume'): - post_event(MediaVolumeChangedEvent, volume=status.get('volume'), device=self.name) + post_event( + MediaVolumeChangedEvent, + volume=status.get('volume'), + device=self.name, + ) # noinspection PyUnresolvedReferences - if abs(status.get('position') - self.status.get('position')) > time.time() - self.last_status_timestamp + 5: - post_event(MediaSeekEvent, position=status.get('position'), device=self.name) + if ( + abs(status.get('position') - self.status.get('position')) + > time.time() - self.last_status_timestamp + 5 + ): + post_event( + MediaSeekEvent, position=status.get('position'), device=self.name + ) self.last_status_timestamp = time.time() self.status = status @@ -127,6 +149,7 @@ class MediaChromecastPlugin(MediaPlugin): @staticmethod def _get_chromecasts(*args, **kwargs): import pychromecast + chromecasts = pychromecast.get_chromecasts(*args, **kwargs) if isinstance(chromecasts, tuple): return chromecasts[0] @@ -134,13 +157,14 @@ class MediaChromecastPlugin(MediaPlugin): @staticmethod def _get_device_property(cc, prop: str): - if hasattr(cc, 'device'): # Previous pychromecast API + if hasattr(cc, 'device'): # Previous pychromecast API return getattr(cc.device, prop) return getattr(cc.cast_info, prop) @action - def get_chromecasts(self, tries=2, retry_wait=10, timeout=60, - blocking=True, callback=None): + def get_chromecasts( + self, tries=2, retry_wait=10, timeout=60, blocking=True, callback=None + ): """ Get the list of Chromecast devices @@ -162,40 +186,51 @@ class MediaChromecastPlugin(MediaPlugin): will be invoked when a new device is discovered :type callback: func """ - self.chromecasts.update({ - self._get_device_property(cast, 'friendly_name'): cast - for cast in self._get_chromecasts(tries=tries, retry_wait=retry_wait, - timeout=timeout, blocking=blocking, - callback=callback) - }) + self.chromecasts.update( + { + self._get_device_property(cast, 'friendly_name'): cast + for cast in self._get_chromecasts( + tries=tries, + retry_wait=retry_wait, + timeout=timeout, + blocking=blocking, + callback=callback, + ) + } + ) for name, cast in self.chromecasts.items(): self._update_listeners(name, cast) - return [{ - 'type': cc.cast_type, - 'name': cc.name, - 'manufacturer': self._get_device_property(cc, 'manufacturer'), - 'model_name': cc.model_name, - 'uuid': str(cc.uuid), - 'address': cc.host if hasattr(cc, 'host') else cc.uri.split(':')[0], - 'port': cc.port if hasattr(cc, 'port') else int(cc.uri.split(':')[1]), - - 'status': ({ - 'app': { - 'id': cc.app_id, - 'name': cc.app_display_name, - }, - - 'media': self.status(cc.name).output, - 'is_active_input': cc.status.is_active_input, - 'is_stand_by': cc.status.is_stand_by, - 'is_idle': cc.is_idle, - 'namespaces': cc.status.namespaces, - 'volume': round(100*cc.status.volume_level, 2), - 'muted': cc.status.volume_muted, - } if cc.status else {}), - } for cc in self.chromecasts.values()] + return [ + { + 'type': cc.cast_type, + 'name': cc.name, + 'manufacturer': self._get_device_property(cc, 'manufacturer'), + 'model_name': cc.model_name, + 'uuid': str(cc.uuid), + 'address': cc.host if hasattr(cc, 'host') else cc.uri.split(':')[0], + 'port': cc.port if hasattr(cc, 'port') else int(cc.uri.split(':')[1]), + 'status': ( + { + 'app': { + 'id': cc.app_id, + 'name': cc.app_display_name, + }, + 'media': self.status(cc.name).output, + 'is_active_input': cc.status.is_active_input, + 'is_stand_by': cc.status.is_stand_by, + 'is_idle': cc.is_idle, + 'namespaces': cc.status.namespaces, + 'volume': round(100 * cc.status.volume_level, 2), + 'muted': cc.status.volume_muted, + } + if cc.status + else {} + ), + } + for cc in self.chromecasts.values() + ] def _update_listeners(self, name, cast): if name not in self._media_listeners: @@ -205,22 +240,27 @@ class MediaChromecastPlugin(MediaPlugin): def get_chromecast(self, chromecast=None, n_tries=2): import pychromecast + if isinstance(chromecast, pychromecast.Chromecast): return chromecast if not chromecast: if not self.chromecast: - raise RuntimeError('No Chromecast specified nor default Chromecast configured') + raise RuntimeError( + 'No Chromecast specified nor default Chromecast configured' + ) chromecast = self.chromecast if chromecast not in self.chromecasts: casts = {} while n_tries > 0: n_tries -= 1 - casts.update({ - self._get_device_property(cast, 'friendly_name'): cast - for cast in self._get_chromecasts() - }) + casts.update( + { + self._get_device_property(cast, 'friendly_name'): cast + for cast in self._get_chromecasts() + } + ) if chromecast in casts: self.chromecasts.update(casts) @@ -234,11 +274,21 @@ class MediaChromecastPlugin(MediaPlugin): return cast @action - def play(self, resource, content_type=None, chromecast=None, title=None, - image_url=None, autoplay=True, current_time=0, - stream_type=STREAM_TYPE_BUFFERED, subtitles=None, - subtitles_lang='en-US', subtitles_mime='text/vtt', - subtitle_id=1): + def play( + self, + resource, + content_type=None, + chromecast=None, + title=None, + image_url=None, + autoplay=True, + current_time=0, + stream_type=STREAM_TYPE_BUFFERED, + subtitles=None, + subtitles_lang='en-US', + subtitles_mime='text/vtt', + subtitle_id=1, + ): """ Cast media to a visible Chromecast @@ -281,6 +331,7 @@ class MediaChromecastPlugin(MediaPlugin): """ from pychromecast.controllers.youtube import YouTubeController + if not chromecast: chromecast = self.chromecast @@ -291,8 +342,7 @@ class MediaChromecastPlugin(MediaPlugin): yt = self._get_youtube_url(resource) if yt: - self.logger.info('Playing YouTube video {} on {}'.format( - yt, chromecast)) + self.logger.info('Playing YouTube video {} on {}'.format(yt, chromecast)) hndl = YouTubeController() cast.register_handler(hndl) @@ -305,21 +355,29 @@ class MediaChromecastPlugin(MediaPlugin): content_type = get_mime_type(resource) if not content_type: - raise RuntimeError('content_type required to process media {}'. - format(resource)) + raise RuntimeError( + 'content_type required to process media {}'.format(resource) + ) - if not resource.startswith('http://') and \ - not resource.startswith('https://'): + if not resource.startswith('http://') and not resource.startswith('https://'): resource = self.start_streaming(resource).output['url'] self.logger.info('HTTP media stream started on {}'.format(resource)) self.logger.info('Playing {} on {}'.format(resource, chromecast)) - mc.play_media(resource, content_type, title=title, thumb=image_url, - current_time=current_time, autoplay=autoplay, - stream_type=stream_type, subtitles=subtitles, - subtitles_lang=subtitles_lang, - subtitles_mime=subtitles_mime, subtitle_id=subtitle_id) + mc.play_media( + resource, + content_type, + title=title, + thumb=image_url, + current_time=current_time, + autoplay=autoplay, + stream_type=stream_type, + subtitles=subtitles, + subtitles_lang=subtitles_lang, + subtitles_mime=subtitles_mime, + subtitle_id=subtitle_id, + ) if subtitles: mc.register_status_listener(self.SubtitlesAsyncHandler(mc, subtitle_id)) @@ -393,7 +451,7 @@ class MediaChromecastPlugin(MediaPlugin): cast = self.get_chromecast(chromecast or self.chromecast) mc = cast.media_controller if mc.status.current_time: - mc.seek(mc.status.current_time-offset) + mc.seek(mc.status.current_time - offset) cast.wait() return self.status(chromecast=chromecast) @@ -403,27 +461,34 @@ class MediaChromecastPlugin(MediaPlugin): cast = self.get_chromecast(chromecast or self.chromecast) mc = cast.media_controller if mc.status.current_time: - mc.seek(mc.status.current_time+offset) + mc.seek(mc.status.current_time + offset) cast.wait() return self.status(chromecast=chromecast) @action def is_playing(self, chromecast=None): - return self.get_chromecast(chromecast or self.chromecast).media_controller.is_playing + return self.get_chromecast( + chromecast or self.chromecast + ).media_controller.is_playing @action def is_paused(self, chromecast=None): - return self.get_chromecast(chromecast or self.chromecast).media_controller.is_paused + return self.get_chromecast( + chromecast or self.chromecast + ).media_controller.is_paused @action def is_idle(self, chromecast=None): - return self.get_chromecast(chromecast or self.chromecast).media_controller.is_idle + return self.get_chromecast( + chromecast or self.chromecast + ).media_controller.is_idle @action def list_subtitles(self, chromecast=None): - return self.get_chromecast(chromecast or self.chromecast) \ - .media_controller.subtitle_tracks + return self.get_chromecast( + chromecast or self.chromecast + ).media_controller.subtitle_tracks @action def enable_subtitles(self, chromecast=None, track_id=None): @@ -535,7 +600,7 @@ class MediaChromecastPlugin(MediaPlugin): chromecast = chromecast or self.chromecast cast = self.get_chromecast(chromecast) - cast.set_volume(volume/100) + cast.set_volume(volume / 100) cast.wait() status = self.status(chromecast=chromecast) status.output['volume'] = volume diff --git a/platypush/plugins/media/gstreamer/__init__.py b/platypush/plugins/media/gstreamer/__init__.py index 44e6bbf57..eab255525 100644 --- a/platypush/plugins/media/gstreamer/__init__.py +++ b/platypush/plugins/media/gstreamer/__init__.py @@ -11,20 +11,6 @@ from platypush.plugins.media.gstreamer.model import MediaPipeline class MediaGstreamerPlugin(MediaPlugin): """ Plugin to play media over GStreamer. - - Requires: - - * **gst-python** - * **pygobject** - - On Debian and derived systems: - - * ``[sudo] apt-get install python3-gi python3-gst-1.0`` - - On Arch and derived systems: - - * ``[sudo] pacman -S gst-python`` - """ def __init__(self, sink: Optional[str] = None, *args, **kwargs): diff --git a/platypush/plugins/media/kodi/__init__.py b/platypush/plugins/media/kodi/__init__.py index b56694814..784bb912c 100644 --- a/platypush/plugins/media/kodi/__init__.py +++ b/platypush/plugins/media/kodi/__init__.py @@ -5,21 +5,30 @@ import time from platypush.context import get_bus from platypush.plugins import action from platypush.plugins.media import MediaPlugin, PlayerState -from platypush.message.event.media import MediaPlayEvent, MediaPauseEvent, MediaStopEvent, \ - MediaSeekEvent, MediaVolumeChangedEvent +from platypush.message.event.media import ( + MediaPlayEvent, + MediaPauseEvent, + MediaStopEvent, + MediaSeekEvent, + MediaVolumeChangedEvent, +) class MediaKodiPlugin(MediaPlugin): """ Plugin to interact with a Kodi media player instance - - Requires: - - * **kodi-json** (``pip install kodi-json``) """ # noinspection HttpUrlsUsage - def __init__(self, host, http_port=8080, websocket_port=9090, username=None, password=None, **kwargs): + def __init__( + self, + host, + http_port=8080, + websocket_port=9090, + username=None, + password=None, + **kwargs, + ): """ :param host: Kodi host name or IP :type host: str @@ -79,14 +88,18 @@ class MediaKodiPlugin(MediaPlugin): try: import websocket except ImportError: - self.logger.warning('websocket-client is not installed, Kodi events will be disabled') + self.logger.warning( + 'websocket-client is not installed, Kodi events will be disabled' + ) return if not self._ws: - self._ws = websocket.WebSocketApp(self.websocket_url, - on_message=self._on_ws_msg(), - on_error=self._on_ws_error(), - on_close=self._on_ws_close()) + self._ws = websocket.WebSocketApp( + self.websocket_url, + on_message=self._on_ws_msg(), + on_error=self._on_ws_error(), + on_close=self._on_ws_close(), + ) self.logger.info('Kodi websocket interface for events started') self._ws.run_forever() @@ -107,20 +120,30 @@ class MediaKodiPlugin(MediaPlugin): if method == 'Player.OnPlay': item = msg.get('params', {}).get('data', {}).get('item', {}) player = msg.get('params', {}).get('data', {}).get('player', {}) - self._post_event(MediaPlayEvent, player_id=player.get('playerid'), - title=item.get('title'), media_type=item.get('type')) + self._post_event( + MediaPlayEvent, + player_id=player.get('playerid'), + title=item.get('title'), + media_type=item.get('type'), + ) elif method == 'Player.OnPause': item = msg.get('params', {}).get('data', {}).get('item', {}) player = msg.get('params', {}).get('data', {}).get('player', {}) - self._post_event(MediaPauseEvent, player_id=player.get('playerid'), - title=item.get('title'), media_type=item.get('type')) + self._post_event( + MediaPauseEvent, + player_id=player.get('playerid'), + title=item.get('title'), + media_type=item.get('type'), + ) elif method == 'Player.OnStop': player = msg.get('params', {}).get('data', {}).get('player', {}) self._post_event(MediaStopEvent, player_id=player.get('playerid')) elif method == 'Player.OnSeek': player = msg.get('params', {}).get('data', {}).get('player', {}) position = self._time_obj_to_pos(player.get('seekoffset')) - self._post_event(MediaSeekEvent, position=position, player_id=player.get('playerid')) + self._post_event( + MediaSeekEvent, position=position, player_id=player.get('playerid') + ) elif method == 'Application.OnVolumeChanged': volume = msg.get('params', {}).get('data', {}).get('volume') self._post_event(MediaVolumeChangedEvent, volume=volume) @@ -131,6 +154,7 @@ class MediaKodiPlugin(MediaPlugin): def hndl(*args): error = args[1] if len(args) > 1 else args[0] self.logger.warning("Kodi websocket connection error: {}".format(error)) + return hndl def _on_ws_close(self): @@ -160,8 +184,15 @@ class MediaKodiPlugin(MediaPlugin): try: resource = self.get_youtube_url(youtube_id).output except Exception as e: - self.logger.warning('youtube-dl error, falling back to Kodi YouTube plugin: {}'.format(str(e))) - resource = 'plugin://plugin.video.youtube/?action=play_video&videoid=' + youtube_id + self.logger.warning( + 'youtube-dl error, falling back to Kodi YouTube plugin: {}'.format( + str(e) + ) + ) + resource = ( + 'plugin://plugin.video.youtube/?action=play_video&videoid=' + + youtube_id + ) if resource.startswith('file://'): resource = resource[7:] @@ -295,27 +326,38 @@ class MediaKodiPlugin(MediaPlugin): @action def get_volume(self, *args, **kwargs): - result = self._get_kodi().Application.GetProperties( - properties=['volume']) + result = self._get_kodi().Application.GetProperties(properties=['volume']) return result.get('result'), result.get('error') @action def volup(self, step=10.0, *args, **kwargs): - """ Volume up (default: +10%) """ - volume = self._get_kodi().Application.GetProperties( - properties=['volume']).get('result', {}).get('volume') + """Volume up (default: +10%)""" + volume = ( + self._get_kodi() + .Application.GetProperties(properties=['volume']) + .get('result', {}) + .get('volume') + ) - result = self._get_kodi().Application.SetVolume(volume=int(min(volume+step, 100))) + result = self._get_kodi().Application.SetVolume( + volume=int(min(volume + step, 100)) + ) return self._build_result(result) @action def voldown(self, step=10.0, *args, **kwargs): - """ Volume down (default: -10%) """ - volume = self._get_kodi().Application.GetProperties( - properties=['volume']).get('result', {}).get('volume') + """Volume down (default: -10%)""" + volume = ( + self._get_kodi() + .Application.GetProperties(properties=['volume']) + .get('result', {}) + .get('volume') + ) - result = self._get_kodi().Application.SetVolume(volume=int(max(volume-step, 0))) + result = self._get_kodi().Application.SetVolume( + volume=int(max(volume - step, 0)) + ) return self._build_result(result) @action @@ -336,8 +378,12 @@ class MediaKodiPlugin(MediaPlugin): Mute/unmute the application """ - muted = self._get_kodi().Application.GetProperties( - properties=['muted']).get('result', {}).get('muted') + muted = ( + self._get_kodi() + .Application.GetProperties(properties=['muted']) + .get('result', {}) + .get('muted') + ) result = self._get_kodi().Application.SetMute(mute=(not muted)) return self._build_result(result) @@ -429,8 +475,12 @@ class MediaKodiPlugin(MediaPlugin): Set/unset fullscreen mode """ - fullscreen = self._get_kodi().GUI.GetProperties( - properties=['fullscreen']).get('result', {}).get('fullscreen') + fullscreen = ( + self._get_kodi() + .GUI.GetProperties(properties=['fullscreen']) + .get('result', {}) + .get('fullscreen') + ) result = self._get_kodi().GUI.SetFullscreen(fullscreen=(not fullscreen)) return result.get('result'), result.get('error') @@ -447,12 +497,16 @@ class MediaKodiPlugin(MediaPlugin): return None, 'No active players found' if shuffle is None: - shuffle = self._get_kodi().Player.GetProperties( - playerid=player_id, - properties=['shuffled']).get('result', {}).get('shuffled') + shuffle = ( + self._get_kodi() + .Player.GetProperties(playerid=player_id, properties=['shuffled']) + .get('result', {}) + .get('shuffled') + ) result = self._get_kodi().Player.SetShuffle( - playerid=player_id, shuffle=(not shuffle)) + playerid=player_id, shuffle=(not shuffle) + ) return result.get('result'), result.get('error') @action @@ -467,21 +521,24 @@ class MediaKodiPlugin(MediaPlugin): return None, 'No active players found' if repeat is None: - repeat = self._get_kodi().Player.GetProperties( - playerid=player_id, - properties=['repeat']).get('result', {}).get('repeat') + repeat = ( + self._get_kodi() + .Player.GetProperties(playerid=player_id, properties=['repeat']) + .get('result', {}) + .get('repeat') + ) result = self._get_kodi().Player.SetRepeat( - playerid=player_id, - repeat='off' if repeat in ('one','all') else 'off') + playerid=player_id, repeat='off' if repeat in ('one', 'all') else 'off' + ) return result.get('result'), result.get('error') @staticmethod def _time_pos_to_obj(t): - hours = int(t/3600) - minutes = int((t - hours*3600)/60) - seconds = t - hours*3600 - minutes*60 + hours = int(t / 3600) + minutes = int((t - hours * 3600) / 60) + seconds = t - hours * 3600 - minutes * 60 milliseconds = t - int(t) return { @@ -493,8 +550,12 @@ class MediaKodiPlugin(MediaPlugin): @staticmethod def _time_obj_to_pos(t): - return t.get('hours', 0) * 3600 + t.get('minutes', 0) * 60 + \ - t.get('seconds', 0) + t.get('milliseconds', 0)/1000 + return ( + t.get('hours', 0) * 3600 + + t.get('minutes', 0) * 60 + + t.get('seconds', 0) + + t.get('milliseconds', 0) / 1000 + ) @action def seek(self, position, player_id=None, *args, **kwargs): @@ -541,8 +602,12 @@ class MediaKodiPlugin(MediaPlugin): if player_id is None: return None, 'No active players found' - position = self._get_kodi().Player.GetProperties( - playerid=player_id, properties=['time']).get('result', {}).get('time', {}) + position = ( + self._get_kodi() + .Player.GetProperties(playerid=player_id, properties=['time']) + .get('result', {}) + .get('time', {}) + ) position = self._time_obj_to_pos(position) return self.seek(player_id=player_id, position=position) @@ -562,8 +627,12 @@ class MediaKodiPlugin(MediaPlugin): if player_id is None: return None, 'No active players found' - position = self._get_kodi().Player.GetProperties( - playerid=player_id, properties=['time']).get('result', {}).get('time', {}) + position = ( + self._get_kodi() + .Player.GetProperties(playerid=player_id, properties=['time']) + .get('result', {}) + .get('time', {}) + ) position = self._time_obj_to_pos(position) return self.seek(player_id=player_id, position=position) @@ -609,7 +678,9 @@ class MediaKodiPlugin(MediaPlugin): return ret ret['state'] = PlayerState.STOP.value - app = kodi.Application.GetProperties(properties=list(set(app_props.values()))).get('result', {}) + app = kodi.Application.GetProperties( + properties=list(set(app_props.values())) + ).get('result', {}) for status_prop, kodi_prop in app_props.items(): ret[status_prop] = app.get(kodi_prop) @@ -628,15 +699,20 @@ class MediaKodiPlugin(MediaPlugin): if player_id is None: return ret - media = kodi.Player.GetItem(playerid=player_id, - properties=list(set(media_props.values()))).get('result', {}).get('item', {}) + media = ( + kodi.Player.GetItem( + playerid=player_id, properties=list(set(media_props.values())) + ) + .get('result', {}) + .get('item', {}) + ) for status_prop, kodi_prop in media_props.items(): ret[status_prop] = media.get(kodi_prop) player_info = kodi.Player.GetProperties( - playerid=player_id, - properties=list(set(player_props.values()))).get('result', {}) + playerid=player_id, properties=list(set(player_props.values())) + ).get('result', {}) for status_prop, kodi_prop in player_props.items(): ret[status_prop] = player_info.get(kodi_prop) @@ -647,7 +723,11 @@ class MediaKodiPlugin(MediaPlugin): if ret['position']: ret['position'] = self._time_obj_to_pos(ret['position']) - ret['state'] = PlayerState.PAUSE.value if player_info.get('speed', 0) == 0 else PlayerState.PLAY.value + ret['state'] = ( + PlayerState.PAUSE.value + if player_info.get('speed', 0) == 0 + else PlayerState.PLAY.value + ) return ret def toggle_subtitles(self, *args, **kwargs): diff --git a/platypush/plugins/media/mplayer/__init__.py b/platypush/plugins/media/mplayer/__init__.py index f4219dca3..79c1fdfa3 100644 --- a/platypush/plugins/media/mplayer/__init__.py +++ b/platypush/plugins/media/mplayer/__init__.py @@ -8,8 +8,13 @@ import time from platypush.context import get_bus from platypush.message.response import Response from platypush.plugins.media import PlayerState, MediaPlugin -from platypush.message.event.media import MediaPlayEvent, MediaPlayRequestEvent, \ - MediaPauseEvent, MediaStopEvent, NewPlayingMediaEvent +from platypush.message.event.media import ( + MediaPlayEvent, + MediaPlayRequestEvent, + MediaPauseEvent, + MediaStopEvent, + NewPlayingMediaEvent, +) from platypush.plugins import action from platypush.utils import find_bins_in_path @@ -17,21 +22,29 @@ from platypush.utils import find_bins_in_path class MediaMplayerPlugin(MediaPlugin): """ - Plugin to control MPlayer instances - - Requires: - - * **mplayer** executable on your system + Plugin to control MPlayer instances. """ _mplayer_default_communicate_timeout = 0.5 - _mplayer_bin_default_args = ['-slave', '-quiet', '-idle', '-input', - 'nodefault-bindings', '-noconfig', 'all'] + _mplayer_bin_default_args = [ + '-slave', + '-quiet', + '-idle', + '-input', + 'nodefault-bindings', + '-noconfig', + 'all', + ] - def __init__(self, mplayer_bin=None, - mplayer_timeout=_mplayer_default_communicate_timeout, - args=None, *argv, **kwargs): + def __init__( + self, + mplayer_bin=None, + mplayer_timeout=_mplayer_default_communicate_timeout, + args=None, + *argv, + **kwargs, + ): """ Create the MPlayer wrapper. Note that the plugin methods are populated dynamically by introspecting the mplayer executable. You can verify the @@ -69,17 +82,22 @@ class MediaMplayerPlugin(MediaPlugin): bins = find_bins_in_path(bin_name) if not bins: - raise RuntimeError('MPlayer executable not specified and not ' + - 'found in your PATH. Make sure that mplayer' + - 'is either installed or configured') + raise RuntimeError( + 'MPlayer executable not specified and not ' + + 'found in your PATH. Make sure that mplayer' + + 'is either installed or configured' + ) self.mplayer_bin = bins[0] else: mplayer_bin = os.path.expanduser(mplayer_bin) - if not (os.path.isfile(mplayer_bin) - and (os.name == 'nt' or os.access(mplayer_bin, os.X_OK))): - raise RuntimeError('{} is does not exist or is not a valid ' + - 'executable file'.format(mplayer_bin)) + if not ( + os.path.isfile(mplayer_bin) + and (os.name == 'nt' or os.access(mplayer_bin, os.X_OK)) + ): + raise RuntimeError( + f'{mplayer_bin} is does not exist or is not a valid executable file' + ) self.mplayer_bin = mplayer_bin @@ -88,8 +106,9 @@ class MediaMplayerPlugin(MediaPlugin): try: self._player.terminate() except Exception as e: - self.logger.debug('Failed to quit mplayer before _exec: {}'. - format(str(e))) + self.logger.debug( + 'Failed to quit mplayer before _exec: {}'.format(str(e)) + ) mplayer_args = mplayer_args or [] args = [self.mplayer_bin] + self._mplayer_bin_default_args @@ -109,11 +128,14 @@ class MediaMplayerPlugin(MediaPlugin): threading.Thread(target=self._process_monitor()).start() def _build_actions(self): - """ Populates the actions list by introspecting the mplayer executable """ + """Populates the actions list by introspecting the mplayer executable""" self._actions = {} - mplayer = subprocess.Popen([self.mplayer_bin, '-input', 'cmdlist'], - stdin=subprocess.PIPE, stdout=subprocess.PIPE) + mplayer = subprocess.Popen( + [self.mplayer_bin, '-input', 'cmdlist'], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + ) def args_pprint(txt): lc = txt.lower() @@ -133,8 +155,9 @@ class MediaMplayerPlugin(MediaPlugin): arguments = ', '.join([args_pprint(a) for a in args]) self._actions[cmd_name] = '{}({})'.format(cmd_name, arguments) - def _exec(self, cmd, *args, mplayer_args=None, prefix=None, - wait_for_response=False): + def _exec( + self, cmd, *args, mplayer_args=None, prefix=None, wait_for_response=False + ): cmd_name = cmd response = None @@ -146,29 +169,32 @@ class MediaMplayerPlugin(MediaPlugin): cmd = '{}{}{}{}\n'.format( prefix + ' ' if prefix else '', - cmd_name, ' ' if args else '', - ' '.join(repr(a) for a in args)).encode() + cmd_name, + ' ' if args else '', + ' '.join(repr(a) for a in args), + ).encode() if not self._player: - self.logger.warning('Cannot send command {}: player unavailable'.format(cmd)) + self.logger.warning( + 'Cannot send command {}: player unavailable'.format(cmd) + ) return self._player.stdin.write(cmd) self._player.stdin.flush() - if cmd_name == 'loadfile' or cmd_name == 'loadlist': + if cmd_name in {'loadfile', 'loadlist'}: self._post_event(NewPlayingMediaEvent, resource=args[0]) elif cmd_name == 'pause': self._post_event(MediaPauseEvent) - elif cmd_name == 'quit' or cmd_name == 'stop': - if cmd_name == 'quit': - self._player.terminate() - self._player.wait() - try: - self._player.kill() - except Exception: - pass - self._player = None + elif cmd_name == 'quit': + self._player.terminate() + self._player.wait() + try: + self._player.kill() + except Exception: + pass + self._player = None if not wait_for_response: return @@ -217,8 +243,10 @@ class MediaMplayerPlugin(MediaPlugin): @action def list_actions(self): - return [{'action': a, 'args': self._actions[a]} - for a in sorted(self._actions.keys())] + return [ + {'action': a, 'args': self._actions[a]} + for a in sorted(self._actions.keys()) + ] def _process_monitor(self): def _thread(): @@ -278,66 +306,66 @@ class MediaMplayerPlugin(MediaPlugin): @action def pause(self): - """ Toggle the paused state """ + """Toggle the paused state""" self._exec('pause') self._post_event(MediaPauseEvent) return self.status() @action def stop(self): - """ Stop the playback """ + """Stop the playback""" # return self._exec('stop') self.quit() return self.status() @action def quit(self): - """ Quit the player """ + """Quit the player""" self._exec('quit') self._post_event(MediaStopEvent) return self.status() @action def voldown(self, step=10.0): - """ Volume down by (default: 10)% """ + """Volume down by (default: 10)%""" self._exec('volume', -step * 10) return self.status() @action def volup(self, step=10.0): - """ Volume up by (default: 10)% """ + """Volume up by (default: 10)%""" self._exec('volume', step * 10) return self.status() @action def back(self, offset=30.0): - """ Back by (default: 30) seconds """ + """Back by (default: 30) seconds""" self.step_property('time_pos', -offset) return self.status() @action def forward(self, offset=30.0): - """ Forward by (default: 30) seconds """ + """Forward by (default: 30) seconds""" self.step_property('time_pos', offset) return self.status() @action def toggle_subtitles(self): - """ Toggle the subtitles visibility """ + """Toggle the subtitles visibility""" subs = self.get_property('sub_visibility').output.get('sub_visibility') self._exec('sub_visibility', int(not subs)) return self.status() @action def add_subtitles(self, filename, **__): - """ Sets media subtitles from filename """ + """Sets media subtitles from filename""" self._exec('sub_visibility', 1) self._exec('sub_load', filename) return self.status() @action def remove_subtitles(self, index=None): - """ Removes the subtitle specified by the index (default: all) """ + """Removes the subtitle specified by the index (default: all)""" if index is None: self._exec('sub_remove') else: @@ -363,7 +391,7 @@ class MediaMplayerPlugin(MediaPlugin): @action def mute(self): - """ Toggle mute state """ + """Toggle mute state""" self._exec('mute') return self.status() @@ -438,11 +466,15 @@ class MediaMplayerPlugin(MediaPlugin): if value is not None: status[prop] = value.get(player_prop) - status['seekable'] = True if status['duration'] is not None else False - status['state'] = PlayerState.PAUSE.value if status['pause'] else PlayerState.PLAY.value + status['seekable'] = bool(status['duration']) + status['state'] = ( + PlayerState.PAUSE.value if status['pause'] else PlayerState.PLAY.value + ) if status['path']: - status['url'] = ('file://' if os.path.isfile(status['path']) else '') + status['path'] + status['url'] = ( + 'file://' if os.path.isfile(status['path']) else '' + ) + status['path'] status['volume_max'] = 100 return status @@ -458,8 +490,16 @@ class MediaMplayerPlugin(MediaPlugin): args = args or [] response = Response(output={}) - result = self._exec('get_property', property, prefix='pausing_keep_force', - wait_for_response=True, *args) or {} + result = ( + self._exec( + 'get_property', + property, + prefix='pausing_keep_force', + wait_for_response=True, + *args, + ) + or {} + ) for k, v in result.items(): if k == 'ERROR' and v not in response.errors: @@ -480,14 +520,21 @@ class MediaMplayerPlugin(MediaPlugin): args = args or [] response = Response(output={}) - result = self._exec('set_property', property, value, - prefix='pausing_keep_force' if property != 'pause' - else None, wait_for_response=True, *args) or {} + result = ( + self._exec( + 'set_property', + property, + value, + prefix='pausing_keep_force' if property != 'pause' else None, + wait_for_response=True, + *args, + ) + or {} + ) for k, v in result.items(): if k == 'ERROR' and v not in response.errors: - response.errors.append('{} {}{}: {}'.format(property, value, - args, v)) + response.errors.append('{} {}{}: {}'.format(property, value, args, v)) else: response.output[k] = v @@ -504,14 +551,21 @@ class MediaMplayerPlugin(MediaPlugin): args = args or [] response = Response(output={}) - result = self._exec('step_property', property, value, - prefix='pausing_keep_force', - wait_for_response=True, *args) or {} + result = ( + self._exec( + 'step_property', + property, + value, + prefix='pausing_keep_force', + wait_for_response=True, + *args, + ) + or {} + ) for k, v in result.items(): if k == 'ERROR' and v not in response.errors: - response.errors.append('{} {}{}: {}'.format(property, value, - args, v)) + response.errors.append('{} {}{}: {}'.format(property, value, args, v)) else: response.output[k] = v diff --git a/platypush/plugins/media/mpv/__init__.py b/platypush/plugins/media/mpv/__init__.py index 19853cbae..01eae6eae 100644 --- a/platypush/plugins/media/mpv/__init__.py +++ b/platypush/plugins/media/mpv/__init__.py @@ -18,12 +18,7 @@ from platypush.plugins import action class MediaMpvPlugin(MediaPlugin): """ - Plugin to control MPV instances - - Requires: - - * **python-mpv** (``pip install python-mpv``) - * **mpv** executable on your system + Plugin to control MPV instances. """ _default_mpv_args = { diff --git a/platypush/plugins/media/omxplayer/__init__.py b/platypush/plugins/media/omxplayer/__init__.py index d9eb868e9..a33597c97 100644 --- a/platypush/plugins/media/omxplayer/__init__.py +++ b/platypush/plugins/media/omxplayer/__init__.py @@ -4,7 +4,13 @@ import urllib.parse from platypush.context import get_bus from platypush.plugins.media import MediaPlugin, PlayerState -from platypush.message.event.media import MediaPlayEvent, MediaPauseEvent, MediaStopEvent, MediaSeekEvent, MediaPlayRequestEvent +from platypush.message.event.media import ( + MediaPlayEvent, + MediaPauseEvent, + MediaStopEvent, + MediaSeekEvent, + MediaPlayRequestEvent, +) from platypush.plugins import action @@ -18,14 +24,9 @@ class PlayerEvent(enum.Enum): class MediaOmxplayerPlugin(MediaPlugin): """ Plugin to control video and media playback using OMXPlayer. - - Requires: - - * **omxplayer** installed on your system (see your distro instructions) - * **omxplayer-wrapper** (``pip install omxplayer-wrapper``) """ - def __init__(self, args=None, *argv, timeout: float = 20., **kwargs): + def __init__(self, args=None, *argv, timeout: float = 20.0, **kwargs): """ :param args: Arguments that will be passed to the OMXPlayer constructor (e.g. subtitles, volume, start position, window size etc.) see @@ -82,40 +83,47 @@ class MediaOmxplayerPlugin(MediaPlugin): self._player = None except Exception as e: self.logger.exception(e) - self.logger.warning('Unable to stop a previously running instance ' + - 'of OMXPlayer, trying to play anyway') + self.logger.warning( + 'Unable to stop a previously running instance ' + + 'of OMXPlayer, trying to play anyway' + ) from dbus import DBusException try: from omxplayer import OMXPlayer + self._player = OMXPlayer(resource, args=self.args) except DBusException as e: - self.logger.warning('DBus connection failed: you will probably not ' + - 'be able to control the media') + self.logger.warning( + 'DBus connection failed: you will probably not ' + + 'be able to control the media' + ) self.logger.exception(e) self._post_event(MediaPlayEvent, resource=resource) self._init_player_handlers() if not self._play_started.wait(timeout=self.timeout): - self.logger.warning(f'The player has not sent a play started event within {self.timeout}') + self.logger.warning( + f'The player has not sent a play started event within {self.timeout}' + ) return self.status() @action def pause(self): - """ Pause the playback """ + """Pause the playback""" if self._player: self._player.play_pause() return self.status() @action def stop(self): - """ Stop the playback (same as quit) """ + """Stop the playback (same as quit)""" return self.quit() @action def quit(self): - """ Quit the player """ + """Quit the player""" from omxplayer.player import OMXPlayerDeadError if self._player: @@ -138,7 +146,7 @@ class MediaOmxplayerPlugin(MediaPlugin): :return: The player volume in percentage [0, 100]. """ if self._player: - return self._player.volume()*100 + return self._player.volume() * 100 @action def voldown(self, step=10.0): @@ -149,7 +157,7 @@ class MediaOmxplayerPlugin(MediaPlugin): :type step: float """ if self._player: - self.set_volume(max(0, self.get_volume()-step)) + self.set_volume(max(0, self.get_volume() - step)) return self.status() @action @@ -161,26 +169,26 @@ class MediaOmxplayerPlugin(MediaPlugin): :type step: float """ if self._player: - self.set_volume(min(100, self.get_volume()+step)) + self.set_volume(min(100, self.get_volume() + step)) return self.status() @action def back(self, offset=30): - """ Back by (default: 30) seconds """ + """Back by (default: 30) seconds""" if self._player: self._player.seek(-offset) return self.status() @action def forward(self, offset=30): - """ Forward by (default: 30) seconds """ + """Forward by (default: 30) seconds""" if self._player: self._player.seek(offset) return self.status() @action def next(self): - """ Play the next track/video """ + """Play the next track/video""" if self._player: self._player.stop() @@ -192,14 +200,14 @@ class MediaOmxplayerPlugin(MediaPlugin): @action def hide_subtitles(self): - """ Hide the subtitles """ + """Hide the subtitles""" if self._player: self._player.hide_subtitles() return self.status() @action def hide_video(self): - """ Hide the video """ + """Hide the video""" if self._player: self._player.hide_video() return self.status() @@ -230,21 +238,21 @@ class MediaOmxplayerPlugin(MediaPlugin): @action def metadata(self): - """ Get the metadata of the current video """ + """Get the metadata of the current video""" if self._player: return self._player.metadata() return self.status() @action def mute(self): - """ Mute the player """ + """Mute the player""" if self._player: self._player.mute() return self.status() @action def unmute(self): - """ Unmute the player """ + """Unmute the player""" if self._player: self._player.unmute() return self.status() @@ -271,7 +279,6 @@ class MediaOmxplayerPlugin(MediaPlugin): """ return self.seek(position) - @action def set_volume(self, volume): """ @@ -282,7 +289,7 @@ class MediaOmxplayerPlugin(MediaPlugin): """ if self._player: - self._player.set_volume(volume/100) + self._player.set_volume(volume / 100) return self.status() @action @@ -315,9 +322,7 @@ class MediaOmxplayerPlugin(MediaPlugin): from dbus import DBusException if not self._player: - return { - 'state': PlayerState.STOP.value - } + return {'state': PlayerState.STOP.value} try: state = self._player.playback_status().lower() @@ -326,9 +331,7 @@ class MediaOmxplayerPlugin(MediaPlugin): if isinstance(e, OMXPlayerDeadError): self._player = None - return { - 'state': PlayerState.STOP.value - } + return {'state': PlayerState.STOP.value} if state == 'playing': state = PlayerState.PLAY.value @@ -339,7 +342,9 @@ class MediaOmxplayerPlugin(MediaPlugin): return { 'duration': self._player.duration(), - 'filename': urllib.parse.unquote(self._player.get_source()).split('/')[-1] if self._player.get_source().startswith('file://') else None, + 'filename': urllib.parse.unquote(self._player.get_source()).split('/')[-1] + if self._player.get_source().startswith('file://') + else None, 'fullscreen': self._player.fullscreen(), 'mute': self._player._is_muted, 'path': self._player.get_source(), @@ -347,7 +352,9 @@ class MediaOmxplayerPlugin(MediaPlugin): 'position': max(0, self._player.position()), 'seekable': self._player.can_seek(), 'state': state, - 'title': urllib.parse.unquote(self._player.get_source()).split('/')[-1] if self._player.get_source().startswith('file://') else None, + 'title': urllib.parse.unquote(self._player.get_source()).split('/')[-1] + if self._player.get_source().startswith('file://') + else None, 'url': self._player.get_source(), 'volume': self.get_volume(), 'volume_max': 100, @@ -355,8 +362,9 @@ class MediaOmxplayerPlugin(MediaPlugin): def add_handler(self, event_type, callback): if event_type not in self._handlers.keys(): - raise AttributeError('{} is not a valid PlayerEvent type'. - format(event_type)) + raise AttributeError( + '{} is not a valid PlayerEvent type'.format(event_type) + ) self._handlers[event_type].append(callback) @@ -392,11 +400,13 @@ class MediaOmxplayerPlugin(MediaPlugin): self._post_event(MediaStopEvent) for callback in self._handlers[PlayerEvent.STOP.value]: callback() + return _f def on_seek(self): def _f(player, *_, **__): self._post_event(MediaSeekEvent, position=player.position()) + return _f def _init_player_handlers(self): diff --git a/platypush/plugins/media/plex/__init__.py b/platypush/plugins/media/plex/__init__.py index da3cb272d..fcb6b983f 100644 --- a/platypush/plugins/media/plex/__init__.py +++ b/platypush/plugins/media/plex/__init__.py @@ -6,11 +6,7 @@ from platypush.plugins import Plugin, action class MediaPlexPlugin(Plugin): """ - Plugin to interact with a Plex media server - - Requires: - - * **plexapi** (``pip install plexapi``) + Plugin to interact with a Plex media server. """ def __init__(self, server, username, password, **kwargs): @@ -26,6 +22,7 @@ class MediaPlexPlugin(Plugin): """ from plexapi.myplex import MyPlexAccount + super().__init__(**kwargs) self.resource = MyPlexAccount(username, password).resource(server) @@ -44,18 +41,21 @@ class MediaPlexPlugin(Plugin): Get the list of active clients """ - return [{ - 'device': c.device, - 'device_class': c.deviceClass, - 'local': c.local, - 'model': c.model, - 'platform': c.platform, - 'platform_version': c.platformVersion, - 'product': c.product, - 'state': c.state, - 'title': c.title, - 'version': c.version, - } for c in self.plex.clients()] + return [ + { + 'device': c.device, + 'device_class': c.deviceClass, + 'local': c.local, + 'model': c.model, + 'platform': c.platform, + 'platform_version': c.platformVersion, + 'product': c.product, + 'state': c.state, + 'title': c.title, + 'version': c.version, + } + for c in self.plex.clients() + ] def _get_client(self, name): return self.plex.client(name) @@ -104,7 +104,8 @@ class MediaPlexPlugin(Plugin): 'summary': pl.summary, 'viewed_at': pl.viewedAt, 'items': [self._flatten_item(item) for item in pl.items()], - } for pl in self.plex.playlists() + } + for pl in self.plex.playlists() ] @action @@ -113,9 +114,7 @@ class MediaPlexPlugin(Plugin): Get the history of items played on the server """ - return [ - self._flatten_item(item) for item in self.plex.history() - ] + return [self._flatten_item(item) for item in self.plex.history()] @staticmethod def get_chromecast(chromecast): @@ -386,12 +385,18 @@ class MediaPlexPlugin(Plugin): 'file': part.file, 'size': part.size, 'duration': (part.duration or 0) / 1000, - 'url': self.plex.url(part.key) + '?' + urllib.parse.urlencode({ - 'X-Plex-Token': self.plex._token, - }), - } for part in item.media[i].parts - ] - } for i in range(0, len(item.media)) + 'url': self.plex.url(part.key) + + '?' + + urllib.parse.urlencode( + { + 'X-Plex-Token': self.plex._token, + } + ), + } + for part in item.media[i].parts + ], + } + for i in range(0, len(item.media)) ] elif isinstance(item, Show): @@ -419,7 +424,9 @@ class MediaPlexPlugin(Plugin): 'audio_channels': episode.media[i].audioChannels, 'audio_codec': episode.media[i].audioCodec, 'video_codec': episode.media[i].videoCodec, - 'video_resolution': episode.media[i].videoResolution, + 'video_resolution': episode.media[ + i + ].videoResolution, 'video_frame_rate': episode.media[i].videoFrameRate, 'title': episode.title, 'parts': [ @@ -427,35 +434,45 @@ class MediaPlexPlugin(Plugin): 'file': part.file, 'size': part.size, 'duration': part.duration / 1000, - 'url': self.plex.url(part.key) + '?' + urllib.parse.urlencode({ - 'X-Plex-Token': self.plex._token, - }), - } for part in episode.media[i].parts - ] - } for i in range(0, len(episode.locations)) - ] - } for episode in season.episodes() - ] - } for season in item.seasons() + 'url': self.plex.url(part.key) + + '?' + + urllib.parse.urlencode( + { + 'X-Plex-Token': self.plex._token, + } + ), + } + for part in episode.media[i].parts + ], + } + for i in range(0, len(episode.locations)) + ], + } + for episode in season.episodes() + ], + } + for season in item.seasons() ] elif isinstance(item, Track): - _item.update({ - 'artist': item.grandparentTitle, - 'album': item.parentTitle, - 'title': item.title, - 'name': item.title, - 'duration': item.duration / 1000., - 'index': item.index, - 'track_number': item.trackNumber, - 'year': item.year, - 'locations': [item.locations], - }) + _item.update( + { + 'artist': item.grandparentTitle, + 'album': item.parentTitle, + 'title': item.title, + 'name': item.title, + 'duration': item.duration / 1000.0, + 'index': item.index, + 'track_number': item.trackNumber, + 'year': item.year, + 'locations': [item.locations], + } + ) _item['media'] = [ { 'title': media.title, - 'duration': media.duration / 1000., + 'duration': media.duration / 1000.0, 'bitrate': media.bitrate, 'width': media.width, 'height': media.height, @@ -469,15 +486,21 @@ class MediaPlexPlugin(Plugin): 'file': part.file, 'duration': part.duration / 1000, 'size': part.size, - 'url': self.plex.url(part.key) + '?' + urllib.parse.urlencode({ - 'X-Plex-Token': self.plex._token, - }), - } for part in media.parts - ] - } for media in item.media + 'url': self.plex.url(part.key) + + '?' + + urllib.parse.urlencode( + { + 'X-Plex-Token': self.plex._token, + } + ), + } + for part in media.parts + ], + } + for media in item.media ] return _item -# vim:sw=4:ts=4:et: \ No newline at end of file +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/media/subtitles/__init__.py b/platypush/plugins/media/subtitles/__init__.py index b65277d14..337c1c377 100644 --- a/platypush/plugins/media/subtitles/__init__.py +++ b/platypush/plugins/media/subtitles/__init__.py @@ -10,13 +10,7 @@ from platypush.utils import find_files_by_ext class MediaSubtitlesPlugin(Plugin): """ - Plugin to get video subtitles from OpenSubtitles - - Requires: - - * **python-opensubtitles** (``pip install -e 'git+https://github.com/agonzalezro/python-opensubtitles#egg=python-opensubtitles'``) - * **webvtt** (``pip install webvtt-py``), optional, to convert srt subtitles into vtt format ready for web streaming - + Plugin to get video subtitles from OpenSubtitles. """ def __init__(self, username, password, language=None, **kwargs): @@ -46,10 +40,11 @@ class MediaSubtitlesPlugin(Plugin): if isinstance(language, str): self.languages.append(language.lower()) elif isinstance(language, list): - self.languages.extend([l.lower() for l in language]) + self.languages.extend([lang.lower() for lang in language]) else: - raise AttributeError('{} is neither a string nor a list'.format( - language)) + raise AttributeError( + '{} is neither a string nor a list'.format(language) + ) @action def get_subtitles(self, resource, language=None): @@ -67,7 +62,7 @@ class MediaSubtitlesPlugin(Plugin): from pythonopensubtitles.utils import File if resource.startswith('file://'): - resource = resource[len('file://'):] + resource = resource[len('file://') :] resource = os.path.abspath(os.path.expanduser(resource)) if not os.path.isfile(resource): @@ -79,37 +74,49 @@ class MediaSubtitlesPlugin(Plugin): os.chdir(media_dir) file = file.split(os.sep)[-1] - local_subs = [{ - 'IsLocal': True, - 'MovieName': '[Local subtitle]', - 'SubFileName': sub.split(os.sep)[-1], - 'SubDownloadLink': 'file://' + os.path.join(media_dir, sub), - } for sub in find_files_by_ext(media_dir, '.srt', '.vtt')] + local_subs = [ + { + 'IsLocal': True, + 'MovieName': '[Local subtitle]', + 'SubFileName': sub.split(os.sep)[-1], + 'SubDownloadLink': 'file://' + os.path.join(media_dir, sub), + } + for sub in find_files_by_ext(media_dir, '.srt', '.vtt') + ] - self.logger.info('Found {} local subtitles for {}'.format( - len(local_subs), file)) + self.logger.info( + 'Found {} local subtitles for {}'.format(len(local_subs), file) + ) languages = [language.lower()] if language else self.languages try: file_hash = File(file).get_hash() - subs = self._ost.search_subtitles([{ - 'sublanguageid': 'all', - 'moviehash': file_hash, - }]) + subs = self._ost.search_subtitles( + [ + { + 'sublanguageid': 'all', + 'moviehash': file_hash, + } + ] + ) subs = [ - sub for sub in subs if not languages or languages[0] == 'all' or - sub.get('LanguageName', '').lower() in languages or - sub.get('SubLanguageID', '').lower() in languages or - sub.get('ISO639', '').lower() in languages + sub + for sub in subs + if not languages + or languages[0] == 'all' + or sub.get('LanguageName', '').lower() in languages + or sub.get('SubLanguageID', '').lower() in languages + or sub.get('ISO639', '').lower() in languages ] for sub in subs: sub['IsLocal'] = False - self.logger.info('Found {} OpenSubtitles items for {}'.format( - len(subs), file)) + self.logger.info( + 'Found {} OpenSubtitles items for {}'.format(len(subs), file) + ) return local_subs + subs finally: @@ -159,7 +166,7 @@ class MediaSubtitlesPlugin(Plugin): """ if link.startswith('file://'): - link = link[len('file://'):] + link = link[len('file://') :] if os.path.isfile(link): if convert_to_vtt: link = self.to_vtt(link).output @@ -169,18 +176,23 @@ class MediaSubtitlesPlugin(Plugin): if not path and media_resource: if media_resource.startswith('file://'): - media_resource = media_resource[len('file://'):] + media_resource = media_resource[len('file://') :] if os.path.isfile(media_resource): media_resource = os.path.abspath(media_resource) - path = os.path.join( - os.path.dirname(media_resource), - '.'.join(os.path.basename(media_resource).split('.')[:-1])) + '.srt' + path = ( + os.path.join( + os.path.dirname(media_resource), + '.'.join(os.path.basename(media_resource).split('.')[:-1]), + ) + + '.srt' + ) if path: - f = open(path, 'wb') + f = open(path, 'wb') # noqa else: - f = tempfile.NamedTemporaryFile(prefix='media_subs_', - suffix='.srt', delete=False) + f = tempfile.NamedTemporaryFile( + prefix='media_subs_', suffix='.srt', delete=False + ) path = f.name try: diff --git a/platypush/plugins/media/vlc/__init__.py b/platypush/plugins/media/vlc/__init__.py index 7428a02ec..05f3d2a8c 100644 --- a/platypush/plugins/media/vlc/__init__.py +++ b/platypush/plugins/media/vlc/__init__.py @@ -5,21 +5,23 @@ from typing import Optional from platypush.context import get_bus from platypush.plugins.media import PlayerState, MediaPlugin -from platypush.message.event.media import MediaPlayEvent, MediaPlayRequestEvent, \ - MediaPauseEvent, MediaStopEvent, MediaSeekEvent, MediaVolumeChangedEvent, \ - MediaMuteChangedEvent, NewPlayingMediaEvent +from platypush.message.event.media import ( + MediaPlayEvent, + MediaPlayRequestEvent, + MediaPauseEvent, + MediaStopEvent, + MediaSeekEvent, + MediaVolumeChangedEvent, + MediaMuteChangedEvent, + NewPlayingMediaEvent, +) from platypush.plugins import action class MediaVlcPlugin(MediaPlugin): """ - Plugin to control vlc instances - - Requires: - - * **python-vlc** (``pip install python-vlc``) - * **vlc** executable on your system + Plugin to control VLC instances. """ def __init__(self, args=None, fullscreen=False, volume=100, *argv, **kwargs): @@ -56,16 +58,30 @@ class MediaVlcPlugin(MediaPlugin): @classmethod def _watched_event_types(cls): import vlc - return [getattr(vlc.EventType, evt) for evt in [ - 'MediaPlayerLengthChanged', 'MediaPlayerMediaChanged', - 'MediaDurationChanged', 'MediaPlayerMuted', - 'MediaPlayerUnmuted', 'MediaPlayerOpening', 'MediaPlayerPaused', - 'MediaPlayerPlaying', 'MediaPlayerPositionChanged', - 'MediaPlayerStopped', 'MediaPlayerTimeChanged', 'MediaStateChanged', - 'MediaPlayerForward', 'MediaPlayerBackward', - 'MediaPlayerEndReached', 'MediaPlayerTitleChanged', - 'MediaPlayerAudioVolume', - ] if hasattr(vlc.EventType, evt)] + + return [ + getattr(vlc.EventType, evt) + for evt in [ + 'MediaPlayerLengthChanged', + 'MediaPlayerMediaChanged', + 'MediaDurationChanged', + 'MediaPlayerMuted', + 'MediaPlayerUnmuted', + 'MediaPlayerOpening', + 'MediaPlayerPaused', + 'MediaPlayerPlaying', + 'MediaPlayerPositionChanged', + 'MediaPlayerStopped', + 'MediaPlayerTimeChanged', + 'MediaStateChanged', + 'MediaPlayerForward', + 'MediaPlayerBackward', + 'MediaPlayerEndReached', + 'MediaPlayerTitleChanged', + 'MediaPlayerAudioVolume', + ] + if hasattr(vlc.EventType, evt) + ] def _init_vlc(self, resource): import vlc @@ -86,7 +102,8 @@ class MediaVlcPlugin(MediaPlugin): for evt in self._watched_event_types(): self._player.event_manager().event_attach( - eventtype=evt, callback=self._event_callback()) + eventtype=evt, callback=self._event_callback() + ) def _player_monitor(self): self._on_stop_event.wait() @@ -118,35 +135,41 @@ class MediaVlcPlugin(MediaPlugin): def _event_callback(self): def callback(event): from vlc import EventType + self.logger.debug('Received vlc event: {}'.format(event)) if event.type == EventType.MediaPlayerPlaying: self._post_event(MediaPlayEvent, resource=self._get_current_resource()) elif event.type == EventType.MediaPlayerPaused: self._post_event(MediaPauseEvent) - elif event.type == EventType.MediaPlayerStopped or \ - event.type == EventType.MediaPlayerEndReached: + elif ( + event.type == EventType.MediaPlayerStopped + or event.type == EventType.MediaPlayerEndReached + ): self._on_stop_event.set() self._post_event(MediaStopEvent) for cbk in self._on_stop_callbacks: cbk() elif ( - event.type == EventType.MediaPlayerTitleChanged or - event.type == EventType.MediaPlayerMediaChanged + event.type == EventType.MediaPlayerTitleChanged + or event.type == EventType.MediaPlayerMediaChanged ): self._title = self._player.get_title() or self._filename if event.type == EventType.MediaPlayerMediaChanged: self._post_event(NewPlayingMediaEvent, resource=self._title) elif event.type == EventType.MediaPlayerLengthChanged: - self._post_event(NewPlayingMediaEvent, resource=self._get_current_resource()) + self._post_event( + NewPlayingMediaEvent, resource=self._get_current_resource() + ) elif event.type == EventType.MediaPlayerTimeChanged: - pos = float(self._player.get_time()/1000) - if self._latest_seek is None or \ - abs(pos-self._latest_seek) > 5: + pos = float(self._player.get_time() / 1000) + if self._latest_seek is None or abs(pos - self._latest_seek) > 5: self._post_event(MediaSeekEvent, position=pos) self._latest_seek = pos elif event.type == EventType.MediaPlayerAudioVolume: - self._post_event(MediaVolumeChangedEvent, volume=self._player.audio_get_volume()) + self._post_event( + MediaVolumeChangedEvent, volume=self._player.audio_get_volume() + ) elif event.type == EventType.MediaPlayerMuted: self._post_event(MediaMuteChangedEvent, mute=True) elif event.type == EventType.MediaPlayerUnmuted: @@ -181,13 +204,13 @@ class MediaVlcPlugin(MediaPlugin): resource = self._get_resource(resource) if resource.startswith('file://'): - resource = resource[len('file://'):] + resource = resource[len('file://') :] self._filename = resource self._init_vlc(resource) if subtitles: if subtitles.startswith('file://'): - subtitles = subtitles[len('file://'):] + subtitles = subtitles[len('file://') :] self._player.video_set_subtitle_file(subtitles) self._player.play() @@ -198,14 +221,13 @@ class MediaVlcPlugin(MediaPlugin): self.set_fullscreen(True) if volume is not None or self._default_volume is not None: - self.set_volume(volume if volume is not None - else self._default_volume) + self.set_volume(volume if volume is not None else self._default_volume) return self.status() @action def pause(self): - """ Toggle the paused state """ + """Toggle the paused state""" if not self._player: return None, 'No vlc instance is running' if not self._player.can_pause(): @@ -216,7 +238,7 @@ class MediaVlcPlugin(MediaPlugin): @action def quit(self): - """ Quit the player (same as `stop`) """ + """Quit the player (same as `stop`)""" with self._stop_lock: if not self._player: return None, 'No vlc instance is running' @@ -228,22 +250,22 @@ class MediaVlcPlugin(MediaPlugin): @action def stop(self): - """ Stop the application (same as `quit`) """ + """Stop the application (same as `quit`)""" return self.quit() @action def voldown(self, step=10.0): - """ Volume down by (default: 10)% """ + """Volume down by (default: 10)%""" if not self._player: return None, 'No vlc instance is running' - return self.set_volume(int(max(0, self._player.audio_get_volume()-step))) + return self.set_volume(int(max(0, self._player.audio_get_volume() - step))) @action def volup(self, step=10.0): - """ Volume up by (default: 10)% """ + """Volume up by (default: 10)%""" if not self._player: return None, 'No vlc instance is running' - return self.set_volume(int(min(100, self._player.audio_get_volume()+step))) + return self.set_volume(int(min(100, self._player.audio_get_volume() + step))) @action def set_volume(self, volume): @@ -279,13 +301,13 @@ class MediaVlcPlugin(MediaPlugin): if not media: return None, 'No media loaded' - pos = min(media.get_duration()/1000, max(0, position)) - self._player.set_time(int(pos*1000)) + pos = min(media.get_duration() / 1000, max(0, position)) + self._player.set_time(int(pos * 1000)) return self.status() @action def back(self, offset=30.0): - """ Back by (default: 30) seconds """ + """Back by (default: 30) seconds""" if not self._player: return None, 'No vlc instance is running' @@ -293,12 +315,12 @@ class MediaVlcPlugin(MediaPlugin): if not media: return None, 'No media loaded' - pos = max(0, (self._player.get_time()/1000)-offset) + pos = max(0, (self._player.get_time() / 1000) - offset) return self.seek(pos) @action def forward(self, offset=30.0): - """ Forward by (default: 30) seconds """ + """Forward by (default: 30) seconds""" if not self._player: return None, 'No vlc instance is running' @@ -306,51 +328,52 @@ class MediaVlcPlugin(MediaPlugin): if not media: return None, 'No media loaded' - pos = min(media.get_duration()/1000, (self._player.get_time()/1000)+offset) + pos = min( + media.get_duration() / 1000, (self._player.get_time() / 1000) + offset + ) return self.seek(pos) @action def toggle_subtitles(self, visibile=None): - """ Toggle the subtitles visibility """ + """Toggle the subtitles visibility""" if not self._player: return None, 'No vlc instance is running' if self._player.video_get_spu_count() == 0: return None, 'The media file has no subtitles set' - if self._player.video_get_spu() is None or \ - self._player.video_get_spu() == -1: + if self._player.video_get_spu() is None or self._player.video_get_spu() == -1: self._player.video_set_spu(0) else: self._player.video_set_spu(-1) @action def toggle_fullscreen(self): - """ Toggle the fullscreen mode """ + """Toggle the fullscreen mode""" if not self._player: return None, 'No vlc instance is running' self._player.toggle_fullscreen() @action def set_fullscreen(self, fullscreen=True): - """ Set fullscreen mode """ + """Set fullscreen mode""" if not self._player: return None, 'No vlc instance is running' self._player.set_fullscreen(fullscreen) @action def set_subtitles(self, filename, **args): - """ Sets media subtitles from filename """ + """Sets media subtitles from filename""" if not self._player: return None, 'No vlc instance is running' if filename.startswith('file://'): - filename = filename[len('file://'):] + filename = filename[len('file://') :] self._player.video_set_subtitle_file(filename) @action def remove_subtitles(self): - """ Removes (hides) the subtitles """ + """Removes (hides) the subtitles""" if not self._player: return None, 'No vlc instance is running' self._player.video_set_spu(-1) @@ -376,7 +399,7 @@ class MediaVlcPlugin(MediaPlugin): @action def mute(self): - """ Toggle mute state """ + """Toggle mute state""" if not self._player: return None, 'No vlc instance is running' self._player.audio_toggle_mute() @@ -418,18 +441,30 @@ class MediaVlcPlugin(MediaPlugin): else: status['state'] = PlayerState.STOP.value - status['url'] = urllib.parse.unquote(self._player.get_media().get_mrl()) if self._player.get_media() else None - status['position'] = float(self._player.get_time()/1000) if self._player.get_time() is not None else None + status['url'] = ( + urllib.parse.unquote(self._player.get_media().get_mrl()) + if self._player.get_media() + else None + ) + status['position'] = ( + float(self._player.get_time() / 1000) + if self._player.get_time() is not None + else None + ) media = self._player.get_media() - status['duration'] = media.get_duration()/1000 if media and media.get_duration() is not None else None + status['duration'] = ( + media.get_duration() / 1000 + if media and media.get_duration() is not None + else None + ) status['seekable'] = status['duration'] is not None status['fullscreen'] = self._player.get_fullscreen() status['mute'] = self._player.audio_get_mute() status['path'] = status['url'] status['pause'] = status['state'] == PlayerState.PAUSE.value - status['percent_pos'] = self._player.get_position()*100 + status['percent_pos'] = self._player.get_position() * 100 status['filename'] = self._filename status['title'] = self._title status['volume'] = self._player.audio_get_volume() diff --git a/platypush/plugins/media/webtorrent/__init__.py b/platypush/plugins/media/webtorrent/__init__.py index 3e654a22d..4f4842805 100644 --- a/platypush/plugins/media/webtorrent/__init__.py +++ b/platypush/plugins/media/webtorrent/__init__.py @@ -9,12 +9,19 @@ import time from platypush.config import Config from platypush.context import get_bus, get_plugin from platypush.plugins.media import PlayerState, MediaPlugin -from platypush.message.event.torrent import TorrentDownloadStartEvent, \ - TorrentDownloadCompletedEvent, TorrentDownloadedMetadataEvent +from platypush.message.event.torrent import ( + TorrentDownloadStartEvent, + TorrentDownloadCompletedEvent, + TorrentDownloadedMetadataEvent, +) from platypush.plugins import action -from platypush.utils import find_bins_in_path, find_files_by_ext, \ - is_process_alive, get_ip_or_hostname +from platypush.utils import ( + find_bins_in_path, + find_files_by_ext, + is_process_alive, + get_ip_or_hostname, +) class TorrentState(enum.IntEnum): @@ -26,24 +33,24 @@ class TorrentState(enum.IntEnum): class MediaWebtorrentPlugin(MediaPlugin): """ - Plugin to download and stream videos using webtorrent + Plugin to download and stream videos using webtorrent. - Requires: - - * **webtorrent** installed on your system (``npm install -g webtorrent``) - * **webtorrent-cli** installed on your system (``npm install -g webtorrent-cli``) - * A media plugin configured for streaming (e.g. media.mplayer, media.vlc, media.mpv or media.omxplayer) """ - _supported_media_plugins = {'media.mplayer', 'media.omxplayer', 'media.mpv', - 'media.vlc', 'media.webtorrent'} + + _supported_media_plugins = { + 'media.mplayer', + 'media.omxplayer', + 'media.mpv', + 'media.vlc', + 'media.webtorrent', + } # Download at least 15 MBs before starting streaming _download_size_before_streaming = 15 * 2**20 _web_stream_ready_timeout = 120 - def __init__(self, webtorrent_bin=None, webtorrent_port=None, *args, - **kwargs): + def __init__(self, webtorrent_bin=None, webtorrent_port=None, *args, **kwargs): """ media.webtorrent will use the default media player plugin you have configured (e.g. mplayer, omxplayer, mpv) to stream the torrent. @@ -72,19 +79,24 @@ class MediaWebtorrentPlugin(MediaPlugin): bins = find_bins_in_path(bin_name) if not bins: - raise RuntimeError('Webtorrent executable not specified and ' + - 'not found in your PATH. Make sure that ' + - 'webtorrent is either installed or ' + - 'configured and that both webtorrent and ' + - 'webtorrent-cli are installed') + raise RuntimeError( + 'Webtorrent executable not specified and ' + + 'not found in your PATH. Make sure that ' + + 'webtorrent is either installed or ' + + 'configured and that both webtorrent and ' + + 'webtorrent-cli are installed' + ) self.webtorrent_bin = bins[0] else: webtorrent_bin = os.path.expanduser(webtorrent_bin) - if not (os.path.isfile(webtorrent_bin) - and (os.name == 'nt' or os.access(webtorrent_bin, os.X_OK))): - raise RuntimeError('{} is does not exist or is not a valid ' + - 'executable file'.format(webtorrent_bin)) + if not ( + os.path.isfile(webtorrent_bin) + and (os.name == 'nt' or os.access(webtorrent_bin, os.X_OK)) + ): + raise RuntimeError( + f'{webtorrent_bin} is does not exist or is not a valid executable file' + ) self.webtorrent_bin = webtorrent_bin @@ -100,18 +112,22 @@ class MediaWebtorrentPlugin(MediaPlugin): self.logger.debug(f'Could not get media plugin {plugin_name}: {str(e)}') if not self._media_plugin: - raise RuntimeError(('No media player specified and no ' + - 'compatible media plugin configured - ' + - 'supported media plugins: {}').format( - self._supported_media_plugins)) + raise RuntimeError( + ( + 'No media player specified and no ' + + 'compatible media plugin configured - ' + + 'supported media plugins: {}' + ).format(self._supported_media_plugins) + ) def _read_process_line(self): line = self._webtorrent_process.stdout.readline().decode().strip() # Strip output of the colors return re.sub(r'\x1b\[(([0-9]+m)|(.{1,2}))', '', line).strip() - def _process_monitor(self, resource, download_dir, download_only, - player_type, player_args): + def _process_monitor( + self, resource, download_dir, download_only, player_type, player_args + ): def _thread(): if not self._webtorrent_process: return @@ -137,35 +153,47 @@ class MediaWebtorrentPlugin(MediaPlugin): line = self._read_process_line() - if 'fetching torrent metadata from' in line.lower() \ - and state == TorrentState.IDLE: + if ( + 'fetching torrent metadata from' in line.lower() + and state == TorrentState.IDLE + ): # IDLE -> DOWNLOADING_METADATA state = TorrentState.DOWNLOADING_METADATA - bus.post(TorrentDownloadedMetadataEvent(url=webtorrent_url, resource=resource)) - elif 'downloading: ' in line.lower() \ - and media_file is None: + bus.post( + TorrentDownloadedMetadataEvent( + url=webtorrent_url, resource=resource + ) + ) + elif 'downloading: ' in line.lower() and media_file is None: # Find video files in torrent directory output_dir = os.path.join( - download_dir, re.search( + download_dir, + re.search( 'downloading: (.+?)$', line, flags=re.IGNORECASE - ).group(1)) + ).group(1), + ) - elif 'server running at: ' in line.lower() \ - and webtorrent_url is None: + elif 'server running at: ' in line.lower() and webtorrent_url is None: # Streaming started - webtorrent_url = re.search('server running at: (.+?)$', - line, flags=re.IGNORECASE).group(1) + webtorrent_url = re.search( + 'server running at: (.+?)$', line, flags=re.IGNORECASE + ).group(1) webtorrent_url = webtorrent_url.replace( - 'http://localhost', 'http://' + get_ip_or_hostname()) + 'http://localhost', 'http://' + get_ip_or_hostname() + ) self._torrent_stream_urls[resource] = webtorrent_url self._download_started_event.set() - self.logger.info('Torrent stream started on {}'.format( - webtorrent_url)) + self.logger.info( + 'Torrent stream started on {}'.format(webtorrent_url) + ) if output_dir and not media_file: - media_files = sorted(find_files_by_ext( - output_dir, *self._media_plugin.video_extensions)) + media_files = sorted( + find_files_by_ext( + output_dir, *self._media_plugin.video_extensions + ) + ) if media_files: # TODO support for queueing multiple media @@ -173,20 +201,27 @@ class MediaWebtorrentPlugin(MediaPlugin): else: time.sleep(1) # Wait before the media file is created - if state.value <= TorrentState.DOWNLOADING_METADATA.value \ - and media_file and webtorrent_url: + if ( + state.value <= TorrentState.DOWNLOADING_METADATA.value + and media_file + and webtorrent_url + ): # DOWNLOADING_METADATA -> DOWNLOADING - bus.post(TorrentDownloadStartEvent( - resource=resource, media_file=media_file, - stream_url=webtorrent_url, url=webtorrent_url)) + bus.post( + TorrentDownloadStartEvent( + resource=resource, + media_file=media_file, + stream_url=webtorrent_url, + url=webtorrent_url, + ) + ) break if not output_dir: raise RuntimeError('Could not download torrent') if not download_only and (not media_file or not webtorrent_url): if not media_file: - self.logger.warning( - 'The torrent does not contain any video files') + self.logger.warning('The torrent does not contain any video files') else: self.logger.warning('WebTorrent could not start streaming') @@ -211,21 +246,27 @@ class MediaWebtorrentPlugin(MediaPlugin): break try: - if os.path.getsize(media_file) > \ - self._download_size_before_streaming: + if ( + os.path.getsize(media_file) + > self._download_size_before_streaming + ): break except FileNotFoundError: continue - player = get_plugin('media.' + player_type) if player_type \ + player = ( + get_plugin('media.' + player_type) + if player_type else self._media_plugin + ) media = media_file if player.is_local() else webtorrent_url self.logger.info( 'Starting playback of {} to {} through {}'.format( - media_file, player.__class__.__name__, - webtorrent_url)) + media_file, player.__class__.__name__, webtorrent_url + ) + ) subfile = self.get_subtitles(media) if subfile: @@ -236,10 +277,14 @@ class MediaWebtorrentPlugin(MediaPlugin): self._wait_for_player(player) self.logger.info('Torrent player terminated') - bus.post(TorrentDownloadCompletedEvent(resource=resource, - output_dir=output_dir, - media_file=media_file, - url=webtorrent_url)) + bus.post( + TorrentDownloadCompletedEvent( + resource=resource, + output_dir=output_dir, + media_file=media_file, + url=webtorrent_url, + ) + ) try: self.quit() @@ -264,12 +309,14 @@ class MediaWebtorrentPlugin(MediaPlugin): def stop_callback(): stop_evt.set() + player.on_stop(stop_callback) elif media_cls == 'MediaOmxplayerPlugin': stop_evt = threading.Event() def stop_callback(): stop_evt.set() + player.add_handler('stop', stop_callback) if stop_evt: @@ -296,14 +343,14 @@ class MediaWebtorrentPlugin(MediaPlugin): if not subs: return - sub = plugin.download_subtitles(subs[0]['SubDownloadLink'], - filepath).output + sub = plugin.download_subtitles(subs[0]['SubDownloadLink'], filepath).output if sub: return sub['filename'] except Exception as e: - self.logger.warning('Could not get subtitles for {}: {}'.format( - filepath, str(e))) + self.logger.warning( + 'Could not get subtitles for {}: {}'.format(filepath, str(e)) + ) @action def play(self, resource, player=None, download_only=False, **player_args): @@ -332,8 +379,9 @@ class MediaWebtorrentPlugin(MediaPlugin): try: self.quit() except Exception as e: - self.logger.debug('Failed to quit the previous instance: {}'. - format(str(e))) + self.logger.debug( + 'Failed to quit the previous instance: {}'.format(str(e)) + ) download_dir = self._get_torrent_download_dir() webtorrent_args = [self.webtorrent_bin, 'download', '-o', download_dir] @@ -343,31 +391,45 @@ class MediaWebtorrentPlugin(MediaPlugin): webtorrent_args += [resource] self._download_started_event.clear() - self._webtorrent_process = subprocess.Popen(webtorrent_args, - stdout=subprocess.PIPE) + self._webtorrent_process = subprocess.Popen( + webtorrent_args, stdout=subprocess.PIPE + ) - threading.Thread(target=self._process_monitor( - resource=resource, download_dir=download_dir, - player_type=player, player_args=player_args, - download_only=download_only)).start() + threading.Thread( + target=self._process_monitor( + resource=resource, + download_dir=download_dir, + player_type=player, + player_args=player_args, + download_only=download_only, + ) + ).start() stream_url = None player_ready_wait_start = time.time() while not stream_url: triggered = self._download_started_event.wait( - self._web_stream_ready_timeout) + self._web_stream_ready_timeout + ) - if not triggered or time.time() - player_ready_wait_start >= \ - self._web_stream_ready_timeout: + if ( + not triggered + or time.time() - player_ready_wait_start + >= self._web_stream_ready_timeout + ): break stream_url = self._torrent_stream_urls.get(resource) if not stream_url: - return (None, ("The webtorrent process hasn't started " + - "streaming after {} seconds").format( - self._web_stream_ready_timeout)) + return ( + None, + ( + "The webtorrent process hasn't started " + + "streaming after {} seconds" + ).format(self._web_stream_ready_timeout), + ) return {'resource': resource, 'url': stream_url} @@ -377,12 +439,12 @@ class MediaWebtorrentPlugin(MediaPlugin): @action def stop(self): - """ Stop the playback """ + """Stop the playback""" return self.quit() @action def quit(self): - """ Quit the player """ + """Quit the player""" if self._is_process_alive(): self._webtorrent_process.terminate() self._webtorrent_process.wait() @@ -401,8 +463,11 @@ class MediaWebtorrentPlugin(MediaPlugin): return self.play(resource) def _is_process_alive(self): - return is_process_alive(self._webtorrent_process.pid) \ - if self._webtorrent_process else False + return ( + is_process_alive(self._webtorrent_process.pid) + if self._webtorrent_process + else False + ) @action def status(self): @@ -418,7 +483,9 @@ class MediaWebtorrentPlugin(MediaPlugin): } """ - return {'state': self._media_plugin.status().get('state', PlayerState.STOP.value)} + return { + 'state': self._media_plugin.status().get('state', PlayerState.STOP.value) + } def pause(self, *args, **kwargs): raise NotImplementedError diff --git a/platypush/plugins/midi/__init__.py b/platypush/plugins/midi/__init__.py index 226a1c878..894ca1c11 100644 --- a/platypush/plugins/midi/__init__.py +++ b/platypush/plugins/midi/__init__.py @@ -7,10 +7,6 @@ class MidiPlugin(Plugin): """ Virtual MIDI controller plugin. It allows you to send custom MIDI messages to any connected devices. - - Requires: - - * **python-rtmidi** (``pip install python-rtmidi``) """ _played_notes = set() @@ -21,6 +17,7 @@ class MidiPlugin(Plugin): :type device_name: str """ import rtmidi + super().__init__(**kwargs) self.device_name = device_name @@ -32,7 +29,9 @@ class MidiPlugin(Plugin): self.logger.info('Initialized MIDI plugin on port 0') else: self.midiout.open_virtual_port(self.device_name) - self.logger.info('Initialized MIDI plugin on virtual device {}'.format(self.device_name)) + self.logger.info( + 'Initialized MIDI plugin on virtual device {}'.format(self.device_name) + ) @action def send_message(self, values): @@ -117,12 +116,14 @@ class MidiPlugin(Plugin): """ import rtmidi + in_ports = rtmidi.MidiIn().get_ports() out_ports = rtmidi.MidiOut().get_ports() return { - 'in': {i: port for i, port in enumerate(in_ports)}, - 'out': {i: port for i, port in enumerate(out_ports)}, + 'in': dict(enumerate(in_ports)), + 'out': dict(enumerate(out_ports)), } + # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/ml/cv/__init__.py b/platypush/plugins/ml/cv/__init__.py index 2f49c105f..24dd4b872 100644 --- a/platypush/plugins/ml/cv/__init__.py +++ b/platypush/plugins/ml/cv/__init__.py @@ -43,11 +43,6 @@ class MlCvPlugin(Plugin): """ Plugin to train and make computer vision predictions using machine learning models. - Requires: - - * **numpy** (``pip install numpy``) - * **opencv** (``pip install cv2``) - Also make sure that your OpenCV installation comes with the ``dnn`` module. To test it:: >>> import cv2.dnn @@ -80,7 +75,9 @@ class MlCvPlugin(Plugin): if model_file not in self.models: self.models[model_file] = MlModel(model_file, classes=classes) - return self.models[model_file].predict(img, resize=resize, color_convert=color_convert) + return self.models[model_file].predict( + img, resize=resize, color_convert=color_convert + ) # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/mqtt/__init__.py b/platypush/plugins/mqtt/__init__.py index d6dc1dd08..3dc62e5e2 100644 --- a/platypush/plugins/mqtt/__init__.py +++ b/platypush/plugins/mqtt/__init__.py @@ -22,16 +22,6 @@ class MqttPlugin(RunnablePlugin): """ This plugin allows you to send custom message to a message queue compatible with the MQTT protocol, see https://mqtt.org/ - - Requires: - - * **paho-mqtt** (``pip install paho-mqtt``) - - Triggers: - - * :class:`platypush.message.event.mqtt.MQTTMessageEvent` when a new - message is received on a subscribed topic. - """ def __init__( @@ -328,6 +318,7 @@ class MqttPlugin(RunnablePlugin): ), 'No host specified and no configured default host' kwargs = self.default_listener.configuration + on_message = on_message or self.on_mqtt_message() kwargs.update( { 'topics': topics, @@ -336,7 +327,6 @@ class MqttPlugin(RunnablePlugin): } ) - on_message = on_message or self.on_mqtt_message() client_id = self._get_client_id( host=kwargs['host'], port=kwargs['port'], diff --git a/platypush/plugins/music/mpd/__init__.py b/platypush/plugins/music/mpd/__init__.py index e5d432443..d7acfaf09 100644 --- a/platypush/plugins/music/mpd/__init__.py +++ b/platypush/plugins/music/mpd/__init__.py @@ -19,11 +19,6 @@ class MusicMpdPlugin(MusicPlugin): **NOTE**: As of Mopidy 3.0 MPD is an optional interface provided by the ``mopidy-mpd`` extension. Make sure that you have the extension installed and enabled on your instance to use this plugin with your server. - - Requires: - - * **python-mpd2** (``pip install python-mpd2``) - """ _client_lock = threading.RLock() @@ -58,8 +53,11 @@ class MusicMpdPlugin(MusicPlugin): return self.client except Exception as e: error = e - self.logger.warning('Connection exception: {}{}'. - format(str(e), (': Retrying' if n_tries > 0 else ''))) + self.logger.warning( + 'Connection exception: {}{}'.format( + str(e), (': Retrying' if n_tries > 0 else '') + ) + ) time.sleep(0.5) self.client = None @@ -69,8 +67,9 @@ class MusicMpdPlugin(MusicPlugin): def _exec(self, method, *args, **kwargs): error = None n_tries = int(kwargs.pop('n_tries')) if 'n_tries' in kwargs else 2 - return_status = kwargs.pop('return_status') \ - if 'return_status' in kwargs else True + return_status = ( + kwargs.pop('return_status') if 'return_status' in kwargs else True + ) while n_tries > 0: try: @@ -84,8 +83,9 @@ class MusicMpdPlugin(MusicPlugin): return response except Exception as e: error = str(e) - self.logger.warning('Exception while executing MPD method {}: {}'. - format(method, error)) + self.logger.warning( + 'Exception while executing MPD method {}: {}'.format(method, error) + ) self.client = None return None, error @@ -117,7 +117,7 @@ class MusicMpdPlugin(MusicPlugin): @action def pause(self): - """ Pause playback """ + """Pause playback""" status = self.status().output['state'] if status == 'play': @@ -127,7 +127,7 @@ class MusicMpdPlugin(MusicPlugin): @action def pause_if_playing(self): - """ Pause playback only if it's playing """ + """Pause playback only if it's playing""" status = self.status().output['state'] if status == 'play': @@ -135,7 +135,7 @@ class MusicMpdPlugin(MusicPlugin): @action def play_if_paused(self): - """ Play only if it's paused (resume) """ + """Play only if it's paused (resume)""" status = self.status().output['state'] if status == 'pause': @@ -143,7 +143,7 @@ class MusicMpdPlugin(MusicPlugin): @action def play_if_paused_or_stopped(self): - """ Play only if it's paused or stopped """ + """Play only if it's paused or stopped""" status = self.status().output['state'] if status == 'pause' or status == 'stop': @@ -151,12 +151,12 @@ class MusicMpdPlugin(MusicPlugin): @action def stop(self): - """ Stop playback """ + """Stop playback""" return self._exec('stop') @action def play_or_stop(self): - """ Play or stop (play state toggle) """ + """Play or stop (play state toggle)""" status = self.status().output['state'] if status == 'play': return self._exec('stop') @@ -176,12 +176,12 @@ class MusicMpdPlugin(MusicPlugin): @action def next(self): - """ Play the next track """ + """Play the next track""" return self._exec('next') @action def previous(self): - """ Play the previous track """ + """Play the previous track""" return self._exec('previous') @action @@ -416,7 +416,7 @@ class MusicMpdPlugin(MusicPlugin): @action def clear(self): - """ Clear the current playlist """ + """Clear the current playlist""" return self._exec('clear') @action @@ -443,13 +443,13 @@ class MusicMpdPlugin(MusicPlugin): @action def forward(self): - """ Go forward by 15 seconds """ + """Go forward by 15 seconds""" return self._exec('seekcur', '+15') @action def back(self): - """ Go backward by 15 seconds """ + """Go backward by 15 seconds""" return self._exec('seekcur', '-15') @@ -492,8 +492,9 @@ class MusicMpdPlugin(MusicPlugin): return self.client.status() except Exception as e: error = e - self.logger.warning('Exception while getting MPD status: {}'. - format(str(e))) + self.logger.warning( + 'Exception while getting MPD status: {}'.format(str(e)) + ) self.client = None return None, error @@ -529,10 +530,12 @@ class MusicMpdPlugin(MusicPlugin): """ track = self._exec('currentsong', return_status=False) - if 'title' in track and ('artist' not in track - or not track['artist'] - or re.search('^https?://', track['file']) - or re.search('^tunein:', track['file'])): + if 'title' in track and ( + 'artist' not in track + or not track['artist'] + or re.search('^https?://', track['file']) + or re.search('^tunein:', track['file']) + ): m = re.match(r'^\s*(.+?)\s+-\s+(.*)\s*$', track['title']) if m and m.group(1) and m.group(2): track['artist'] = m.group(1) @@ -600,8 +603,10 @@ class MusicMpdPlugin(MusicPlugin): } ] """ - return sorted(self._exec('listplaylists', return_status=False), - key=lambda p: p['playlist']) + return sorted( + self._exec('listplaylists', return_status=False), + key=lambda p: p['playlist'], + ) @action def listplaylists(self): @@ -622,7 +627,9 @@ class MusicMpdPlugin(MusicPlugin): """ return self._exec( 'listplaylistinfo' if with_tracks else 'listplaylist', - playlist, return_status=False) + playlist, + return_status=False, + ) @action def listplaylist(self, name): @@ -742,8 +749,11 @@ class MusicMpdPlugin(MusicPlugin): Returns the list of playlists and directories on the server """ - return self._exec('lsinfo', uri, return_status=False) \ - if uri else self._exec('lsinfo', return_status=False) + return ( + self._exec('lsinfo', uri, return_status=False) + if uri + else self._exec('lsinfo', return_status=False) + ) @action def plchanges(self, version): @@ -768,10 +778,13 @@ class MusicMpdPlugin(MusicPlugin): :type name: str """ - playlists = list(map(lambda _: _['playlist'], - filter(lambda playlist: - name.lower() in playlist['playlist'].lower(), - self._exec('listplaylists', return_status=False)))) + playlists = [ + pl['playlist'] + for pl in filter( + lambda playlist: name.lower() in playlist['playlist'].lower(), + self._exec('listplaylists', return_status=False), + ) + ] if len(playlists): self._exec('clear') @@ -814,7 +827,13 @@ class MusicMpdPlugin(MusicPlugin): # noinspection PyShadowingBuiltins @action - def search(self, query: Optional[Union[str, dict]] = None, filter: Optional[dict] = None, *args, **kwargs): + def search( + self, + query: Optional[Union[str, dict]] = None, + filter: Optional[dict] = None, + *args, + **kwargs + ): """ Free search by filter. @@ -827,7 +846,9 @@ class MusicMpdPlugin(MusicPlugin): items = self._exec('search', *filter, *args, return_status=False, **kwargs) # Spotify results first - return sorted(items, key=lambda item: 0 if item['file'].startswith('spotify:') else 1) + return sorted( + items, key=lambda item: 0 if item['file'].startswith('spotify:') else 1 + ) # noinspection PyShadowingBuiltins @action diff --git a/platypush/plugins/music/tidal/__init__.py b/platypush/plugins/music/tidal/__init__.py index c269db5f9..7e3a2e351 100644 --- a/platypush/plugins/music/tidal/__init__.py +++ b/platypush/plugins/music/tidal/__init__.py @@ -26,16 +26,6 @@ class MusicTidalPlugin(RunnablePlugin): Upon the first login, the application will prompt you with a link to connect to your Tidal account. Once authorized, you should no longer be required to explicitly login. - - Triggers: - - * :class:`platypush.message.event.music.TidalPlaylistUpdatedEvent`: when a user playlist - is updated. - - Requires: - - * **tidalapi** (``pip install 'tidalapi >= 0.7.0'``) - """ _base_url = 'https://api.tidalhifi.com/v1/' diff --git a/platypush/plugins/nextcloud/__init__.py b/platypush/plugins/nextcloud/__init__.py index 682c97d55..abf63c6aa 100644 --- a/platypush/plugins/nextcloud/__init__.py +++ b/platypush/plugins/nextcloud/__init__.py @@ -43,11 +43,6 @@ class Permission(IntEnum): class NextcloudPlugin(Plugin): """ Plugin to interact with a NextCloud instance. - - Requires: - - * **nextcloud-api-wrapper** (``pip install nextcloud-api-wrapper``) - """ def __init__( diff --git a/platypush/plugins/ngrok/__init__.py b/platypush/plugins/ngrok/__init__.py index b3ba367c4..115de07b3 100644 --- a/platypush/plugins/ngrok/__init__.py +++ b/platypush/plugins/ngrok/__init__.py @@ -2,8 +2,12 @@ import os from typing import Optional, Union, Callable from platypush.context import get_bus -from platypush.message.event.ngrok import NgrokProcessStartedEvent, NgrokTunnelStartedEvent, NgrokTunnelStoppedEvent, \ - NgrokProcessStoppedEvent +from platypush.message.event.ngrok import ( + NgrokProcessStartedEvent, + NgrokTunnelStartedEvent, + NgrokTunnelStoppedEvent, + NgrokProcessStoppedEvent, +) from platypush.plugins import Plugin, action from platypush.schemas.ngrok import NgrokTunnelSchema @@ -11,22 +15,15 @@ from platypush.schemas.ngrok import NgrokTunnelSchema class NgrokPlugin(Plugin): """ Plugin to dynamically create and manage network tunnels using `ngrok `_. - - Requires: - - * **pyngrok** (``pip install pyngrok``) - - Triggers: - - * :class:`platypush.message.event.ngrok.NgrokProcessStartedEvent` when the ``ngrok`` process is started. - * :class:`platypush.message.event.ngrok.NgrokProcessStoppedEvent` when the ``ngrok`` process is stopped. - * :class:`platypush.message.event.ngrok.NgrokTunnelStartedEvent` when a tunnel is started. - * :class:`platypush.message.event.ngrok.NgrokTunnelStoppedEvent` when a tunnel is stopped. - """ - def __init__(self, auth_token: Optional[str] = None, ngrok_bin: Optional[str] = None, region: Optional[str] = None, - **kwargs): + def __init__( + self, + auth_token: Optional[str] = None, + ngrok_bin: Optional[str] = None, + region: Optional[str] = None, + **kwargs, + ): """ :param auth_token: Specify the ``ngrok`` auth token, enabling authenticated features (e.g. more concurrent tunnels, custom subdomains, etc.). @@ -35,6 +32,7 @@ class NgrokPlugin(Plugin): :param region: ISO code of the region/country that should host the ``ngrok`` tunnel (default: ``us``). """ from pyngrok import conf, ngrok + super().__init__(**kwargs) conf.get_default().log_event_callback = self._get_event_callback() @@ -50,8 +48,7 @@ class NgrokPlugin(Plugin): @property def _active_tunnels_by_name(self) -> dict: return { - tunnel['name']: tunnel - for tunnel in self._active_tunnels_by_url.values() + tunnel['name']: tunnel for tunnel in self._active_tunnels_by_url.values() } def _get_event_callback(self) -> Callable: @@ -61,23 +58,23 @@ class NgrokPlugin(Plugin): if log.msg == 'client session established': get_bus().post(NgrokProcessStartedEvent()) elif log.msg == 'started tunnel': - # noinspection PyUnresolvedReferences - tunnel = dict( - name=log.name, - url=log.url, - protocol=log.url.split(':')[0] - ) + tunnel = { + 'name': log.name, + 'url': log.url, + 'protocol': log.url.split(':')[0], + } self._active_tunnels_by_url[tunnel['url']] = tunnel get_bus().post(NgrokTunnelStartedEvent(**tunnel)) elif ( - log.msg == 'end' and - int(getattr(log, 'status', 0)) == 204 and - getattr(log, 'pg', '').startswith('/api/tunnels') + log.msg == 'end' + and int(getattr(log, 'status', 0)) == 204 + and getattr(log, 'pg', '').startswith('/api/tunnels') ): - # noinspection PyUnresolvedReferences tunnel = log.pg.split('/')[-1] - tunnel = self._active_tunnels_by_name.pop(tunnel, self._active_tunnels_by_url.pop(tunnel, None)) + tunnel = self._active_tunnels_by_name.pop( + tunnel, self._active_tunnels_by_url.pop(tunnel, None) + ) if tunnel: get_bus().post(NgrokTunnelStoppedEvent(**tunnel)) elif log.msg == 'received stop request': @@ -86,8 +83,14 @@ class NgrokPlugin(Plugin): return callback @action - def create_tunnel(self, resource: Union[int, str] = 80, protocol: str = 'tcp', - name: Optional[str] = None, auth: Optional[str] = None, **kwargs) -> dict: + def create_tunnel( + self, + resource: Union[int, str] = 80, + protocol: str = 'tcp', + name: Optional[str] = None, + auth: Optional[str] = None, + **kwargs, + ) -> dict: """ Create an ``ngrok`` tunnel to the specified localhost port/protocol. @@ -110,6 +113,7 @@ class NgrokPlugin(Plugin): :return: .. schema:: ngrok.NgrokTunnelSchema """ from pyngrok import ngrok + if isinstance(resource, str) and resource.startswith('file://'): protocol = None @@ -128,7 +132,9 @@ class NgrokPlugin(Plugin): if tunnel in self._active_tunnels_by_name: tunnel = self._active_tunnels_by_name[tunnel]['url'] - assert tunnel in self._active_tunnels_by_url, f'No such tunnel URL or name: {tunnel}' + assert ( + tunnel in self._active_tunnels_by_url + ), f'No such tunnel URL or name: {tunnel}' ngrok.disconnect(tunnel) @action @@ -139,6 +145,7 @@ class NgrokPlugin(Plugin): :return: .. schema:: ngrok.NgrokTunnelSchema(many=True) """ from pyngrok import ngrok + tunnels = ngrok.get_tunnels() return NgrokTunnelSchema().dump(tunnels, many=True) @@ -149,6 +156,7 @@ class NgrokPlugin(Plugin): The process will stay alive until the Python interpreter is stopped or this action is invoked. """ from pyngrok import ngrok + proc = ngrok.get_ngrok_process() assert proc and proc.proc, 'The ngrok process is not running' proc.proc.kill() diff --git a/platypush/plugins/ntfy/__init__.py b/platypush/plugins/ntfy/__init__.py index a7eddbadd..3ca3b8f1b 100644 --- a/platypush/plugins/ntfy/__init__.py +++ b/platypush/plugins/ntfy/__init__.py @@ -19,11 +19,6 @@ class NtfyPlugin(AsyncRunnablePlugin): `ntfy `_ allows you to process asynchronous notification across multiple devices and it's compatible with the `UnifiedPush ` specification. - - Triggers: - - * :class:`platypush.message.event.ntfy.NotificationEvent` when a new notification is received. - """ def __init__( diff --git a/platypush/plugins/otp/__init__.py b/platypush/plugins/otp/__init__.py index 616a447ff..73f1aa818 100644 --- a/platypush/plugins/otp/__init__.py +++ b/platypush/plugins/otp/__init__.py @@ -12,14 +12,16 @@ class OtpPlugin(Plugin): """ This plugin can be used to generate OTP (One-Time Password) codes compatible with Google Authenticator and other 2FA (Two-Factor Authentication) applications. - - Requires: - - * **pyotp** (``pip install pyotp``) """ - def __init__(self, secret: Optional[str] = None, secret_path: Optional[str] = None, - provisioning_name: Optional[str] = None, issuer_name: Optional[str] = None, **kwargs): + def __init__( + self, + secret: Optional[str] = None, + secret_path: Optional[str] = None, + provisioning_name: Optional[str] = None, + issuer_name: Optional[str] = None, + **kwargs + ): """ :param secret: Base32-encoded secret to be used for password generation. :param secret_path: If no secret is provided statically, then it will be read from this path @@ -48,7 +50,9 @@ class OtpPlugin(Plugin): return secret - def _get_secret(self, secret: Optional[str] = None, secret_path: Optional[str] = None) -> str: + def _get_secret( + self, secret: Optional[str] = None, secret_path: Optional[str] = None + ) -> str: if secret: return secret if secret_path: @@ -60,10 +64,14 @@ class OtpPlugin(Plugin): raise AssertionError('No secret nor secret_file specified') - def _get_topt(self, secret: Optional[str] = None, secret_path: Optional[str] = None) -> pyotp.TOTP: + def _get_topt( + self, secret: Optional[str] = None, secret_path: Optional[str] = None + ) -> pyotp.TOTP: return pyotp.TOTP(self._get_secret(secret, secret_path)) - def _get_hopt(self, secret: Optional[str] = None, secret_path: Optional[str] = None) -> pyotp.HOTP: + def _get_hopt( + self, secret: Optional[str] = None, secret_path: Optional[str] = None + ) -> pyotp.HOTP: return pyotp.HOTP(self._get_secret(secret, secret_path)) @action @@ -77,15 +85,20 @@ class OtpPlugin(Plugin): secret_path = secret_path or self.secret_path assert secret_path, 'No secret_path configured' - os.makedirs(os.path.dirname(os.path.abspath(os.path.expanduser(secret_path))), exist_ok=True) + os.makedirs( + os.path.dirname(os.path.abspath(os.path.expanduser(secret_path))), + exist_ok=True, + ) secret = pyotp.random_base32() with open(secret_path, 'w') as f: - f.writelines([secret]) # lgtm [py/clear-text-storage-sensitive-data] + f.writelines([secret]) # lgtm [py/clear-text-storage-sensitive-data] os.chmod(secret_path, 0o600) return secret @action - def get_time_otp(self, secret: Optional[str] = None, secret_path: Optional[str] = None) -> str: + def get_time_otp( + self, secret: Optional[str] = None, secret_path: Optional[str] = None + ) -> str: """ :param secret: Secret token to be used (overrides configured ``secret``). :param secret_path: File containing the secret to be used (overrides configured ``secret_path``). @@ -95,7 +108,12 @@ class OtpPlugin(Plugin): return otp.now() @action - def get_counter_otp(self, count: int, secret: Optional[str] = None, secret_path: Optional[str] = None) -> str: + def get_counter_otp( + self, + count: int, + secret: Optional[str] = None, + secret_path: Optional[str] = None, + ) -> str: """ :param count: Index for the counter-OTP. :param secret: Secret token to be used (overrides configured ``secret``). @@ -106,7 +124,9 @@ class OtpPlugin(Plugin): return otp.at(count) @action - def verify_time_otp(self, otp: str, secret: Optional[str] = None, secret_path: Optional[str] = None) -> bool: + def verify_time_otp( + self, otp: str, secret: Optional[str] = None, secret_path: Optional[str] = None + ) -> bool: """ Verify a code against a stored time-OTP. @@ -119,8 +139,13 @@ class OtpPlugin(Plugin): return _otp.verify(otp) @action - def verify_counter_otp(self, otp: str, count: int, secret: Optional[str] = None, - secret_path: Optional[str] = None) -> bool: + def verify_counter_otp( + self, + otp: str, + count: int, + secret: Optional[str] = None, + secret_path: Optional[str] = None, + ) -> bool: """ Verify a code against a stored counter-OTP. @@ -134,8 +159,13 @@ class OtpPlugin(Plugin): return _otp.verify(otp, count) @action - def provision_time_otp(self, name: Optional[str] = None, issuer_name: Optional[str] = None, - secret: Optional[str] = None, secret_path: Optional[str] = None) -> str: + def provision_time_otp( + self, + name: Optional[str] = None, + issuer_name: Optional[str] = None, + secret: Optional[str] = None, + secret_path: Optional[str] = None, + ) -> str: """ Generate a provisioning URI for a time-OTP that can be imported in Google Authenticator. @@ -154,8 +184,14 @@ class OtpPlugin(Plugin): return _otp.provisioning_uri(name, issuer_name=issuer_name) @action - def provision_counter_otp(self, name: Optional[str] = None, issuer_name: Optional[str] = None, initial_count=0, - secret: Optional[str] = None, secret_path: Optional[str] = None) -> str: + def provision_counter_otp( + self, + name: Optional[str] = None, + issuer_name: Optional[str] = None, + initial_count=0, + secret: Optional[str] = None, + secret_path: Optional[str] = None, + ) -> str: """ Generate a provisioning URI for a counter-OTP that can be imported in Google Authenticator. @@ -172,7 +208,9 @@ class OtpPlugin(Plugin): assert name, 'No account name or default provisioning address provided' _otp = self._get_hopt(secret, secret_path) - return _otp.provisioning_uri(name, issuer_name=issuer_name, initial_count=initial_count) + return _otp.provisioning_uri( + name, issuer_name=issuer_name, initial_count=initial_count + ) # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/printer/cups/__init__.py b/platypush/plugins/printer/cups/__init__.py index e7f7f4036..d6bd1e823 100644 --- a/platypush/plugins/printer/cups/__init__.py +++ b/platypush/plugins/printer/cups/__init__.py @@ -2,21 +2,22 @@ import os from typing import Optional, Dict, Any, List -from platypush.message.response.printer.cups import PrinterResponse, PrintersResponse, PrinterJobAddedResponse +from platypush.message.response.printer.cups import ( + PrinterResponse, + PrintersResponse, + PrinterJobAddedResponse, +) from platypush.plugins import Plugin, action class PrinterCupsPlugin(Plugin): """ A plugin to interact with a CUPS printer server. - - Requires: - - - **pycups** (``pip install pycups``) - """ - def __init__(self, host: str = 'localhost', printer: Optional[str] = None, **kwargs): + def __init__( + self, host: str = 'localhost', printer: Optional[str] = None, **kwargs + ): """ :param host: CUPS host IP/name (default: localhost). :param printer: Default printer name that should be used. @@ -28,6 +29,7 @@ class PrinterCupsPlugin(Plugin): def _get_connection(self, host: Optional[str] = None): # noinspection PyPackageRequirements import cups + connection = cups.Connection(host=host or self.host) return connection @@ -44,25 +46,29 @@ class PrinterCupsPlugin(Plugin): :return: :class:`platypush.message.response.printer.cups.PrintersResponse`, as a name -> attributes dict. """ conn = self._get_connection(host) - return PrintersResponse(printers=[ - PrinterResponse( - name=name, - printer_type=printer.get('printer-type'), - info=printer.get('printer-info'), - uri=printer.get('device-uri'), - state=printer.get('printer-state'), - is_shared=printer.get('printer-is-shared'), - state_message=printer.get('printer-state-message'), - state_reasons=printer.get('printer-state-reasons', []), - location=printer.get('printer-location'), - uri_supported=printer.get('printer-uri-supported'), - make_and_model=printer.get('printer-make-and-model'), - ) - for name, printer in conn.getPrinters().items() - ]) + return PrintersResponse( + printers=[ + PrinterResponse( + name=name, + printer_type=printer.get('printer-type'), + info=printer.get('printer-info'), + uri=printer.get('device-uri'), + state=printer.get('printer-state'), + is_shared=printer.get('printer-is-shared'), + state_message=printer.get('printer-state-message'), + state_reasons=printer.get('printer-state-reasons', []), + location=printer.get('printer-location'), + uri_supported=printer.get('printer-uri-supported'), + make_and_model=printer.get('printer-make-and-model'), + ) + for name, printer in conn.getPrinters().items() + ] + ) @action - def print_test_page(self, printer: Optional[str] = None, host: Optional[str] = None) -> PrinterJobAddedResponse: + def print_test_page( + self, printer: Optional[str] = None, host: Optional[str] = None + ) -> PrinterJobAddedResponse: """ Print the CUPS test page. @@ -75,12 +81,14 @@ class PrinterCupsPlugin(Plugin): return PrinterJobAddedResponse(printer=printer, job_id=job_id) @action - def print_file(self, - filename: str, - printer: Optional[str] = None, - host: Optional[str] = None, - title: Optional[str] = None, - options: Optional[Dict[str, Any]] = None) -> PrinterJobAddedResponse: + def print_file( + self, + filename: str, + printer: Optional[str] = None, + host: Optional[str] = None, + title: Optional[str] = None, + options: Optional[Dict[str, Any]] = None, + ) -> PrinterJobAddedResponse: """ Print a file. @@ -93,16 +101,20 @@ class PrinterCupsPlugin(Plugin): filename = os.path.abspath(os.path.expanduser(filename)) conn = self._get_connection(host) printer = self._get_printer(printer) - job_id = conn.printFile(printer, filename=filename, title=title or '', options=options or {}) + job_id = conn.printFile( + printer, filename=filename, title=title or '', options=options or {} + ) return PrinterJobAddedResponse(printer=printer, job_id=job_id) @action - def print_files(self, - filenames: List[str], - printer: Optional[str] = None, - host: Optional[str] = None, - title: Optional[str] = None, - options: Optional[Dict[str, Any]] = None) -> PrinterJobAddedResponse: + def print_files( + self, + filenames: List[str], + printer: Optional[str] = None, + host: Optional[str] = None, + title: Optional[str] = None, + options: Optional[Dict[str, Any]] = None, + ) -> PrinterJobAddedResponse: """ Print a list of files. @@ -115,16 +127,20 @@ class PrinterCupsPlugin(Plugin): filenames = [os.path.abspath(os.path.expanduser(f)) for f in filenames] conn = self._get_connection(host) printer = self._get_printer(printer) - job_id = conn.printFiles(printer, filenames=filenames, title=title or '', options=options or {}) + job_id = conn.printFiles( + printer, filenames=filenames, title=title or '', options=options or {} + ) return PrinterJobAddedResponse(printer=printer, job_id=job_id) @action - def add_printer(self, - name: str, - ppd_file: str, - info: str, - location: Optional[str] = None, - host: Optional[str] = None): + def add_printer( + self, + name: str, + ppd_file: str, + info: str, + location: Optional[str] = None, + host: Optional[str] = None, + ): """ Add a printer. @@ -163,7 +179,9 @@ class PrinterCupsPlugin(Plugin): conn.enablePrinter(printer) @action - def disable_printer(self, printer: Optional[str] = None, host: Optional[str] = None): + def disable_printer( + self, printer: Optional[str] = None, host: Optional[str] = None + ): """ Disable a printer on a CUPS server. @@ -210,7 +228,9 @@ class PrinterCupsPlugin(Plugin): conn.rejectJobs(printer) @action - def cancel_job(self, job_id: int, purge_job: bool = False, host: Optional[str] = None): + def cancel_job( + self, job_id: int, purge_job: bool = False, host: Optional[str] = None + ): """ Cancel a printer job. @@ -222,11 +242,13 @@ class PrinterCupsPlugin(Plugin): conn.cancelJob(job_id, purge_job=purge_job) @action - def move_job(self, - job_id: int, - source_printer_uri: str, - target_printer_uri: str, - host: Optional[str] = None): + def move_job( + self, + job_id: int, + source_printer_uri: str, + target_printer_uri: str, + host: Optional[str] = None, + ): """ Move a job to another printer/URI. @@ -236,10 +258,16 @@ class PrinterCupsPlugin(Plugin): :param host: CUPS server IP/name (default: default configured ``host``). """ conn = self._get_connection(host) - conn.moveJob(printer_uri=source_printer_uri, job_id=job_id, job_printer_uri=target_printer_uri) + conn.moveJob( + printer_uri=source_printer_uri, + job_id=job_id, + job_printer_uri=target_printer_uri, + ) @action - def finish_document(self, printer: Optional[str] = None, host: Optional[str] = None): + def finish_document( + self, printer: Optional[str] = None, host: Optional[str] = None + ): """ Finish sending a document to a printer. @@ -251,10 +279,12 @@ class PrinterCupsPlugin(Plugin): conn.finishDocument(printer) @action - def add_printer_to_class(self, - printer_class: str, - printer: Optional[str] = None, - host: Optional[str] = None): + def add_printer_to_class( + self, + printer_class: str, + printer: Optional[str] = None, + host: Optional[str] = None, + ): """ Add a printer to a class. @@ -267,10 +297,12 @@ class PrinterCupsPlugin(Plugin): conn.addPrinterToClass(printer, printer_class) @action - def delete_printer_from_class(self, - printer_class: str, - printer: Optional[str] = None, - host: Optional[str] = None): + def delete_printer_from_class( + self, + printer_class: str, + printer: Optional[str] = None, + host: Optional[str] = None, + ): """ Delete a printer from a class. diff --git a/platypush/plugins/pwm/pca9685/__init__.py b/platypush/plugins/pwm/pca9685/__init__.py index 6da28116d..413259c49 100644 --- a/platypush/plugins/pwm/pca9685/__init__.py +++ b/platypush/plugins/pwm/pca9685/__init__.py @@ -28,34 +28,41 @@ class PwmPca9685Plugin(Plugin): # pip3 install --upgrade adafruit-circuitpython-pca9685 This plugin works with a PCA9685 circuit connected to the Platypush host over I2C interface. - - Requires: - - - **adafruit-circuitpython-pca9685** (``pip install adafruit-circuitpython-pca9685``) - """ - def __init__(self, frequency: float, min_duty_cycle: int = 0, max_duty_cycle: int = 0xffff, channels: Iterable[int] = tuple(range(16)), **kwargs): + def __init__( + self, + frequency: float, + min_duty_cycle: int = 0, + max_duty_cycle: int = 0xFFFF, + channels: Optional[Iterable[int]] = None, + **kwargs + ): """ :param frequency: Default PWM frequency to use for the driver, in Hz. :param min_duty_cycle: Minimum PWM duty cycle (you can often find it in the documentation of your device). Default: 0. :param max_duty_cycle: Maximum PWM duty cycle (you can often find it in the documentation of your device). Default: 0xffff. - :param Indices of the default channels to be controlled (default: all channels, + :param channels: Indices of the default channels to be controlled (default: all channels, i.e. ``[0-15]``). """ super().__init__(**kwargs) self.frequency = frequency self.min_duty_cycle = min_duty_cycle self.max_duty_cycle = max_duty_cycle - self.channels = channels + self.channels = channels or tuple(range(16)) self._pca = None @action - def write(self, value: Optional[int] = None, channels: Optional[Dict[int, float]] = None, - frequency: Optional[float] = None, step: Optional[int] = None, - step_duration: Optional[float] = None): + def write( + self, + value: Optional[int] = None, + channels: Optional[Dict[int, float]] = None, + frequency: Optional[float] = None, + step: Optional[int] = None, + step_duration: Optional[float] = None, + ): """ Send PWM values to the channels. @@ -85,7 +92,7 @@ class PwmPca9685Plugin(Plugin): i2c_bus = busio.I2C(SCL, SDA) pca = self._pca = self._pca or PCA9685(i2c_bus) pca.frequency = frequency or self.frequency - step_duration = step_duration or 1/pca.frequency + step_duration = step_duration or 1 / pca.frequency if not step: for i, val in channels.items(): @@ -93,10 +100,7 @@ class PwmPca9685Plugin(Plugin): return done = False - cur_values = { - i: channel.duty_cycle - for i, channel in enumerate(pca.channels) - } + cur_values = {i: channel.duty_cycle for i, channel in enumerate(pca.channels)} while not done: done = True @@ -106,9 +110,11 @@ class PwmPca9685Plugin(Plugin): continue done = False - val = min(cur_values[i] + step, val, self.max_duty_cycle) \ - if val > pca.channels[i].duty_cycle \ - else max(cur_values[i] - step, val, self.min_duty_cycle) + val = ( + min(cur_values[i] + step, val, self.max_duty_cycle) + if val > pca.channels[i].duty_cycle + else max(cur_values[i] - step, val, self.min_duty_cycle) + ) pca.channels[i].duty_cycle = cur_values[i] = val @@ -125,10 +131,7 @@ class PwmPca9685Plugin(Plugin): if not self._pca: return {i: 0 for i in self.channels} - return { - i: channel.duty_cycle - for i, channel in enumerate(self._pca.channels) - } + return {i: channel.duty_cycle for i, channel in enumerate(self._pca.channels)} @action def deinit(self): @@ -152,4 +155,3 @@ class PwmPca9685Plugin(Plugin): return self._pca.reset() - diff --git a/platypush/plugins/qrcode/__init__.py b/platypush/plugins/qrcode/__init__.py index 93bfc1d47..b211cb23c 100644 --- a/platypush/plugins/qrcode/__init__.py +++ b/platypush/plugins/qrcode/__init__.py @@ -21,14 +21,6 @@ from platypush.utils import get_plugin_class_by_name class QrcodePlugin(Plugin): """ Plugin to generate and scan QR and bar codes. - - Requires: - - * **numpy** (``pip install numpy``). - * **qrcode** (``pip install 'qrcode[pil]'``) for QR generation. - * **pyzbar** (``pip install pyzbar``) for decoding code from images. - * **Pillow** (``pip install Pillow``) for image management. - """ def __init__(self, camera_plugin: Optional[str] = None, **kwargs): @@ -141,10 +133,6 @@ class QrcodePlugin(Plugin): """ Decode QR-codes and bar codes using a camera. - Triggers: - - - :class:`platypush.message.event.qrcode.QrcodeScannedEvent` when a code is successfully scanned. - :param camera_plugin: Camera plugin (overrides default ``camera_plugin``). :param duration: How long the capturing phase should run (default: until ``stop_scanning`` or app termination). :param n_codes: Stop after decoding this number of codes (default: None). diff --git a/platypush/plugins/qrcode/manifest.yaml b/platypush/plugins/qrcode/manifest.yaml index 4aab80ebd..faee6bfbf 100644 --- a/platypush/plugins/qrcode/manifest.yaml +++ b/platypush/plugins/qrcode/manifest.yaml @@ -1,5 +1,6 @@ manifest: - events: {} + events: + - platypush.message.event.qrcode.QrcodeScannedEvent install: apk: - py3-numpy diff --git a/platypush/plugins/rss/__init__.py b/platypush/plugins/rss/__init__.py index 1db8b34ee..4df6a2462 100644 --- a/platypush/plugins/rss/__init__.py +++ b/platypush/plugins/rss/__init__.py @@ -27,16 +27,6 @@ def _variable() -> VariablePlugin: class RssPlugin(RunnablePlugin): """ A plugin for parsing and subscribing to RSS feeds. - - Triggers: - - - :class:`platypush.message.event.rss.NewFeedEntryEvent` when a new entry is received on a subscribed feed. - - Requires: - - * **feedparser** (``pip install feedparser``) - * **defusedxml** (``pip install defusedxml``) - """ user_agent = ( diff --git a/platypush/plugins/rtorrent/__init__.py b/platypush/plugins/rtorrent/__init__.py index 3fb76f75e..a9bce8eb5 100644 --- a/platypush/plugins/rtorrent/__init__.py +++ b/platypush/plugins/rtorrent/__init__.py @@ -11,17 +11,26 @@ from typing import List, Optional from platypush.context import get_bus from platypush.plugins import action from platypush.plugins.torrent import TorrentPlugin -from platypush.message.event.torrent import \ - TorrentDownloadStartEvent, TorrentDownloadedMetadataEvent, TorrentDownloadProgressEvent, \ - TorrentDownloadCompletedEvent, TorrentPausedEvent, TorrentResumedEvent, TorrentQueuedEvent, TorrentRemovedEvent, \ - TorrentEvent +from platypush.message.event.torrent import ( + TorrentDownloadStartEvent, + TorrentDownloadedMetadataEvent, + TorrentDownloadProgressEvent, + TorrentDownloadCompletedEvent, + TorrentPausedEvent, + TorrentResumedEvent, + TorrentQueuedEvent, + TorrentRemovedEvent, + TorrentEvent, +) class RtorrentPlugin(TorrentPlugin): """ Plugin to interact search, download and manage torrents through RTorrent. - The usage of this plugin is advised over :class:`platypush.plugins.torrent.TorrentPlugin`, as RTorrent is a more - flexible and optimized solution for downloading and managing torrents compared to the Platypush native plugin. + + You may prefer the built-in :class:`platypush.plugins.torrent.TorrentPlugin` over this one, unless you have heavy + dependencies on RTorrent, as quite some extra configuration is required to enable RTorrent's RPC API - + which is required to communicate with this integration. Configuration: @@ -105,21 +114,15 @@ class RtorrentPlugin(TorrentPlugin): - In this example, the URL to configure in the plugin would be ``http://localhost:5000/RPC2``. - Triggers: - - * :class:`platypush.message.event.torrent.TorrentQueuedEvent` when a new torrent transfer is queued. - * :class:`platypush.message.event.torrent.TorrentRemovedEvent` when a torrent transfer is removed. - * :class:`platypush.message.event.torrent.TorrentDownloadStartEvent` when a torrent transfer starts. - * :class:`platypush.message.event.torrent.TorrentDownloadedMetadataEvent` when the metadata of a torrent - transfer has been downloaded. - * :class:`platypush.message.event.torrent.TorrentDownloadProgressEvent` when a transfer is progressing. - * :class:`platypush.message.event.torrent.TorrentPausedEvent` when a transfer is paused. - * :class:`platypush.message.event.torrent.TorrentResumedEvent` when a transfer is resumed. - * :class:`platypush.message.event.torrent.TorrentDownloadCompletedEvent` when a transfer is completed. - """ - def __init__(self, url: str, poll_seconds: float = 5.0, download_dir: str = '~/.rtorrent/watch', **kwargs): + def __init__( + self, + url: str, + poll_seconds: float = 5.0, + download_dir: str = '~/.rtorrent/watch', + **kwargs + ): """ :param url: HTTP URL that exposes the XML/RPC interface of RTorrent (e.g. ``http://localhost:5000/RPC2``). :param poll_seconds: How often the plugin will monitor for changes in the torrent state (default: 5 seconds). @@ -174,9 +177,8 @@ class RtorrentPlugin(TorrentPlugin): elif not is_active and last_status.get('is_active'): self._fire_event(TorrentPausedEvent(**status)) - if progress > 0: - if progress > last_status.get('progress', 0): - self._fire_event(TorrentDownloadProgressEvent(**status)) + if progress > 0 and progress > last_status.get('progress', 0): + self._fire_event(TorrentDownloadProgressEvent(**status)) if finish_date and not last_status.get('finish_date'): self._fire_event(TorrentDownloadCompletedEvent(**status)) @@ -194,7 +196,10 @@ class RtorrentPlugin(TorrentPlugin): torrent_hashes = set(statuses.keys()).union(last_statuses.keys()) for torrent_hash in torrent_hashes: - self._process_events(statuses.get(torrent_hash, {}), last_statuses.get(torrent_hash, {})) + self._process_events( + statuses.get(torrent_hash, {}), + last_statuses.get(torrent_hash, {}), + ) except Exception as e: self.logger.warning('Error while monitoring torrent status') self.logger.exception(e) @@ -252,10 +257,16 @@ class RtorrentPlugin(TorrentPlugin): m = re.search(r'xt=urn:btih:([^&/]+)', torrent) assert m, 'Invalid magnet link: {}'.format(torrent) torrent_hash = m.group(1) - torrent_file = os.path.join(self.torrent_files_dir, '{}.torrent'.format(torrent_hash)) + torrent_file = os.path.join( + self.torrent_files_dir, '{}.torrent'.format(torrent_hash) + ) with open(torrent_file, 'w') as f: - f.write('d10:magnet-uri{length}:{info}e'.format(length=len(torrent), info=torrent)) + f.write( + 'd10:magnet-uri{length}:{info}e'.format( + length=len(torrent), info=torrent + ) + ) self._torrent_urls[torrent_hash] = torrent return torrent_file @@ -277,7 +288,9 @@ class RtorrentPlugin(TorrentPlugin): torrent_file = os.path.abspath(os.path.expanduser(torrent)) assert os.path.isfile(torrent_file), 'No such torrent file: {}'.format(torrent) - self._torrent_urls[os.path.basename(torrent_file).split('.')[0]] = 'file://' + torrent + self._torrent_urls[os.path.basename(torrent_file).split('.')[0]] = ( + 'file://' + torrent + ) return torrent_file @action @@ -344,12 +357,40 @@ class RtorrentPlugin(TorrentPlugin): } """ - attrs = ['hash', 'name', 'save_path', 'is_active', 'is_open', 'completed_bytes', 'download_rate', - 'is_multi_file', 'remaining_bytes', 'size_bytes', 'load_date', 'peers', 'start_date', - 'finish_date', 'upload_rate'] - cmds = ['d.hash=', 'd.name=', 'd.directory=', 'd.is_active=', 'd.is_open=', 'd.completed_bytes=', - 'd.down.rate=', 'd.is_multi_file=', 'd.left_bytes=', 'd.size_bytes=', 'd.load_date=', - 'd.peers_connected=', 'd.timestamp.started=', 'd.timestamp.finished=', 'd.up.rate='] + attrs = [ + 'hash', + 'name', + 'save_path', + 'is_active', + 'is_open', + 'completed_bytes', + 'download_rate', + 'is_multi_file', + 'remaining_bytes', + 'size_bytes', + 'load_date', + 'peers', + 'start_date', + 'finish_date', + 'upload_rate', + ] + cmds = [ + 'd.hash=', + 'd.name=', + 'd.directory=', + 'd.is_active=', + 'd.is_open=', + 'd.completed_bytes=', + 'd.down.rate=', + 'd.is_multi_file=', + 'd.left_bytes=', + 'd.size_bytes=', + 'd.load_date=', + 'd.peers_connected=', + 'd.timestamp.started=', + 'd.timestamp.finished=', + 'd.up.rate=', + ] mappers = { 'is_active': lambda v: bool(v), @@ -370,11 +411,17 @@ class RtorrentPlugin(TorrentPlugin): } for torrent_id, info in torrents.items(): - torrents[torrent_id]['progress'] = round(100. * (info['completed_bytes']/info['size_bytes']), 1) + torrents[torrent_id]['progress'] = round( + 100.0 * (info['completed_bytes'] / info['size_bytes']), 1 + ) torrents[torrent_id]['url'] = self._torrent_urls.get(torrent_id, torrent_id) torrents[torrent_id]['is_paused'] = not info['is_active'] - torrents[torrent_id]['paused'] = not info['is_active'] # Back compatibility with TorrentPlugin - torrents[torrent_id]['size'] = info['size_bytes'] # Back compatibility with TorrentPlugin + torrents[torrent_id]['paused'] = not info[ + 'is_active' + ] # Back compatibility with TorrentPlugin + torrents[torrent_id]['size'] = info[ + 'size_bytes' + ] # Back compatibility with TorrentPlugin torrents[torrent_id]['files'] = [] if not info['is_open']: @@ -385,8 +432,11 @@ class RtorrentPlugin(TorrentPlugin): torrents[torrent_id]['state'] = 'downloading' if info.get('save_path'): - torrents[torrent_id]['files'] = list(str(f) for f in Path(info['save_path']).rglob('*')) \ - if info.get('is_multi_file') else info['save_path'] + torrents[torrent_id]['files'] = ( + [str(f) for f in Path(info['save_path']).rglob('*')] + if info.get('is_multi_file') + else info['save_path'] + ) return torrents.get(torrent, {}) if torrent else torrents diff --git a/platypush/plugins/sensor/__init__.py b/platypush/plugins/sensor/__init__.py index c0559709e..dd52809fb 100644 --- a/platypush/plugins/sensor/__init__.py +++ b/platypush/plugins/sensor/__init__.py @@ -23,13 +23,6 @@ class SensorPlugin(RunnablePlugin, SensorEntityManager, ABC): """ Sensor abstract plugin. Any plugin that interacts with sensors should implement this class. - - Triggers: - - * :class:`platypush.message.event.sensor.SensorDataAboveThresholdEvent` - * :class:`platypush.message.event.sensor.SensorDataBelowThresholdEvent` - * :class:`platypush.message.event.sensor.SensorDataChangeEvent` - """ _max_retry_secs = 60.0 diff --git a/platypush/plugins/sensor/bme280/__init__.py b/platypush/plugins/sensor/bme280/__init__.py index cf81f2c79..f33c97184 100644 --- a/platypush/plugins/sensor/bme280/__init__.py +++ b/platypush/plugins/sensor/bme280/__init__.py @@ -62,18 +62,7 @@ _sensor_entity_mappings = { class SensorBme280Plugin(SensorPlugin): """ Plugin to interact with a `BME280 `_ environment sensor for - temperature, humidity and pressure measurements over I2C interface - - Requires: - - * ``pimoroni-bme280`` (``pip install pimoroni-bme280``) - - Triggers: - - * :class:`platypush.message.event.sensor.SensorDataAboveThresholdEvent` - * :class:`platypush.message.event.sensor.SensorDataBelowThresholdEvent` - * :class:`platypush.message.event.sensor.SensorDataChangeEvent` - + temperature, humidity and pressure measurements over I2C interface. """ def __init__(self, port: int = 1, **kwargs): diff --git a/platypush/plugins/sensor/dht/__init__.py b/platypush/plugins/sensor/dht/__init__.py index a5f9d642b..80ec02fe2 100644 --- a/platypush/plugins/sensor/dht/__init__.py +++ b/platypush/plugins/sensor/dht/__init__.py @@ -12,17 +12,6 @@ from platypush.plugins.sensor import SensorPlugin class SensorDhtPlugin(SensorPlugin): """ Plugin to interact with a DHT11/DHT22/AM2302 temperature/humidity sensor through GPIO. - - Requires: - - * ``Adafruit_Python_DHT`` (``pip install git+https://github.com/adafruit/Adafruit_Python_DHT.git``) - - Triggers: - - * :class:`platypush.message.event.sensor.SensorDataAboveThresholdEvent` - * :class:`platypush.message.event.sensor.SensorDataBelowThresholdEvent` - * :class:`platypush.message.event.sensor.SensorDataChangeEvent` - """ def __init__( diff --git a/platypush/plugins/sensor/distance/vl53l1x/__init__.py b/platypush/plugins/sensor/distance/vl53l1x/__init__.py index 0fc373bfd..140c97f40 100644 --- a/platypush/plugins/sensor/distance/vl53l1x/__init__.py +++ b/platypush/plugins/sensor/distance/vl53l1x/__init__.py @@ -12,17 +12,7 @@ class SensorDistanceVl53l1xPlugin(SensorPlugin): """ Plugin to interact with an `VL53L1x `_ - laser ranger/distance sensor - - Requires: - - * ``smbus2`` (``pip install smbus2``) - * ``vl53l1x`` (``pip install vl53l1x``) - - Triggers: - - * :class:`platypush.message.event.sensor.SensorDataChangeEvent` - + laser ranger/distance sensor. """ def __init__(self, i2c_bus=1, i2c_address=0x29, poll_interval=3, **kwargs): diff --git a/platypush/plugins/sensor/envirophat/__init__.py b/platypush/plugins/sensor/envirophat/__init__.py index 366a8ae3c..02e365e4a 100644 --- a/platypush/plugins/sensor/envirophat/__init__.py +++ b/platypush/plugins/sensor/envirophat/__init__.py @@ -85,17 +85,6 @@ class SensorEnvirophatPlugin(SensorPlugin): You can use an enviropHAT device to read e.g. temperature, pressure, altitude, accelerometer, magnetometer and luminosity data. - - Requires: - - * ``envirophat`` (``pip install envirophat``) - - Triggers: - - * :class:`platypush.message.event.sensor.SensorDataAboveThresholdEvent` - * :class:`platypush.message.event.sensor.SensorDataBelowThresholdEvent` - * :class:`platypush.message.event.sensor.SensorDataChangeEvent` - """ @action diff --git a/platypush/plugins/sensor/hcsr04/__init__.py b/platypush/plugins/sensor/hcsr04/__init__.py index 2f69586cb..20cfe8a77 100644 --- a/platypush/plugins/sensor/hcsr04/__init__.py +++ b/platypush/plugins/sensor/hcsr04/__init__.py @@ -18,19 +18,6 @@ class SensorHcsr04Plugin(GpioPlugin, SensorPlugin): `_, but it should be compatible with any GPIO-compatible sensor that relies on the same trigger-and-echo principle. - - Requires: - - * ``RPi.GPIO`` (``pip install RPi.GPIO``) - - Triggers: - - * :class:`platypush.message.event.sensor.SensorDataAboveThresholdEvent` - * :class:`platypush.message.event.sensor.SensorDataBelowThresholdEvent` - * :class:`platypush.message.event.sensor.SensorDataChangeEvent` - * :class:`platypush.message.event.distance.DistanceSensorEvent` when a - new distance measurement is available (legacy event) - """ def __init__( diff --git a/platypush/plugins/sensor/lis3dh/__init__.py b/platypush/plugins/sensor/lis3dh/__init__.py index aee045c1c..b85a21432 100644 --- a/platypush/plugins/sensor/lis3dh/__init__.py +++ b/platypush/plugins/sensor/lis3dh/__init__.py @@ -11,17 +11,6 @@ class SensorLis3dhPlugin(SensorPlugin): Plugin to interact with an `Adafruit LIS3DH accelerometer `_ and get X,Y,Z measurement. Tested with a Raspberry Pi over I2C connection. - - Requires: - - * ``Adafruit-GPIO`` (``pip install Adafruit-GPIO``) - - Triggers: - - * :class:`platypush.message.event.sensor.SensorDataAboveThresholdEvent` - * :class:`platypush.message.event.sensor.SensorDataBelowThresholdEvent` - * :class:`platypush.message.event.sensor.SensorDataChangeEvent` - """ def __init__(self, g=4, precision=None, poll_interval=1, **kwargs): diff --git a/platypush/plugins/sensor/ltr559/__init__.py b/platypush/plugins/sensor/ltr559/__init__.py index b0280da0a..83dd6654b 100644 --- a/platypush/plugins/sensor/ltr559/__init__.py +++ b/platypush/plugins/sensor/ltr559/__init__.py @@ -12,19 +12,7 @@ from platypush.plugins.sensor import SensorPlugin class SensorLtr559Plugin(SensorPlugin): """ Plugin to interact with an `LTR559 `_ - light and proximity sensor - - Requires: - - * ``ltr559`` (``pip install ltr559``) - * ``smbus`` (``pip install smbus``) - - Triggers: - - * :class:`platypush.message.event.sensor.SensorDataAboveThresholdEvent` - * :class:`platypush.message.event.sensor.SensorDataBelowThresholdEvent` - * :class:`platypush.message.event.sensor.SensorDataChangeEvent` - + light and proximity sensor. """ def __init__(self, **kwargs): diff --git a/platypush/plugins/sensor/mcp3008/__init__.py b/platypush/plugins/sensor/mcp3008/__init__.py index c6f4443e6..5cc7244a9 100644 --- a/platypush/plugins/sensor/mcp3008/__init__.py +++ b/platypush/plugins/sensor/mcp3008/__init__.py @@ -26,17 +26,6 @@ class SensorMcp3008Plugin(SensorPlugin): Raspberry Pi or a regular laptop. See https://learn.adafruit.com/raspberry-pi-analog-to-digital-converters/mcp3008 for more info. - - Requires: - - * ``adafruit-mcp3008`` (``pip install adafruit-mcp3008``) - - Triggers: - - * :class:`platypush.message.event.sensor.SensorDataAboveThresholdEvent` - * :class:`platypush.message.event.sensor.SensorDataBelowThresholdEvent` - * :class:`platypush.message.event.sensor.SensorDataChangeEvent` - """ N_CHANNELS = 8 diff --git a/platypush/plugins/sensor/pmw3901/__init__.py b/platypush/plugins/sensor/pmw3901/__init__.py index d566de51b..7684cea74 100644 --- a/platypush/plugins/sensor/pmw3901/__init__.py +++ b/platypush/plugins/sensor/pmw3901/__init__.py @@ -36,18 +36,7 @@ class SPISlot(enum.Enum): class SensorPmw3901Plugin(SensorPlugin): """ Plugin to interact with an `PMW3901 `_ - optical flow and motion sensor - - Requires: - - * ``pmw3901`` (``pip install pmw3901``) - - Triggers: - - * :class:`platypush.message.event.sensor.SensorDataAboveThresholdEvent` - * :class:`platypush.message.event.sensor.SensorDataBelowThresholdEvent` - * :class:`platypush.message.event.sensor.SensorDataChangeEvent` - + optical flow and motion sensor. """ def __init__( diff --git a/platypush/plugins/serial/__init__.py b/platypush/plugins/serial/__init__.py index 69d20f0aa..e9cfc0ca2 100644 --- a/platypush/plugins/serial/__init__.py +++ b/platypush/plugins/serial/__init__.py @@ -52,24 +52,13 @@ class SerialPlugin(SensorPlugin): ``/dev/ttyUSB``), you may consider creating `static mappings through udev `_. - - Requires: - - * **pyserial** (``pip install pyserial``) - - Triggers: - - * :class:`platypush.message.event.sensor.SensorDataAboveThresholdEvent` - * :class:`platypush.message.event.sensor.SensorDataBelowThresholdEvent` - * :class:`platypush.message.event.sensor.SensorDataChangeEvent` - """ _default_lock_timeout: float = 2.0 def __init__( self, - device: Optional[str] = None, + device: str, baud_rate: int = 9600, max_size: int = 1 << 19, timeout: float = _default_lock_timeout, @@ -78,7 +67,7 @@ class SerialPlugin(SensorPlugin): **kwargs, ): """ - :param device: Device path (e.g. ``/dev/ttyUSB0`` or ``/dev/ttyACM0``) + :param device: Device path (e.g. ``/dev/ttyUSB0`` or ``/dev/ttyACM0``). :param baud_rate: Serial baud rate (default: 9600) :param max_size: Maximum size of a JSON payload (default: 512 KB). The plugin will keep reading bytes from the wire until it can form a @@ -206,9 +195,6 @@ class SerialPlugin(SensorPlugin): :param device: Default device path override. :param baud_rate: Default baud rate override. - :param reset: By default, if a connection to the device is already open - then the current object will be returned. If ``reset=True``, the - connection will be reset and a new one will be created instead. """ try: return self.__get_serial(device, baud_rate) @@ -273,7 +259,6 @@ class SerialPlugin(SensorPlugin): """ device, baud_rate = self._get_device_and_baud_rate(device, baud_rate) - data = None with get_lock(self.serial_lock, timeout=self._timeout) as serial_available: if serial_available: diff --git a/platypush/plugins/slack/__init__.py b/platypush/plugins/slack/__init__.py index 69a97a555..0d9c7280b 100644 --- a/platypush/plugins/slack/__init__.py +++ b/platypush/plugins/slack/__init__.py @@ -6,8 +6,12 @@ import requests from websocket import WebSocketApp from platypush.context import get_bus -from platypush.message.event.chat.slack import SlackMessageReceivedEvent, SlackMessageDeletedEvent, \ - SlackMessageEditedEvent, SlackAppMentionReceivedEvent +from platypush.message.event.chat.slack import ( + SlackMessageReceivedEvent, + SlackMessageDeletedEvent, + SlackMessageEditedEvent, + SlackAppMentionReceivedEvent, +) from platypush.plugins import RunnablePlugin, action from platypush.plugins.chat import ChatPlugin @@ -35,22 +39,13 @@ class SlackPlugin(ChatPlugin, RunnablePlugin): see a Bot User OAuth Token, used to authenticate API calls performed as the app/bot. If you also granted user permissions to the app then you should also see a User OAuth Token on the page. - Triggers: - - - :class:`platypush.message.event.chat.slack.SlackMessageReceivedEvent` when a message is received on a - monitored channel. - - :class:`platypush.message.event.chat.slack.SlackMessageEditedEvent` when a message is edited on a - monitored channel. - - :class:`platypush.message.event.chat.slack.SlackMessageDeletedEvent` when a message is deleted from a - monitored channel. - - :class:`platypush.message.event.chat.slack.SlackAppMentionReceivedEvent` when a message that mentions - the app is received on a monitored channel. - """ _api_base_url = 'https://slack.com/api' - def __init__(self, app_token: str, bot_token: str, user_token: Optional[str] = None, **kwargs): + def __init__( + self, app_token: str, bot_token: str, user_token: Optional[str] = None, **kwargs + ): """ :param app_token: Your Slack app token. :param bot_token: Bot OAuth token reported on the *Install App* menu. @@ -72,8 +67,14 @@ class SlackPlugin(ChatPlugin, RunnablePlugin): return f'{cls._api_base_url}/{method}' @action - def send_message(self, channel: str, as_user: bool = False, text: Optional[str] = None, - blocks: Optional[Iterable[str]] = None, **kwargs): + def send_message( + self, + channel: str, + as_user: bool = False, + text: Optional[str] = None, + blocks: Optional[Iterable[str]] = None, + **kwargs, + ): """ Send a message to a channel. It requires a token with ``chat:write`` bot/user scope. @@ -94,7 +95,7 @@ class SlackPlugin(ChatPlugin, RunnablePlugin): 'channel': channel, 'text': text, 'blocks': blocks or [], - } + }, ) try: @@ -109,7 +110,9 @@ class SlackPlugin(ChatPlugin, RunnablePlugin): stop_events = [] while not any(stop_events): - stop_events = self._should_stop.wait(timeout=1), self._disconnected_event.wait(timeout=1) + stop_events = self._should_stop.wait( + timeout=1 + ), self._disconnected_event.wait(timeout=1) def stop(self): if self._ws_app: @@ -122,7 +125,9 @@ class SlackPlugin(ChatPlugin, RunnablePlugin): self._ws_listener.join(5) if self._ws_listener and self._ws_listener.is_alive(): - self.logger.warning('Terminating the websocket process failed, killing the process') + self.logger.warning( + 'Terminating the websocket process failed, killing the process' + ) self._ws_listener.kill() if self._ws_listener: @@ -137,15 +142,20 @@ class SlackPlugin(ChatPlugin, RunnablePlugin): return self._ws_url = None - rs = requests.post('https://slack.com/api/apps.connections.open', headers={ - 'Authorization': f'Bearer {self._app_token}', - }) + rs = requests.post( + 'https://slack.com/api/apps.connections.open', + headers={ + 'Authorization': f'Bearer {self._app_token}', + }, + ) try: rs.raise_for_status() - except: # lgtm [py/catch-base-exception] + except Exception: if rs.status_code == 401 or rs.status_code == 403: - self.logger.error('Unauthorized/Forbidden Slack API request, stopping the service') + self.logger.error( + 'Unauthorized/Forbidden Slack API request, stopping the service' + ) self.stop() return @@ -154,11 +164,13 @@ class SlackPlugin(ChatPlugin, RunnablePlugin): rs = rs.json() assert rs.get('ok') self._ws_url = rs.get('url') - self._ws_app = WebSocketApp(self._ws_url, - on_open=self._on_open(), - on_message=self._on_msg(), - on_error=self._on_error(), - on_close=self._on_close()) + self._ws_app = WebSocketApp( + self._ws_url, + on_open=self._on_open(), + on_message=self._on_msg(), + on_error=self._on_error(), + on_close=self._on_close(), + ) def server(): self._ws_app.run_forever() @@ -180,9 +192,13 @@ class SlackPlugin(ChatPlugin, RunnablePlugin): envelope_id = msg.get('envelope_id') if envelope_id: # Send ACK - ws.send(json.dumps({ - 'envelope_id': envelope_id, - })) + ws.send( + json.dumps( + { + 'envelope_id': envelope_id, + } + ) + ) def _on_msg(self): def hndl(*args): @@ -203,7 +219,7 @@ class SlackPlugin(ChatPlugin, RunnablePlugin): team=event['team'], timestamp=event['event_ts'], icons=event.get('icons'), - blocks=event.get('blocks') + blocks=event.get('blocks'), ) elif event['type'] == 'message': msg = event.copy() @@ -221,14 +237,16 @@ class SlackPlugin(ChatPlugin, RunnablePlugin): event_args['previous_message'] = prev_msg event_type = SlackMessageEditedEvent - event_args.update({ - 'text': msg.get('text'), - 'user': msg.get('user'), - 'channel': msg.get('channel', event.get('channel')), - 'team': msg.get('team'), - 'icons': msg.get('icons'), - 'blocks': msg.get('blocks'), - }) + event_args.update( + { + 'text': msg.get('text'), + 'user': msg.get('user'), + 'channel': msg.get('channel', event.get('channel')), + 'team': msg.get('team'), + 'icons': msg.get('icons'), + 'blocks': msg.get('blocks'), + } + ) output_event = event_type(**event_args) diff --git a/platypush/plugins/smartthings/__init__.py b/platypush/plugins/smartthings/__init__.py index 825eb981f..0bb47bfea 100644 --- a/platypush/plugins/smartthings/__init__.py +++ b/platypush/plugins/smartthings/__init__.py @@ -44,11 +44,6 @@ class SmartthingsPlugin( ): """ Plugin to interact with devices and locations registered to a Samsung SmartThings account. - - Requires: - - * **pysmartthings** (``pip install pysmartthings``) - """ _timeout = aiohttp.ClientTimeout(total=20.0) diff --git a/platypush/plugins/sound/__init__.py b/platypush/plugins/sound/__init__.py index 5d158dc6d..cb54f011d 100644 --- a/platypush/plugins/sound/__init__.py +++ b/platypush/plugins/sound/__init__.py @@ -23,26 +23,6 @@ class SoundPlugin(RunnablePlugin): It can also be used as a general-purpose audio player and synthesizer, supporting both local and remote audio resources, as well as a MIDI-like interface through the :meth:`.play` command. - - Triggers: - - * :class:`platypush.message.event.sound.SoundPlaybackStartedEvent` on playback start - * :class:`platypush.message.event.sound.SoundPlaybackStoppedEvent` on playback stop - * :class:`platypush.message.event.sound.SoundPlaybackPausedEvent` on playback pause - * :class:`platypush.message.event.sound.SoundPlaybackResumedEvent` on playback resume - * :class:`platypush.message.event.sound.SoundRecordingStartedEvent` on recording start - * :class:`platypush.message.event.sound.SoundRecordingStoppedEvent` on recording stop - * :class:`platypush.message.event.sound.SoundRecordingPausedEvent` on recording pause - * :class:`platypush.message.event.sound.SoundRecordingResumedEvent` on recording resume - - Requires: - - * **sounddevice** (``pip install sounddevice``) - * **numpy** (``pip install numpy``) - * **ffmpeg** package installed on the system - * **portaudio** package installed on the system - either - ``portaudio19-dev`` on Debian-like systems, or ``portaudio`` on Arch. - """ _DEFAULT_BLOCKSIZE = 1024 diff --git a/platypush/plugins/ssh/__init__.py b/platypush/plugins/ssh/__init__.py index 8aef1dab4..ee7e5c9b8 100644 --- a/platypush/plugins/ssh/__init__.py +++ b/platypush/plugins/ssh/__init__.py @@ -6,7 +6,16 @@ import os import threading from binascii import hexlify -from stat import S_ISDIR, S_ISREG, S_ISLNK, S_ISCHR, S_ISFIFO, S_ISSOCK, S_ISBLK, S_ISDOOR +from stat import ( + S_ISDIR, + S_ISREG, + S_ISLNK, + S_ISCHR, + S_ISFIFO, + S_ISSOCK, + S_ISBLK, + S_ISDOOR, +) from typing import Optional, Dict, Tuple, List, Union, Any from paramiko import DSSKey, RSAKey, SSHClient, WarningPolicy, SFTPClient @@ -27,32 +36,35 @@ from platypush.plugins.ssh.tunnel.reverse import reverse_tunnel, close_tunnel class SshPlugin(Plugin): """ SSH plugin. - - Requires: - - * **paramiko** (``pip install paramiko``) - """ key_dispatch_table = {'dsa': DSSKey, 'rsa': RSAKey} - def __init__(self, key_file: Optional[str] = None, passphrase: Optional[str] = None, **kwargs): + def __init__( + self, key_file: Optional[str] = None, passphrase: Optional[str] = None, **kwargs + ): """ :param key_file: Default key file (default: any "id_rsa", "id_dsa", "id_ecdsa", or "id_ed25519" key discoverable in ``~/.ssh/``. :param passphrase: Key file passphrase (default: None). """ super().__init__(**kwargs) - self.key_file = os.path.abspath(os.path.expanduser(key_file)) if key_file else None + self.key_file = ( + os.path.abspath(os.path.expanduser(key_file)) if key_file else None + ) self.passphrase = passphrase self._sessions: Dict[Tuple[str, int, Optional[str]], SSHClient] = {} self._fwd_tunnels: Dict[Tuple[int, str, int], dict] = {} self._rev_tunnels: Dict[Tuple[int, str, int], dict] = {} - def _get_key(self, key_file: Optional[str] = None, passphrase: Optional[str] = None): + def _get_key( + self, key_file: Optional[str] = None, passphrase: Optional[str] = None + ): key_file = key_file or self.key_file - return (os.path.abspath(os.path.expanduser(key_file)) if key_file else None, - passphrase or self.passphrase) + return ( + os.path.abspath(os.path.expanduser(key_file)) if key_file else None, + passphrase or self.passphrase, + ) @staticmethod def _get_host_port_user(host: str, port: int = 22, user: Optional[str] = None, **_): @@ -67,12 +79,14 @@ class SshPlugin(Plugin): # noinspection PyShadowingBuiltins @action - def keygen(self, - filename: str, - type: str = 'rsa', - bits: int = 4096, - comment: Optional[str] = None, - passphrase: Optional[str] = None) -> SSHKeygenResponse: + def keygen( + self, + filename: str, + type: str = 'rsa', + bits: int = 4096, + comment: Optional[str] = None, + passphrase: Optional[str] = None, + ) -> SSHKeygenResponse: """ Generate an SSH keypair. @@ -84,8 +98,11 @@ class SshPlugin(Plugin): :return: :class:`platypush.message.response.ssh.SSHKeygenResponse`. """ assert type != 'dsa' or bits <= 1024, 'DSA keys support a maximum of 1024 bits' - assert type in self.key_dispatch_table, 'No such type: {}. Available types: {}'.format( - type, self.key_dispatch_table.keys()) + assert ( + type in self.key_dispatch_table + ), 'No such type: {}. Available types: {}'.format( + type, self.key_dispatch_table.keys() + ) if filename: filename = os.path.abspath(os.path.expanduser(filename)) @@ -100,7 +117,9 @@ class SshPlugin(Plugin): f.write(' ' + comment) hash = u(hexlify(pub.get_fingerprint())) - return SSHKeygenResponse(fingerprint=hash, key_file=filename, pub_key_file=pub_file) + return SSHKeygenResponse( + fingerprint=hash, key_file=filename, pub_key_file=pub_file + ) def run(self, *args, **kwargs): try: @@ -108,22 +127,27 @@ class SshPlugin(Plugin): except Exception as e: raise AssertionError(e) - def _connect(self, - host: str, - port: int = 22, - user: Optional[str] = None, - password: Optional[str] = None, - key_file: Optional[str] = None, - passphrase: Optional[str] = None, - compress: bool = False, - timeout: Optional[int] = None, - auth_timeout: Optional[int] = None) -> SSHClient: + def _connect( + self, + host: str, + port: int = 22, + user: Optional[str] = None, + password: Optional[str] = None, + key_file: Optional[str] = None, + passphrase: Optional[str] = None, + compress: bool = False, + timeout: Optional[int] = None, + auth_timeout: Optional[int] = None, + ) -> SSHClient: try: host, port, user = self._get_host_port_user(host, port, user) key = (host, port, user) if key in self._sessions: - self.logger.info('[Connect] The SSH session is already active: {user}@{host}:{port}'.format( - user=user, host=host, port=port)) + self.logger.info( + '[Connect] The SSH session is already active: {user}@{host}:{port}'.format( + user=user, host=host, port=port + ) + ) return self._sessions[key] key_file, passphrase = self._get_key(key_file, passphrase) @@ -157,16 +181,18 @@ class SshPlugin(Plugin): raise AssertionError('Connection to {} failed: {}'.format(host, str(e))) @action - def connect(self, - host: str, - port: int = 22, - user: Optional[str] = None, - password: Optional[str] = None, - key_file: Optional[str] = None, - passphrase: Optional[str] = None, - compress: bool = False, - timeout: Optional[int] = None, - auth_timeout: Optional[int] = None) -> None: + def connect( + self, + host: str, + port: int = 22, + user: Optional[str] = None, + password: Optional[str] = None, + key_file: Optional[str] = None, + passphrase: Optional[str] = None, + compress: bool = False, + timeout: Optional[int] = None, + auth_timeout: Optional[int] = None, + ) -> None: """ Open an SSH connection. @@ -180,14 +206,20 @@ class SshPlugin(Plugin): :param timeout: Data transfer timeout in seconds (default: None). :param auth_timeout: Authentication timeout in seconds (default: None). """ - self._connect(host=host, port=port, user=user, password=password, key_file=key_file, passphrase=passphrase, - compress=compress, timeout=timeout, auth_timeout=auth_timeout) + self._connect( + host=host, + port=port, + user=user, + password=password, + key_file=key_file, + passphrase=passphrase, + compress=compress, + timeout=timeout, + auth_timeout=auth_timeout, + ) @action - def disconnect(self, - host: str, - port: int = 22, - user: Optional[str] = None) -> None: + def disconnect(self, host: str, port: int = 22, user: Optional[str] = None) -> None: """ Close a connection to a host. @@ -198,8 +230,11 @@ class SshPlugin(Plugin): host, port, user = self._get_host_port_user(host, port, user) key = (host, port, user) if key not in self._sessions: - self.logger.info('[Disconnect] The SSH session is not active: {user}@{host}:{port}'.format( - user=user, host=host, port=port)) + self.logger.info( + '[Disconnect] The SSH session is not active: {user}@{host}:{port}'.format( + user=user, host=host, port=port + ) + ) session = self._sessions[key] try: @@ -210,8 +245,15 @@ class SshPlugin(Plugin): del self._sessions[key] @action - def exec(self, cmd: str, keep_alive: bool = False, timeout: Optional[int] = None, - stdin: Optional[str] = None, env: Optional[Dict[str, str]] = None, **kwargs) -> Response: + def exec( + self, + cmd: str, + keep_alive: bool = False, + timeout: Optional[int] = None, + stdin: Optional[str] = None, + env: Optional[Dict[str, str]] = None, + **kwargs, + ) -> Response: """ Run a command on a host. @@ -278,23 +320,37 @@ class SshPlugin(Plugin): for x in cls.sftp_walk(sftp, new_path): yield x - def sftp_get(self, sftp: SFTPClient, remote_path: str, local_path: str, recursive: bool = False) -> None: + def sftp_get( + self, + sftp: SFTPClient, + remote_path: str, + local_path: str, + recursive: bool = False, + ) -> None: if self.is_directory(sftp, remote_path): - assert recursive, '{} is a directory on the server but recursive has been set to False' + assert ( + recursive + ), '{} is a directory on the server but recursive has been set to False' local_path = os.path.join(local_path, os.path.basename(remote_path)) os.makedirs(local_path, mode=0o755, exist_ok=True) sftp.chdir(remote_path) - for path, folders, files in self.sftp_walk(sftp, '.'): + for path, _, files in self.sftp_walk(sftp, '.'): new_local_path = os.path.join(local_path, path) os.makedirs(new_local_path, mode=0o755, exist_ok=True) for file in files: - self.logger.info('Downloading file {} from {} to {}'.format(file, path, new_local_path)) - self.sftp_get(sftp, - os.path.join(remote_path, path, file), - os.path.join(new_local_path, file), - recursive=recursive) + self.logger.info( + 'Downloading file {} from {} to {}'.format( + file, path, new_local_path + ) + ) + self.sftp_get( + sftp, + os.path.join(remote_path, path, file), + os.path.join(new_local_path, file), + recursive=recursive, + ) else: if os.path.isdir(local_path): local_path = os.path.join(local_path, os.path.basename(remote_path)) @@ -302,8 +358,14 @@ class SshPlugin(Plugin): sftp.get(remote_path, local_path) @action - def get(self, remote_path: str, local_path: str, recursive: bool = False, keep_alive: bool = False, - **kwargs) -> None: + def get( + self, + remote_path: str, + local_path: str, + recursive: bool = False, + keep_alive: bool = False, + **kwargs, + ) -> None: """ Download a file or folder from an SSH server. @@ -319,15 +381,26 @@ class SshPlugin(Plugin): sftp = client.open_sftp() try: - self.sftp_get(sftp, remote_path=remote_path, local_path=local_path, recursive=recursive) + self.sftp_get( + sftp, + remote_path=remote_path, + local_path=local_path, + recursive=recursive, + ) finally: if not keep_alive: host, port, user = self._get_host_port_user(**kwargs) self.disconnect(host=host, port=port, user=user) @action - def put(self, remote_path: str, local_path: str, recursive: bool = False, keep_alive: bool = False, - **kwargs) -> None: + def put( + self, + remote_path: str, + local_path: str, + recursive: bool = False, + keep_alive: bool = False, + **kwargs, + ) -> None: """ Upload a file or folder to an SSH server. @@ -350,14 +423,19 @@ class SshPlugin(Plugin): except Exception as e: self.logger.warning(f'mkdir {remote_path}: {e}') - assert recursive, '{} is a directory but recursive has been set to False'.format(local_path) - assert self.is_directory(sftp, remote_path), \ - '{} is not a directory on the remote host'.format(remote_path) + assert ( + recursive + ), '{} is a directory but recursive has been set to False'.format( + local_path + ) + assert self.is_directory( + sftp, remote_path + ), '{} is not a directory on the remote host'.format(remote_path) sftp.chdir(remote_path) os.chdir(local_path) - for path, folders, files in os.walk('.'): + for path, _, files in os.walk('.'): try: sftp.mkdir(path) except Exception as e: @@ -370,7 +448,9 @@ class SshPlugin(Plugin): sftp.put(src, dst) else: if self.is_directory(sftp, remote_path): - remote_path = os.path.join(remote_path, os.path.basename(local_path)) + remote_path = os.path.join( + remote_path, os.path.basename(local_path) + ) sftp.put(local_path, remote_path) finally: @@ -379,8 +459,9 @@ class SshPlugin(Plugin): self.disconnect(host=host, port=port, user=user) @action - def ls(self, path: str = '.', attrs: bool = False, keep_alive: bool = False, **kwargs) \ - -> Union[List[str], Dict[str, Any]]: + def ls( + self, path: str = '.', attrs: bool = False, keep_alive: bool = False, **kwargs + ) -> Union[List[str], Dict[str, Any]]: """ Return the list of files in a path on a remote server. @@ -477,7 +558,9 @@ class SshPlugin(Plugin): self.disconnect(host=host, port=port, user=user) @action - def mkdir(self, path: str, mode: int = 0o777, keep_alive: bool = False, **kwargs) -> None: + def mkdir( + self, path: str, mode: int = 0o777, keep_alive: bool = False, **kwargs + ) -> None: """ Create a directory. @@ -556,7 +639,9 @@ class SshPlugin(Plugin): self.disconnect(host=host, port=port, user=user) @action - def chown(self, path: str, uid: int, gid: int, keep_alive: bool = False, **kwargs) -> None: + def chown( + self, path: str, uid: int, gid: int, keep_alive: bool = False, **kwargs + ) -> None: """ Change the owner of a path. @@ -614,8 +699,14 @@ class SshPlugin(Plugin): self.disconnect(host=host, port=port, user=user) @action - def start_forward_tunnel(self, local_port: int, remote_host: str, remote_port: int, bind_addr: Optional[str] = '', - **kwargs): + def start_forward_tunnel( + self, + local_port: int, + remote_host: str, + remote_port: int, + bind_addr: Optional[str] = '', + **kwargs, + ): """ Start an SSH forward tunnel, tunnelling to :. @@ -627,12 +718,21 @@ class SshPlugin(Plugin): """ key = local_port, remote_host, remote_port if key in self._fwd_tunnels: - self.logger.info('The tunnel {}:{}:{}:{} is already active'.format( - bind_addr, local_port, remote_host, remote_port)) + self.logger.info( + 'The tunnel {}:{}:{}:{} is already active'.format( + bind_addr, local_port, remote_host, remote_port + ) + ) return client = self._connect(**kwargs) - server = forward_tunnel(local_port, remote_host, remote_port, client.get_transport(), bind_addr=bind_addr) + server = forward_tunnel( + local_port, + remote_host, + remote_port, + client.get_transport(), + bind_addr=bind_addr, + ) threading.Thread(target=server.serve_forever, name='sshfwdtun').start() self._fwd_tunnels[key] = { @@ -652,7 +752,11 @@ class SshPlugin(Plugin): """ key = (local_port, remote_host, remote_port) if key not in self._fwd_tunnels: - self.logger.warning('No such forward tunnel: {}:{}:{}'.format(local_port, remote_host, remote_port)) + self.logger.warning( + 'No such forward tunnel: {}:{}:{}'.format( + local_port, remote_host, remote_port + ) + ) return server = self._fwd_tunnels[key]['server'] @@ -663,8 +767,14 @@ class SshPlugin(Plugin): self.disconnect(host=host, port=port, user=user) @action - def start_reverse_tunnel(self, server_port: int, remote_host: str, remote_port: int, bind_addr: Optional[str] = '', - **kwargs): + def start_reverse_tunnel( + self, + server_port: int, + remote_host: str, + remote_port: int, + bind_addr: Optional[str] = '', + **kwargs, + ): """ Start an SSH reversed tunnel. on the SSH server is forwarded across an SSH session back to the local machine, and out to a : reachable from this network. @@ -677,13 +787,21 @@ class SshPlugin(Plugin): """ key = server_port, remote_host, remote_port if key in self._fwd_tunnels: - self.logger.info('The tunnel {}:{}:{}:{} is already active'.format( - bind_addr, server_port, remote_host, remote_port)) + self.logger.info( + 'The tunnel {}:{}:{}:{} is already active'.format( + bind_addr, server_port, remote_host, remote_port + ) + ) return client = self._connect(**kwargs) - server = reverse_tunnel(server_port, remote_host, remote_port, transport=client.get_transport(), - bind_addr=bind_addr) + server = reverse_tunnel( + server_port, + remote_host, + remote_port, + transport=client.get_transport(), + bind_addr=bind_addr, + ) threading.Thread(target=server, name='sshrevtun').start() @@ -704,7 +822,11 @@ class SshPlugin(Plugin): """ key = (server_port, remote_host, remote_port) if key not in self._rev_tunnels: - self.logger.warning('No such reversed tunnel: {}:{}:{}'.format(server_port, remote_host, remote_port)) + self.logger.warning( + 'No such reversed tunnel: {}:{}:{}'.format( + server_port, remote_host, remote_port + ) + ) return close_tunnel(*key) diff --git a/platypush/plugins/stt/__init__.py b/platypush/plugins/stt/__init__.py index bca339f8c..1df2ae451 100644 --- a/platypush/plugins/stt/__init__.py +++ b/platypush/plugins/stt/__init__.py @@ -6,8 +6,14 @@ from typing import Optional, Union, List import sounddevice as sd from platypush.context import get_bus -from platypush.message.event.stt import SpeechDetectionStartedEvent, SpeechDetectionStoppedEvent, SpeechStartedEvent, \ - SpeechDetectedEvent, HotwordDetectedEvent, ConversationDetectedEvent +from platypush.message.event.stt import ( + SpeechDetectionStartedEvent, + SpeechDetectionStoppedEvent, + SpeechStartedEvent, + SpeechDetectedEvent, + HotwordDetectedEvent, + ConversationDetectedEvent, +) from platypush.message.response.stt import SpeechDetectedResponse from platypush.plugins import Plugin, action @@ -15,28 +21,20 @@ from platypush.plugins import Plugin, action class SttPlugin(ABC, Plugin): """ Abstract class for speech-to-text plugins. - - Triggers: - - * :class:`platypush.message.event.stt.SpeechStartedEvent` when speech starts being detected. - * :class:`platypush.message.event.stt.SpeechDetectedEvent` when speech is detected. - * :class:`platypush.message.event.stt.SpeechDetectionStartedEvent` when speech detection starts. - * :class:`platypush.message.event.stt.SpeechDetectionStoppedEvent` when speech detection stops. - * :class:`platypush.message.event.stt.HotwordDetectedEvent` when a user-defined hotword is detected. - * :class:`platypush.message.event.stt.ConversationDetectedEvent` when speech is detected after a hotword. - """ _thread_stop_timeout = 10.0 rate = 16000 channels = 1 - def __init__(self, - input_device: Optional[Union[int, str]] = None, - hotword: Optional[str] = None, - hotwords: Optional[List[str]] = None, - conversation_timeout: Optional[float] = 10.0, - block_duration: float = 1.0): + def __init__( + self, + input_device: Optional[Union[int, str]] = None, + hotword: Optional[str] = None, + hotwords: Optional[List[str]] = None, + conversation_timeout: Optional[float] = 10.0, + block_duration: float = 1.0, + ): """ :param input_device: PortAudio device index or name that will be used for recording speech (default: default system audio input device). @@ -104,7 +102,9 @@ class SttPlugin(ABC, Plugin): event = HotwordDetectedEvent(hotword=speech) if self.conversation_timeout: self._conversation_event.set() - threading.Timer(self.conversation_timeout, lambda: self._conversation_event.clear()).start() + threading.Timer( + self.conversation_timeout, lambda: self._conversation_event.clear() + ).start() elif self._conversation_event.is_set(): event = ConversationDetectedEvent(speech=speech) else: @@ -113,7 +113,7 @@ class SttPlugin(ABC, Plugin): get_bus().post(event) @staticmethod - def convert_frames(frames: bytes) -> bytes: + def convert_frames(frames: bytes) -> bytes: """ Conversion method for raw audio frames. It just returns the input frames as bytes. Override it if required by your logic. @@ -193,7 +193,9 @@ class SttPlugin(ABC, Plugin): frames = self._audio_queue.get() frames = self.convert_frames(frames) except Exception as e: - self.logger.warning('Error while feeding audio to the model: {}'.format(str(e))) + self.logger.warning( + 'Error while feeding audio to the model: {}'.format(str(e)) + ) continue text = self.detect_speech(frames).strip() @@ -202,8 +204,12 @@ class SttPlugin(ABC, Plugin): self.on_detection_ended() self.logger.debug('Detection thread terminated') - def recording_thread(self, block_duration: Optional[float] = None, block_size: Optional[int] = None, - input_device: Optional[str] = None) -> None: + def recording_thread( + self, + block_duration: Optional[float] = None, + block_size: Optional[int] = None, + input_device: Optional[str] = None, + ) -> None: """ Recording thread. It reads raw frames from the audio device and dispatches them to ``detection_thread``. @@ -211,8 +217,9 @@ class SttPlugin(ABC, Plugin): :param block_size: Size of the audio blocks. Specify either ``block_duration`` or ``block_size``. :param input_device: Input device """ - assert (block_duration or block_size) and not (block_duration and block_size), \ - 'Please specify either block_duration or block_size' + assert (block_duration or block_size) and not ( + block_duration and block_size + ), 'Please specify either block_duration or block_size' if not block_size: block_size = int(self.rate * self.channels * block_duration) @@ -220,9 +227,14 @@ class SttPlugin(ABC, Plugin): self.before_recording() self.logger.debug('Recording thread started') device = self._get_input_device(input_device) - self._input_stream = sd.InputStream(samplerate=self.rate, device=device, - channels=self.channels, dtype='int16', latency=0, - blocksize=block_size) + self._input_stream = sd.InputStream( + samplerate=self.rate, + device=device, + channels=self.channels, + dtype='int16', + latency=0, + blocksize=block_size, + ) self._input_stream.start() self.on_recording_started() get_bus().post(SpeechDetectionStartedEvent()) @@ -231,7 +243,9 @@ class SttPlugin(ABC, Plugin): try: frames = self._input_stream.read(block_size)[0] except Exception as e: - self.logger.warning('Error while reading from the audio input: {}'.format(str(e))) + self.logger.warning( + 'Error while reading from the audio input: {}'.format(str(e)) + ) continue self._audio_queue.put(frames) @@ -264,8 +278,12 @@ class SttPlugin(ABC, Plugin): self.stop_detection() @action - def start_detection(self, input_device: Optional[str] = None, seconds: Optional[float] = None, - block_duration: Optional[float] = None) -> None: + def start_detection( + self, + input_device: Optional[str] = None, + seconds: Optional[float] = None, + block_duration: Optional[float] = None, + ) -> None: """ Start the speech detection engine. @@ -274,15 +292,22 @@ class SttPlugin(ABC, Plugin): start running until ``stop_detection`` is called or application stop. :param block_duration: ``block_duration`` override. """ - assert not self._input_stream and not self._recording_thread, 'Speech detection is already running' + assert ( + not self._input_stream and not self._recording_thread + ), 'Speech detection is already running' block_duration = block_duration or self.block_duration input_device = input_device if input_device is not None else self.input_device self._audio_queue = queue.Queue() self._recording_thread = threading.Thread( - target=lambda: self.recording_thread(block_duration=block_duration, input_device=input_device)) + target=lambda: self.recording_thread( + block_duration=block_duration, input_device=input_device + ) + ) self._recording_thread.start() - self._detection_thread = threading.Thread(target=lambda: self.detection_thread()) + self._detection_thread = threading.Thread( + target=lambda: self.detection_thread() + ) self._detection_thread.start() if seconds: diff --git a/platypush/plugins/stt/deepspeech/__init__.py b/platypush/plugins/stt/deepspeech/__init__.py index afdcaf523..ca64b02ca 100644 --- a/platypush/plugins/stt/deepspeech/__init__.py +++ b/platypush/plugins/stt/deepspeech/__init__.py @@ -13,23 +13,19 @@ class SttDeepspeechPlugin(SttPlugin): """ This plugin performs speech-to-text and speech detection using the `Mozilla DeepSpeech `_ engine. - - Requires: - - * **deepspeech** (``pip install 'deepspeech>=0.6.0'``) - * **numpy** (``pip install numpy``) - * **sounddevice** (``pip install sounddevice``) - """ - def __init__(self, - model_file: str, - lm_file: str, - trie_file: str, - lm_alpha: float = 0.75, - lm_beta: float = 1.85, - beam_width: int = 500, - *args, **kwargs): + def __init__( + self, + model_file: str, + lm_file: str, + trie_file: str, + lm_alpha: float = 0.75, + lm_beta: float = 1.85, + beam_width: int = 500, + *args, + **kwargs + ): """ In order to run the speech-to-text engine you'll need to download the right model files for the Deepspeech engine that you have installed: @@ -43,7 +39,8 @@ class SttDeepspeechPlugin(SttPlugin): # Download and extract the model files for your version of Deepspeech. This may take a while. export DEEPSPEECH_VERSION=0.6.1 - wget https://github.com/mozilla/DeepSpeech/releases/download/v$DEEPSPEECH_VERSION/deepspeech-$DEEPSPEECH_VERSION-models.tar.gz + wget \ + 'https://github.com/mozilla/DeepSpeech/releases/download/v$DEEPSPEECH_VERSION/deepspeech-$DEEPSPEECH_VERSION-models.tar.gz' tar -xvzf deepspeech-$DEEPSPEECH_VERSION-models.tar.gz x deepspeech-0.6.1-models/ x deepspeech-0.6.1-models/lm.binary @@ -79,6 +76,7 @@ class SttDeepspeechPlugin(SttPlugin): """ import deepspeech + super().__init__(*args, **kwargs) self.model_file = os.path.abspath(os.path.expanduser(model_file)) self.lm_file = os.path.abspath(os.path.expanduser(lm_file)) @@ -91,9 +89,12 @@ class SttDeepspeechPlugin(SttPlugin): def _get_model(self): import deepspeech + if not self._model: self._model = deepspeech.Model(self.model_file, self.beam_width) - self._model.enableDecoderWithLM(self.lm_file, self.trie_file, self.lm_alpha, self.lm_beta) + self._model.enableDecoderWithLM( + self.lm_file, self.trie_file, self.lm_alpha, self.lm_beta + ) return self._model diff --git a/platypush/plugins/stt/picovoice/hotword/__init__.py b/platypush/plugins/stt/picovoice/hotword/__init__.py index 4b41f8778..5c7767833 100644 --- a/platypush/plugins/stt/picovoice/hotword/__init__.py +++ b/platypush/plugins/stt/picovoice/hotword/__init__.py @@ -10,46 +10,58 @@ from platypush.plugins.stt import SttPlugin class SttPicovoiceHotwordPlugin(SttPlugin): """ This plugin performs hotword detection using `PicoVoice `_. - - Requires: - - * **pvporcupine** (``pip install pvporcupine``) for hotword detection. - """ - def __init__(self, - library_path: Optional[str] = None, - model_file_path: Optional[str] = None, - keyword_file_paths: Optional[List[str]] = None, - sensitivity: float = 0.5, - sensitivities: Optional[List[float]] = None, - *args, **kwargs): + def __init__( + self, + library_path: Optional[str] = None, + model_file_path: Optional[str] = None, + keyword_file_paths: Optional[List[str]] = None, + sensitivity: float = 0.5, + sensitivities: Optional[List[float]] = None, + *args, + **kwargs + ): from pvporcupine import Porcupine - from pvporcupine.resources.util.python.util import LIBRARY_PATH, MODEL_FILE_PATH, KEYWORD_FILE_PATHS + from pvporcupine.resources.util.python.util import ( + LIBRARY_PATH, + MODEL_FILE_PATH, + KEYWORD_FILE_PATHS, + ) + super().__init__(*args, **kwargs) self.hotwords = list(self.hotwords) self._hotword_engine: Optional[Porcupine] = None - self._library_path = os.path.abspath(os.path.expanduser(library_path or LIBRARY_PATH)) - self._model_file_path = os.path.abspath(os.path.expanduser(model_file_path or MODEL_FILE_PATH)) + self._library_path = os.path.abspath( + os.path.expanduser(library_path or LIBRARY_PATH) + ) + self._model_file_path = os.path.abspath( + os.path.expanduser(model_file_path or MODEL_FILE_PATH) + ) if not keyword_file_paths: hotwords = KEYWORD_FILE_PATHS - assert all(hotword in hotwords for hotword in self.hotwords), \ - 'Not all the hotwords could be found. Available hotwords: {}'.format(list(hotwords.keys())) + assert all( + hotword in hotwords for hotword in self.hotwords + ), 'Not all the hotwords could be found. Available hotwords: {}'.format( + list(hotwords.keys()) + ) - self._keyword_file_paths = [os.path.abspath(os.path.expanduser(hotwords[hotword])) - for hotword in self.hotwords] + self._keyword_file_paths = [ + os.path.abspath(os.path.expanduser(hotwords[hotword])) + for hotword in self.hotwords + ] else: self._keyword_file_paths = [ - os.path.abspath(os.path.expanduser(p)) - for p in keyword_file_paths + os.path.abspath(os.path.expanduser(p)) for p in keyword_file_paths ] self._sensitivities = [] if sensitivities: - assert len(self._keyword_file_paths) == len(sensitivities), \ - 'Please specify as many sensitivities as the number of configured hotwords' + assert len(self._keyword_file_paths) == len( + sensitivities + ), 'Please specify as many sensitivities as the number of configured hotwords' self._sensitivities = sensitivities else: @@ -82,18 +94,24 @@ class SttPicovoiceHotwordPlugin(SttPlugin): """ pass - def recording_thread(self, input_device: Optional[str] = None, *args, **kwargs) -> None: + def recording_thread( + self, input_device: Optional[str] = None, *args, **kwargs + ) -> None: assert self._hotword_engine, 'The hotword engine has not yet been initialized' - super().recording_thread(block_size=self._hotword_engine.frame_length, input_device=input_device) + super().recording_thread( + block_size=self._hotword_engine.frame_length, input_device=input_device + ) @action def start_detection(self, *args, **kwargs) -> None: from pvporcupine import Porcupine + self._hotword_engine = Porcupine( library_path=self._library_path, model_file_path=self._model_file_path, keyword_file_paths=self._keyword_file_paths, - sensitivities=self._sensitivities) + sensitivities=self._sensitivities, + ) self.rate = self._hotword_engine.sample_rate super().start_detection(*args, **kwargs) diff --git a/platypush/plugins/stt/picovoice/speech/__init__.py b/platypush/plugins/stt/picovoice/speech/__init__.py index df12db5fc..4043ec530 100644 --- a/platypush/plugins/stt/picovoice/speech/__init__.py +++ b/platypush/plugins/stt/picovoice/speech/__init__.py @@ -19,20 +19,18 @@ class SttPicovoiceSpeechPlugin(SttPlugin): NOTE: The PicoVoice product used for real-time speech-to-text (Cheetah) can be used freely for personal applications on x86_64 Linux. Other architectures and operating systems require a commercial license. You can ask for a license `here `_. - - Requires: - - * **cheetah** (``pip install git+https://github.com/BlackLight/cheetah``) - """ - def __init__(self, - library_path: Optional[str] = None, - acoustic_model_path: Optional[str] = None, - language_model_path: Optional[str] = None, - license_path: Optional[str] = None, - end_of_speech_timeout: int = 1, - *args, **kwargs): + def __init__( + self, + library_path: Optional[str] = None, + acoustic_model_path: Optional[str] = None, + language_model_path: Optional[str] = None, + license_path: Optional[str] = None, + end_of_speech_timeout: int = 1, + *args, + **kwargs + ): """ :param library_path: Path to the Cheetah binary library for your OS (default: ``CHEETAH_INSTALL_DIR/lib/OS/ARCH/libpv_cheetah.EXT``). @@ -46,17 +44,26 @@ class SttPicovoiceSpeechPlugin(SttPlugin): a phrase over (default: 1). """ from pvcheetah import Cheetah + super().__init__(*args, **kwargs) - self._basedir = os.path.abspath(os.path.join(inspect.getfile(Cheetah), '..', '..', '..')) + self._basedir = os.path.abspath( + os.path.join(inspect.getfile(Cheetah), '..', '..', '..') + ) if not library_path: library_path = self._get_library_path() if not language_model_path: - language_model_path = os.path.join(self._basedir, 'lib', 'common', 'language_model.pv') + language_model_path = os.path.join( + self._basedir, 'lib', 'common', 'language_model.pv' + ) if not acoustic_model_path: - acoustic_model_path = os.path.join(self._basedir, 'lib', 'common', 'acoustic_model.pv') + acoustic_model_path = os.path.join( + self._basedir, 'lib', 'common', 'acoustic_model.pv' + ) if not license_path: - license_path = os.path.join(self._basedir, 'resources', 'license', 'cheetah_eval_linux_public.lic') + license_path = os.path.join( + self._basedir, 'resources', 'license', 'cheetah_eval_linux_public.lic' + ) self._library_path = library_path self._language_model_path = language_model_path @@ -67,8 +74,12 @@ class SttPicovoiceSpeechPlugin(SttPlugin): self._speech_in_progress = threading.Event() def _get_library_path(self) -> str: - path = os.path.join(self._basedir, 'lib', platform.system().lower(), platform.machine()) - return os.path.join(path, [f for f in os.listdir(path) if f.startswith('libpv_cheetah.')][0]) + path = os.path.join( + self._basedir, 'lib', platform.system().lower(), platform.machine() + ) + return os.path.join( + path, [f for f in os.listdir(path) if f.startswith('libpv_cheetah.')][0] + ) def convert_frames(self, frames: bytes) -> tuple: assert self._stt_engine, 'The speech engine is not running' @@ -115,13 +126,18 @@ class SttPicovoiceSpeechPlugin(SttPlugin): """ pass - def recording_thread(self, input_device: Optional[str] = None, *args, **kwargs) -> None: + def recording_thread( + self, input_device: Optional[str] = None, *args, **kwargs + ) -> None: assert self._stt_engine, 'The hotword engine has not yet been initialized' - super().recording_thread(block_size=self._stt_engine.frame_length, input_device=input_device) + super().recording_thread( + block_size=self._stt_engine.frame_length, input_device=input_device + ) @action def start_detection(self, *args, **kwargs) -> None: from pvcheetah import Cheetah + self._stt_engine = Cheetah( library_path=self._library_path, acoustic_model_path=self._acoustic_model_path, diff --git a/platypush/plugins/sun/__init__.py b/platypush/plugins/sun/__init__.py index d757eb4c9..e01e643d3 100644 --- a/platypush/plugins/sun/__init__.py +++ b/platypush/plugins/sun/__init__.py @@ -13,13 +13,8 @@ from platypush.schemas.sun import SunEventsSchema class SunPlugin(RunnablePlugin): """ Plugin to get sunset/sunrise events and info for a certain location. - - Triggers: - - * :class:`platypush.message.event.sun.SunriseEvent` on sunrise. - * :class:`platypush.message.event.sun.SunsetEvent` on sunset. - """ + _base_url = 'https://api.sunrise-sunset.org/json' _attr_to_event_class = { 'sunrise': SunriseEvent, @@ -39,16 +34,25 @@ class SunPlugin(RunnablePlugin): while not self.should_stop(): # noinspection PyUnresolvedReferences next_events = self.get_events().output - next_events = sorted([ - event_class(latitude=self.latitude, longitude=self.longitude, time=next_events[attr]) - for attr, event_class in self._attr_to_event_class.items() - if next_events.get(attr) - ], key=lambda t: t.time) + next_events = sorted( + [ + event_class( + latitude=self.latitude, + longitude=self.longitude, + time=next_events[attr], + ) + for attr, event_class in self._attr_to_event_class.items() + if next_events.get(attr) + ], + key=lambda t: t.time, + ) for event in next_events: # noinspection PyTypeChecker dt = datetime.datetime.fromisoformat(event.time) - while (not self.should_stop()) and (dt > datetime.datetime.now(tz=gettz())): + while (not self.should_stop()) and ( + dt > datetime.datetime.now(tz=gettz()) + ): time.sleep(1) if dt <= datetime.datetime.now(tz=gettz()): @@ -56,17 +60,28 @@ class SunPlugin(RunnablePlugin): @staticmethod def _convert_time(t: str) -> datetime.datetime: - now = datetime.datetime.now().replace(tzinfo=gettz()) # lgtm [py/call-to-non-callable] + now = datetime.datetime.now().replace( + tzinfo=gettz() + ) # lgtm [py/call-to-non-callable] dt = datetime.datetime.strptime(t, '%H:%M:%S %p') - dt = datetime.datetime(year=now.year, month=now.month, day=now.day, - hour=dt.hour, minute=dt.minute, second=dt.second, tzinfo=tzutc()) + dt = datetime.datetime( + year=now.year, + month=now.month, + day=now.day, + hour=dt.hour, + minute=dt.minute, + second=dt.second, + tzinfo=tzutc(), + ) if dt < now: dt += datetime.timedelta(days=1) return datetime.datetime.fromtimestamp(dt.timestamp(), tz=gettz()) @action - def get_events(self, latitude: Optional[float] = None, longitude: Optional[float] = None) -> dict: + def get_events( + self, latitude: Optional[float] = None, longitude: Optional[float] = None + ) -> dict: """ Return the next sun events. @@ -74,14 +89,23 @@ class SunPlugin(RunnablePlugin): :param longitude: Default longitude override. :return: .. schema:: sun.SunEventsSchema """ - response = requests.get(self._base_url, params={ - 'lat': latitude or self.latitude, - 'lng': longitude or self.longitude, - }).json().get('results', {}) + response = ( + requests.get( + self._base_url, + params={ + 'lat': latitude or self.latitude, + 'lng': longitude or self.longitude, + }, + ) + .json() + .get('results', {}) + ) schema = SunEventsSchema() - return schema.dump({ - attr: self._convert_time(t) - for attr, t in response.items() - if attr in schema.declared_fields.keys() - }) + return schema.dump( + { + attr: self._convert_time(t) + for attr, t in response.items() + if attr in schema.declared_fields + } + ) diff --git a/platypush/plugins/switch/tplink/__init__.py b/platypush/plugins/switch/tplink/__init__.py index c6b456e0e..d06bc03ba 100644 --- a/platypush/plugins/switch/tplink/__init__.py +++ b/platypush/plugins/switch/tplink/__init__.py @@ -24,11 +24,6 @@ class SwitchTplinkPlugin(RunnablePlugin, SwitchEntityManager): """ Plugin to interact with TP-Link smart switches/plugs like the HS100 (https://www.tp-link.com/us/products/details/cat-5516_HS100.html). - - Requires: - - * **pyHS100** (``pip install pyHS100``) - """ _ip_to_dev: Dict[str, SmartDevice] = {} diff --git a/platypush/plugins/system/__init__.py b/platypush/plugins/system/__init__.py index 099790edb..924149b18 100644 --- a/platypush/plugins/system/__init__.py +++ b/platypush/plugins/system/__init__.py @@ -58,12 +58,6 @@ from platypush.schemas.system import ( class SystemPlugin(SensorPlugin, EntityManager): """ Plugin to get system info. - - Requires: - - - **py-cpuinfo** (``pip install py-cpuinfo``) for CPU model and info. - - **psutil** (``pip install psutil``) for CPU load and stats. - """ def __init__(self, *args, poll_interval: Optional[float] = 60, **kwargs): diff --git a/platypush/plugins/tensorflow/__init__.py b/platypush/plugins/tensorflow/__init__.py index 00b512b32..b63f35ef2 100644 --- a/platypush/plugins/tensorflow/__init__.py +++ b/platypush/plugins/tensorflow/__init__.py @@ -31,29 +31,6 @@ class TensorflowPlugin(Plugin): """ This plugin can be used to create, train, load and make predictions with TensorFlow-compatible machine learning models. - - Triggers: - - - :class:`platypush.message.event.tensorflow.TensorflowEpochStartedEvent` - when a Tensorflow model training/evaluation epoch begins. - - :class:`platypush.message.event.tensorflow.TensorflowEpochEndedEvent` - when a Tensorflow model training/evaluation epoch ends. - - :class:`platypush.message.event.tensorflow.TensorflowBatchStartedEvent` - when a Tensorflow model training/evaluation batch starts being processed. - - :class:`platypush.message.event.tensorflow.TensorflowBatchEndedEvent` - when a the processing of a Tensorflow model training/evaluation batch ends. - - :class:`platypush.message.event.tensorflow.TensorflowTrainStartedEvent` - when a Tensorflow model starts being trained. - - :class:`platypush.message.event.tensorflow.TensorflowTrainEndedEvent` - when the training phase of a Tensorflow model ends. - - Requires: - - * **numpy** (``pip install numpy``) - * **pandas** (``pip install pandas``) (optional, for CSV parsing) - * **tensorflow** (``pip install 'tensorflow>=2.0'``) - * **keras** (``pip install keras``) - """ _image_extensions = ['jpg', 'jpeg', 'bmp', 'tiff', 'tif', 'png', 'gif'] diff --git a/platypush/plugins/todoist/__init__.py b/platypush/plugins/todoist/__init__.py index 96b9a1080..73210da4f 100644 --- a/platypush/plugins/todoist/__init__.py +++ b/platypush/plugins/todoist/__init__.py @@ -6,19 +6,22 @@ import todoist import todoist.managers.items from platypush.plugins import Plugin, action -from platypush.message.response.todoist import TodoistUserResponse, TodoistProjectsResponse, TodoistItemsResponse, \ - TodoistFiltersResponse, TodoistLiveNotificationsResponse, TodoistCollaboratorsResponse, TodoistNotesResponse, \ - TodoistProjectNotesResponse +from platypush.message.response.todoist import ( + TodoistUserResponse, + TodoistProjectsResponse, + TodoistItemsResponse, + TodoistFiltersResponse, + TodoistLiveNotificationsResponse, + TodoistCollaboratorsResponse, + TodoistNotesResponse, + TodoistProjectNotesResponse, +) class TodoistPlugin(Plugin): """ Todoist integration. - Requires: - - * **todoist-python** (``pip install todoist-python``) - You'll also need a Todoist token. You can get it `here `. """ @@ -38,7 +41,10 @@ class TodoistPlugin(Plugin): if not self._api: self._api = todoist.TodoistAPI(self.api_token) - if not self._last_sync_time or time.time() - self._last_sync_time > self._sync_timeout: + if ( + not self._last_sync_time + or time.time() - self._last_sync_time > self._sync_timeout + ): self._api.sync() return self._api diff --git a/platypush/plugins/torrent/__init__.py b/platypush/plugins/torrent/__init__.py index 713707954..eb645900c 100644 --- a/platypush/plugins/torrent/__init__.py +++ b/platypush/plugins/torrent/__init__.py @@ -25,11 +25,6 @@ from platypush.message.event.torrent import ( class TorrentPlugin(Plugin): """ Plugin to search and download torrents. - - Requires: - - * **python-libtorrent** (``pip install python-libtorrent``) - """ # Wait time in seconds between two torrent transfer checks diff --git a/platypush/plugins/trello/__init__.py b/platypush/plugins/trello/__init__.py index 5db5d473d..0eba5c16c 100644 --- a/platypush/plugins/trello/__init__.py +++ b/platypush/plugins/trello/__init__.py @@ -4,14 +4,32 @@ from typing import Optional, Dict, List, Union # noinspection PyPackageRequirements import trello + # noinspection PyPackageRequirements from trello.board import Board, List as List_ + # noinspection PyPackageRequirements from trello.exceptions import ResourceUnavailable -from platypush.message.response.trello import TrelloBoard, TrelloBoardsResponse, TrelloCardsResponse, TrelloCard, \ - TrelloAttachment, TrelloPreview, TrelloChecklist, TrelloChecklistItem, TrelloUser, TrelloComment, TrelloLabel, \ - TrelloList, TrelloBoardResponse, TrelloListsResponse, TrelloMembersResponse, TrelloMember, TrelloCardResponse +from platypush.message.response.trello import ( + TrelloBoard, + TrelloBoardsResponse, + TrelloCardsResponse, + TrelloCard, + TrelloAttachment, + TrelloPreview, + TrelloChecklist, + TrelloChecklistItem, + TrelloUser, + TrelloComment, + TrelloLabel, + TrelloList, + TrelloBoardResponse, + TrelloListsResponse, + TrelloMembersResponse, + TrelloMember, + TrelloCardResponse, +) from platypush.plugins import Plugin, action @@ -20,17 +38,19 @@ class TrelloPlugin(Plugin): """ Trello integration. - Requires: - - * **py-trello** (``pip install py-trello``) - - You'll also need a Trello API key. You can get it `here `. + You'll need a Trello API key. You can get it `here `. You'll also need an auth token if you want to view/change private resources. You can generate a permanent token linked to your account on https://trello.com/1/connect?key=&name=platypush&response_type=token&expiration=never&scope=read,write """ - def __init__(self, api_key: str, api_secret: Optional[str] = None, token: Optional[str] = None, **kwargs): + def __init__( + self, + api_key: str, + api_secret: Optional[str] = None, + token: Optional[str] = None, + **kwargs + ): """ :param api_key: Trello API key. You can get it `here `. :param api_secret: Trello API secret. You can get it `here `. @@ -75,17 +95,19 @@ class TrelloPlugin(Plugin): """ client = self._get_client() - return TrelloBoardsResponse([ - TrelloBoard( - id=b.id, - name=b.name, - description=b.description, - url=b.url, - date_last_activity=b.date_last_activity, - closed=b.closed, - ) - for b in client.list_boards(board_filter='all' if all else 'open') - ]) + return TrelloBoardsResponse( + [ + TrelloBoard( + id=b.id, + name=b.name, + description=b.description, + url=b.url, + date_last_activity=b.date_last_activity, + closed=b.closed, + ) + for b in client.list_boards(board_filter='all' if all else 'open') + ] + ) @action def get_board(self, board: str) -> TrelloBoardResponse: @@ -105,9 +127,14 @@ class TrelloPlugin(Plugin): description=board.description, date_last_activity=board.date_last_activity, lists=[ - TrelloList(id=ll.id, name=ll.name, closed=ll.closed, subscribed=ll.subscribed) + TrelloList( + id=ll.id, + name=ll.name, + closed=ll.closed, + subscribed=ll.subscribed, + ) for ll in board.list_lists() - ] + ], ) ) @@ -181,10 +208,7 @@ class TrelloPlugin(Plugin): try: board.delete_label(label) except ResourceUnavailable: - labels = [ - ll for ll in board.get_labels() - if ll.name == label - ] + labels = [ll for ll in board.get_labels() if ll.name == label] assert labels, 'No such label: {}'.format(label) label = labels[0].id @@ -213,22 +237,26 @@ class TrelloPlugin(Plugin): board = self._get_board(board) board.remove_member(member_id) - def _get_members(self, board: str, only_admin: bool = False) -> TrelloMembersResponse: + def _get_members( + self, board: str, only_admin: bool = False + ) -> TrelloMembersResponse: board = self._get_board(board) members = board.admin_members() if only_admin else board.get_members() - return TrelloMembersResponse([ - TrelloMember( - id=m.id, - full_name=m.full_name, - bio=m.bio, - url=m.url, - username=m.username, - initials=m.initials, - member_type=getattr(m, 'member_type') if hasattr(m, 'member_type') else None - ) - for m in members - ]) + return TrelloMembersResponse( + [ + TrelloMember( + id=m.id, + full_name=m.full_name, + bio=m.bio, + url=m.url, + username=m.username, + initials=m.initials, + member_type=getattr(m, 'member_type', None), + ) + for m in members + ] + ) @action def get_members(self, board: str) -> TrelloMembersResponse: @@ -258,10 +286,14 @@ class TrelloPlugin(Plugin): """ board = self._get_board(board) - return TrelloListsResponse([ - TrelloList(id=ll.id, name=ll.name, closed=ll.closed, subscribed=ll.subscribed) - for ll in board.list_lists('all' if all else 'open') - ]) + return TrelloListsResponse( + [ + TrelloList( + id=ll.id, name=ll.name, closed=ll.closed, subscribed=ll.subscribed + ) + for ll in board.list_lists('all' if all else 'open') + ] + ) @action def add_list(self, board: str, name: str, pos: Optional[int] = None): @@ -388,10 +420,18 @@ class TrelloPlugin(Plugin): # noinspection PyShadowingBuiltins @action - def add_card(self, board: str, list: str, name: str, description: Optional[str] = None, - position: Optional[int] = None, labels: Optional[List[str]] = None, - due: Optional[Union[str, datetime.datetime]] = None, source: Optional[str] = None, - assign: Optional[List[str]] = None) -> TrelloCardResponse: + def add_card( + self, + board: str, + list: str, + name: str, + description: Optional[str] = None, + position: Optional[int] = None, + labels: Optional[List[str]] = None, + due: Optional[Union[str, datetime.datetime]] = None, + source: Optional[str] = None, + assign: Optional[List[str]] = None, + ) -> TrelloCardResponse: """ Add a card to a list. @@ -409,42 +449,48 @@ class TrelloPlugin(Plugin): if labels: labels = [ - ll for ll in list.board.get_labels() + ll + for ll in list.board.get_labels() if ll.id in labels or ll.name in labels ] - card = list.add_card(name=name, desc=description, labels=labels, due=due, source=source, position=position, - assign=assign) + card = list.add_card( + name=name, + desc=description, + labels=labels, + due=due, + source=source, + position=position, + assign=assign, + ) - return TrelloCardResponse(TrelloCard(id=card.id, - name=card.name, - url=card.url, - closed=card.closed, - board=TrelloBoard( - id=list.board.id, - name=list.board.name, - url=list.board.url, - closed=list.board.closed, - description=list.board.description, - date_last_activity=list.board.date_last_activity - ), - - is_due_complete=card.is_due_complete, - list=None, - comments=[], - labels=[ - TrelloLabel( - id=lb.id, - name=lb.name, - color=lb.color - ) - for lb in (card.labels or []) - ], - description=card.description, - due_date=card.due_date, - latest_card_move_date=card.latestCardMove_date, - date_last_activity=card.date_last_activity - )) + return TrelloCardResponse( + TrelloCard( + id=card.id, + name=card.name, + url=card.url, + closed=card.closed, + board=TrelloBoard( + id=list.board.id, + name=list.board.name, + url=list.board.url, + closed=list.board.closed, + description=list.board.description, + date_last_activity=list.board.date_last_activity, + ), + is_due_complete=card.is_due_complete, + list=None, + comments=[], + labels=[ + TrelloLabel(id=lb.id, name=lb.name, color=lb.color) + for lb in (card.labels or []) + ], + description=card.description, + due_date=card.due_date, + latest_card_move_date=card.latestCardMove_date, + date_last_activity=card.date_last_activity, + ) + ) @action def delete_card(self, card_id: str): @@ -480,7 +526,13 @@ class TrelloPlugin(Plugin): card.set_closed(True) @action - def add_checklist(self, card_id: str, title: str, items: List[str], states: Optional[List[bool]] = None): + def add_checklist( + self, + card_id: str, + title: str, + items: List[str], + states: Optional[List[bool]] = None, + ): """ Add a checklist to a card. @@ -504,10 +556,7 @@ class TrelloPlugin(Plugin): client = self._get_client() card = client.get_card(card_id) - labels = [ - ll for ll in card.board.get_labels() - if ll.name == label - ] + labels = [ll for ll in card.board.get_labels() if ll.name == label] assert labels, 'No such label: {}'.format(label) label = labels[0] @@ -524,10 +573,7 @@ class TrelloPlugin(Plugin): client = self._get_client() card = client.get_card(card_id) - labels = [ - ll for ll in card.board.get_labels() - if ll.name == label - ] + labels = [ll for ll in card.board.get_labels() if ll.name == label] assert labels, 'No such label: {}'.format(label) label = labels[0] @@ -558,8 +604,14 @@ class TrelloPlugin(Plugin): card.unassign(member_id) @action - def attach_card(self, card_id: str, name: Optional[str] = None, mime_type: Optional[str] = None, - file: Optional[str] = None, url: Optional[str] = None): + def attach_card( + self, + card_id: str, + name: Optional[str] = None, + mime_type: Optional[str] = None, + file: Optional[str] = None, + url: Optional[str] = None, + ): """ Add an attachment to a card. It can be either a local file or a remote URL. @@ -777,7 +829,9 @@ class TrelloPlugin(Plugin): # noinspection PyShadowingBuiltins @action - def get_cards(self, board: str, list: Optional[str] = None, all: bool = False) -> TrelloCardsResponse: + def get_cards( + self, board: str, list: Optional[str] = None, all: bool = False + ) -> TrelloCardsResponse: """ Get the list of cards on a board. @@ -790,7 +844,9 @@ class TrelloPlugin(Plugin): board = self._get_board(board) lists: Dict[str, TrelloList] = { - ll.id: TrelloList(id=ll.id, name=ll.name, closed=ll.closed, subscribed=ll.subscribed) + ll.id: TrelloList( + id=ll.id, name=ll.name, closed=ll.closed, subscribed=ll.subscribed + ) for ll in board.list_lists() } @@ -807,102 +863,92 @@ class TrelloPlugin(Plugin): list_id = ll[0].id # noinspection PyUnresolvedReferences - return TrelloCardsResponse([ - TrelloCard( - id=c.id, - name=c.name, - url=c.url, - closed=c.closed, - list=lists.get(c.list_id), - - board=TrelloBoard( - id=c.board.id, - name=c.board.name, - url=c.board.url, - closed=c.board.closed, - description=c.board.description, - date_last_activity=c.board.date_last_activity - ), - - attachments=[ - TrelloAttachment( - id=a.get('id'), - bytes=a.get('bytes'), - date=a.get('date'), - edge_color=a.get('edgeColor'), - id_member=a.get('idMember'), - is_upload=a.get('isUpload'), - name=a.get('name'), - previews=[ - TrelloPreview( - id=p.get('id'), - scaled=p.get('scaled'), - url=p.get('url'), - bytes=p.get('bytes'), - height=p.get('height'), - width=p.get('width') - ) - for p in a.get('previews', []) - ], - url=a.get('url'), - mime_type=a.get('mimeType') - ) - for a in c.attachments - ], - - checklists=[ - TrelloChecklist( - id=ch.id, - name=ch.name, - checklist_items=[ - TrelloChecklistItem( - id=i.get('id'), - name=i.get('name'), - checked=i.get('checked') - ) - for i in ch.items - ] - ) - for ch in c.checklists - ], - - comments=[ - TrelloComment( - id=co.get('id'), - text=co.get('data', {}).get('text'), - type=co.get('type'), - date=co.get('date'), - creator=TrelloUser( - id=co.get('memberCreator', {}).get('id'), - username=co.get('memberCreator', {}).get('username'), - fullname=co.get('memberCreator', {}).get('fullName'), - initials=co.get('memberCreator', {}).get('initials'), - avatar_url=co.get('memberCreator', {}).get('avatarUrl') + return TrelloCardsResponse( + [ + TrelloCard( + id=c.id, + name=c.name, + url=c.url, + closed=c.closed, + list=lists.get(c.list_id), + board=TrelloBoard( + id=c.board.id, + name=c.board.name, + url=c.board.url, + closed=c.board.closed, + description=c.board.description, + date_last_activity=c.board.date_last_activity, + ), + attachments=[ + TrelloAttachment( + id=a.get('id'), + bytes=a.get('bytes'), + date=a.get('date'), + edge_color=a.get('edgeColor'), + id_member=a.get('idMember'), + is_upload=a.get('isUpload'), + name=a.get('name'), + previews=[ + TrelloPreview( + id=p.get('id'), + scaled=p.get('scaled'), + url=p.get('url'), + bytes=p.get('bytes'), + height=p.get('height'), + width=p.get('width'), + ) + for p in a.get('previews', []) + ], + url=a.get('url'), + mime_type=a.get('mimeType'), ) - ) - for co in c.comments - ], - - labels=[ - TrelloLabel( - id=lb.id, - name=lb.name, - color=lb.color - ) - for lb in (c.labels or []) - ], - - is_due_complete=c.is_due_complete, - due_date=c.due_date, - description=c.description, - latest_card_move_date=c.latestCardMove_date, - date_last_activity=c.date_last_activity - ) - for c in board.all_cards() if ( - (all or not c.closed) and - (not list or c.list_id == list_id) - ) - ]) + for a in c.attachments + ], + checklists=[ + TrelloChecklist( + id=ch.id, + name=ch.name, + checklist_items=[ + TrelloChecklistItem( + id=i.get('id'), + name=i.get('name'), + checked=i.get('checked'), + ) + for i in ch.items + ], + ) + for ch in c.checklists + ], + comments=[ + TrelloComment( + id=co.get('id'), + text=co.get('data', {}).get('text'), + type=co.get('type'), + date=co.get('date'), + creator=TrelloUser( + id=co.get('memberCreator', {}).get('id'), + username=co.get('memberCreator', {}).get('username'), + fullname=co.get('memberCreator', {}).get('fullName'), + initials=co.get('memberCreator', {}).get('initials'), + avatar_url=co.get('memberCreator', {}).get('avatarUrl'), + ), + ) + for co in c.comments + ], + labels=[ + TrelloLabel(id=lb.id, name=lb.name, color=lb.color) + for lb in (c.labels or []) + ], + is_due_complete=c.is_due_complete, + due_date=c.due_date, + description=c.description, + latest_card_move_date=c.latestCardMove_date, + date_last_activity=c.date_last_activity, + ) + for c in board.all_cards() + if ((all or not c.closed) and (not list or c.list_id == list_id)) + ] + ) # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/tts/google/__init__.py b/platypush/plugins/tts/google/__init__.py index c3965c560..e12672ed9 100644 --- a/platypush/plugins/tts/google/__init__.py +++ b/platypush/plugins/tts/google/__init__.py @@ -11,25 +11,28 @@ class TtsGooglePlugin(TtsPlugin): Advanced text-to-speech engine that leverages the Google Cloud TTS API. See https://cloud.google.com/text-to-speech/docs/quickstart-client-libraries#client-libraries-install-python for how to enable the API on your account and get your credentials. - - Requires: - - * **google-cloud-texttospeech** (``pip install google-cloud-texttospeech``) - """ - def __init__(self, - language: str = 'en-US', - voice: Optional[str] = None, - gender: str = 'FEMALE', - credentials_file: str = '~/.credentials/platypush/google/platypush-tts.json', - **kwargs): + def __init__( + self, + language: str = 'en-US', + voice: Optional[str] = None, + gender: str = 'FEMALE', + credentials_file: str = '~/.credentials/platypush/google/platypush-tts.json', + **kwargs + ): """ - :param language: Language code, see https://cloud.google.com/text-to-speech/docs/basics for supported languages - :param voice: Voice type, see https://cloud.google.com/text-to-speech/docs/basics for supported voices - :param gender: Voice gender (MALE, FEMALE or NEUTRAL) - :param credentials_file: Where your GCloud credentials for TTS are stored, see https://cloud.google.com/text-to-speech/docs/basics - :param kwargs: Extra arguments to be passed to the :class:`platypush.plugins.tts.TtsPlugin` constructor. + :param language: Language code, see + https://cloud.google.com/text-to-speech/docs/basics for supported + languages. + :param voice: Voice type, see + https://cloud.google.com/text-to-speech/docs/basics for supported + voices. + :param gender: Voice gender (MALE, FEMALE or NEUTRAL). + :param credentials_file: Where your GCloud credentials for TTS are + stored, see https://cloud.google.com/text-to-speech/docs/basics. + :param kwargs: Extra arguments to be passed to the + :class:`platypush.plugins.tts.TtsPlugin` constructor. """ super().__init__(**kwargs) @@ -38,7 +41,9 @@ class TtsGooglePlugin(TtsPlugin): self.language = self._parse_language(language) self.voice = self._parse_voice(self.language, voice) self.gender = getattr(self._gender, gender.upper()) - os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = os.path.expanduser(credentials_file) + os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = os.path.expanduser( + credentials_file + ) def _parse_language(self, language): if language is None: @@ -65,40 +70,62 @@ class TtsGooglePlugin(TtsPlugin): @property def _gender(self): from google.cloud import texttospeech - return texttospeech.enums.SsmlVoiceGender if hasattr(texttospeech, 'enums') else \ - texttospeech.SsmlVoiceGender + + return ( + texttospeech.enums.SsmlVoiceGender + if hasattr(texttospeech, 'enums') + else texttospeech.SsmlVoiceGender + ) @property def _voice_selection_params(self): from google.cloud import texttospeech - return texttospeech.types.VoiceSelectionParams if hasattr(texttospeech, 'types') else \ - texttospeech.VoiceSelectionParams + + return ( + texttospeech.types.VoiceSelectionParams + if hasattr(texttospeech, 'types') + else texttospeech.VoiceSelectionParams + ) @property def _synthesis_input(self): from google.cloud import texttospeech - return texttospeech.types.SynthesisInput if hasattr(texttospeech, 'types') else \ - texttospeech.SynthesisInput + + return ( + texttospeech.types.SynthesisInput + if hasattr(texttospeech, 'types') + else texttospeech.SynthesisInput + ) @property def _audio_config(self): from google.cloud import texttospeech - return texttospeech.types.AudioConfig if hasattr(texttospeech, 'types') else \ - texttospeech.AudioConfig + + return ( + texttospeech.types.AudioConfig + if hasattr(texttospeech, 'types') + else texttospeech.AudioConfig + ) @property def _audio_encoding(self): from google.cloud import texttospeech - return texttospeech.enums.AudioEncoding if hasattr(texttospeech, 'enums') else \ - texttospeech.AudioEncoding + + return ( + texttospeech.enums.AudioEncoding + if hasattr(texttospeech, 'enums') + else texttospeech.AudioEncoding + ) @action - def say(self, - text: str, - language: Optional[str] = None, - voice: Optional[str] = None, - gender: Optional[str] = None, - player_args: Optional[dict] = None): + def say( + self, + text: str, + language: Optional[str] = None, + voice: Optional[str] = None, + gender: Optional[str] = None, + player_args: Optional[dict] = None, + ): """ Say a phrase. @@ -106,13 +133,14 @@ class TtsGooglePlugin(TtsPlugin): :param language: Language code override. :param voice: Voice type override. :param gender: Gender override. - :param player_args: Optional arguments that should be passed to the player plugin's - :meth:`platypush.plugins.media.MediaPlugin.play` method. + :param player_args: Optional arguments that should be passed to the + player plugin's :meth:`platypush.plugins.media.MediaPlugin.play` + method. """ from google.cloud import texttospeech + client = texttospeech.TextToSpeechClient() - # noinspection PyTypeChecker synthesis_input = self._synthesis_input(text=text) language = self._parse_language(language) @@ -123,10 +151,14 @@ class TtsGooglePlugin(TtsPlugin): else: gender = getattr(self._gender, gender.upper()) - voice = self._voice_selection_params(language_code=language, ssml_gender=gender, name=voice) - # noinspection PyTypeChecker + voice = self._voice_selection_params( + language_code=language, ssml_gender=gender, name=voice + ) + audio_config = self._audio_config(audio_encoding=self._audio_encoding.MP3) - response = client.synthesize_speech(input=synthesis_input, voice=voice, audio_config=audio_config) + response = client.synthesize_speech( + input=synthesis_input, voice=voice, audio_config=audio_config + ) player_args = player_args or {} with tempfile.NamedTemporaryFile() as f: diff --git a/platypush/plugins/tv/samsung/ws/__init__.py b/platypush/plugins/tv/samsung/ws/__init__.py index 31af99f1f..0f537740d 100644 --- a/platypush/plugins/tv/samsung/ws/__init__.py +++ b/platypush/plugins/tv/samsung/ws/__init__.py @@ -12,15 +12,17 @@ class TvSamsungWsPlugin(Plugin): """ Control a Samsung smart TV with Tizen OS over WiFi/ethernet. It should support any post-2016 Samsung with Tizen OS and enabled websocket-based connection. - - Requires: - - * **samsungtvws** (``pip install samsungtvws``) - """ - def __init__(self, host: Optional[str] = None, port: int = 8002, timeout: Optional[int] = 5, name='platypush', - token_file: Optional[str] = None, **kwargs): + def __init__( + self, + host: Optional[str] = None, + port: int = 8002, + timeout: Optional[int] = 5, + name='platypush', + token_file: Optional[str] = None, + **kwargs + ): """ :param host: IP address or host name of the smart TV. :param port: Websocket port (default: 8002). @@ -41,22 +43,36 @@ class TvSamsungWsPlugin(Plugin): self._connections: Dict[Tuple[host, port], SamsungTVWS] = {} os.makedirs(self.workdir, mode=0o700, exist_ok=True) - def _get_host_and_port(self, host: Optional[str] = None, port: Optional[int] = None) -> Tuple[str, int]: + def _get_host_and_port( + self, host: Optional[str] = None, port: Optional[int] = None + ) -> Tuple[str, int]: host = host or self.host port = port or self.port assert host and port, 'No host/port specified' return host, port - def connect(self, host: Optional[str] = None, port: Optional[int] = None) -> SamsungTVWS: + def connect( + self, host: Optional[str] = None, port: Optional[int] = None + ) -> SamsungTVWS: host, port = self._get_host_and_port(host, port) if (host, port) not in self._connections: - self._connections[(host, port)] = SamsungTVWS(host=host, port=port, token_file=self.token_file, - timeout=self.timeout, name=self.name) + self._connections[(host, port)] = SamsungTVWS( + host=host, + port=port, + token_file=self.token_file, + timeout=self.timeout, + name=self.name, + ) return self._connections[(host, port)] - def exec(self, func: Callable[[SamsungTVWS], Any], host: Optional[str] = None, port: Optional[int] = None, - n_tries=2) -> Any: + def exec( + self, + func: Callable[[SamsungTVWS], Any], + host: Optional[str] = None, + port: Optional[int] = None, + n_tries=2, + ) -> Any: tv = self.connect(host, port) try: @@ -67,7 +83,7 @@ class TvSamsungWsPlugin(Plugin): raise e else: time.sleep(1) - return self.exec(func, host, port, n_tries-1) + return self.exec(func, host, port, n_tries - 1) @action def power(self, host: Optional[str] = None, port: Optional[int] = None) -> None: @@ -90,7 +106,9 @@ class TvSamsungWsPlugin(Plugin): return self.exec(lambda tv: tv.shortcuts().volume_up(), host=host, port=port) @action - def volume_down(self, host: Optional[str] = None, port: Optional[int] = None) -> None: + def volume_down( + self, host: Optional[str] = None, port: Optional[int] = None + ) -> None: """ Send volume down control to the device. @@ -110,7 +128,9 @@ class TvSamsungWsPlugin(Plugin): return self.exec(lambda tv: tv.shortcuts().back(), host=host, port=port) @action - def channel(self, channel: int, host: Optional[str] = None, port: Optional[int] = None) -> None: + def channel( + self, channel: int, host: Optional[str] = None, port: Optional[int] = None + ) -> None: """ Change to the selected channel. @@ -118,10 +138,14 @@ class TvSamsungWsPlugin(Plugin): :param host: Default host IP/name override. :param port: Default port override. """ - return self.exec(lambda tv: tv.shortcuts().channel(channel), host=host, port=port) + return self.exec( + lambda tv: tv.shortcuts().channel(channel), host=host, port=port + ) @action - def channel_up(self, host: Optional[str] = None, port: Optional[int] = None) -> None: + def channel_up( + self, host: Optional[str] = None, port: Optional[int] = None + ) -> None: """ Send channel_up key to the device. @@ -131,7 +155,9 @@ class TvSamsungWsPlugin(Plugin): return self.exec(lambda tv: tv.shortcuts().channel_up(), host=host, port=port) @action - def channel_down(self, host: Optional[str] = None, port: Optional[int] = None) -> None: + def channel_down( + self, host: Optional[str] = None, port: Optional[int] = None + ) -> None: """ Send channel_down key to the device. @@ -301,7 +327,9 @@ class TvSamsungWsPlugin(Plugin): return self.exec(lambda tv: tv.shortcuts().yellow(), host=host, port=port) @action - def digit(self, digit: int, host: Optional[str] = None, port: Optional[int] = None) -> None: + def digit( + self, digit: int, host: Optional[str] = None, port: Optional[int] = None + ) -> None: """ Send a digit key to the device. @@ -312,7 +340,12 @@ class TvSamsungWsPlugin(Plugin): return self.exec(lambda tv: tv.shortcuts().digit(digit), host=host, port=port) @action - def run_app(self, app_id: Union[int, str], host: Optional[str] = None, port: Optional[int] = None) -> None: + def run_app( + self, + app_id: Union[int, str], + host: Optional[str] = None, + port: Optional[int] = None, + ) -> None: """ Run an app by ID. @@ -324,7 +357,12 @@ class TvSamsungWsPlugin(Plugin): tv.rest_app_run(str(app_id)) @action - def close_app(self, app_id: Union[int, str], host: Optional[str] = None, port: Optional[int] = None) -> None: + def close_app( + self, + app_id: Union[int, str], + host: Optional[str] = None, + port: Optional[int] = None, + ) -> None: """ Close an app. @@ -336,7 +374,12 @@ class TvSamsungWsPlugin(Plugin): tv.rest_app_close(str(app_id)) @action - def install_app(self, app_id: Union[int, str], host: Optional[str] = None, port: Optional[int] = None) -> None: + def install_app( + self, + app_id: Union[int, str], + host: Optional[str] = None, + port: Optional[int] = None, + ) -> None: """ Install an app. @@ -348,7 +391,12 @@ class TvSamsungWsPlugin(Plugin): tv.rest_app_install(str(app_id)) @action - def status_app(self, app_id: Union[int, str], host: Optional[str] = None, port: Optional[int] = None) -> dict: + def status_app( + self, + app_id: Union[int, str], + host: Optional[str] = None, + port: Optional[int] = None, + ) -> dict: """ Get the status of an app. @@ -370,7 +418,9 @@ class TvSamsungWsPlugin(Plugin): return self.exec(lambda tv: tv.app_list(), host=host, port=port) @action - def open_browser(self, url: str, host: Optional[str] = None, port: Optional[int] = None) -> None: + def open_browser( + self, url: str, host: Optional[str] = None, port: Optional[int] = None + ) -> None: """ Open a URL in the browser. @@ -381,7 +431,9 @@ class TvSamsungWsPlugin(Plugin): return self.exec(lambda tv: tv.open_browser(url), host=host, port=port) @action - def device_info(self, host: Optional[str] = None, port: Optional[int] = None) -> dict: + def device_info( + self, host: Optional[str] = None, port: Optional[int] = None + ) -> dict: """ Return the info of the device. diff --git a/platypush/plugins/twilio/__init__.py b/platypush/plugins/twilio/__init__.py index e70a949c4..05fbd71ac 100644 --- a/platypush/plugins/twilio/__init__.py +++ b/platypush/plugins/twilio/__init__.py @@ -20,30 +20,32 @@ class TwilioPhoneNumberType(enum.Enum): class TwilioPlugin(Plugin): """ - The Twilio plugin allows you to send messages and WhatsApp texts and make programmable phone call by using a Twilio - account. Note that some features may require a Premium account. - - Requires: - - * **twilio** (``pip install twilio``) + The Twilio plugin allows you to send messages and WhatsApp texts and make + programmable phone call by using a Twilio account. Note that some features + may require a Premium account. """ _api_base_url = 'https://api.twilio.com' - def __init__(self, - account_sid: str, - auth_token: str, - address_sid: Optional[str] = None, - phone_number: Optional[str] = None, - address_book: Optional[Dict[str, str]] = None, - **kwargs): + def __init__( + self, + account_sid: str, + auth_token: str, + address_sid: Optional[str] = None, + phone_number: Optional[str] = None, + address_book: Optional[Dict[str, str]] = None, + **kwargs + ): """ :param account_sid: Account SID. :param auth_token: Account authentication token. - :param address_sid: SID of the default physical address - required to register a new number in some countries. - :param phone_number: Default phone number associated to the account to be used for messages and calls. - :param address_book: ``name``->``phone_number`` mapping of contacts. You can use directly these names to send - messages and make calls instead of the full phone number. + :param address_sid: SID of the default physical address - required to + register a new number in some countries. + :param phone_number: Default phone number associated to the account to + be used for messages and calls. + :param address_book: ``name``->``phone_number`` mapping of contacts. + You can use directly these names to send messages and make calls + instead of the full phone number. """ super().__init__(**kwargs) self.account_sid = account_sid @@ -59,58 +61,70 @@ class TwilioPlugin(Plugin): Get a list of phone numbers of a certain type available for a certain country. :param country: Country code (e.g. ``US`` or ``NL``). - :param number_type: Phone number type - e.g. ``mobile``, ``local`` or ``toll_free``. - :return: A list of the available phone numbers with their properties and capabilities. Example: + :param number_type: Phone number type - e.g. ``mobile``, ``local`` or + ``toll_free``. + :return: A list of the available phone numbers with their properties + and capabilities. Example: - .. code-block:: json + .. code-block:: json - [ - { - "friendly_name": "+311234567890", - "phone_number": "+311234567890", - "lata": null, - "rate_center": null, - "latitude": null, - "longitude": null, - "locality": null, - "region": null, - "postal_code": null, - "iso_country": "NL", - "address_requirements": "any", - "beta": false, - "capabilities": { - "voice": true, - "SMS": true, - "MMS": false, - "fax": false - } - } - ] + [ + { + "friendly_name": "+311234567890", + "phone_number": "+311234567890", + "lata": null, + "rate_center": null, + "latitude": null, + "longitude": null, + "locality": null, + "region": null, + "postal_code": null, + "iso_country": "NL", + "address_requirements": "any", + "beta": false, + "capabilities": { + "voice": true, + "SMS": true, + "MMS": false, + "fax": false + } + } + ] """ phone_numbers = self.client.available_phone_numbers(country.upper()).fetch() - resp = requests.get(self._api_base_url + phone_numbers.uri, auth=(self.account_sid, self.auth_token)).json() - assert 'subresource_uris' in resp, 'No available phone numbers found for the country {}'.format(country) - assert number_type in resp['subresource_uris'], 'No "{}" phone numbers available - available types: {}'.format( + resp = requests.get( + self._api_base_url + phone_numbers.uri, + auth=(self.account_sid, self.auth_token), + ).json() + assert ( + 'subresource_uris' in resp + ), 'No available phone numbers found for the country {}'.format(country) + assert ( + number_type in resp['subresource_uris'] + ), 'No "{}" phone numbers available - available types: {}'.format( number_type, list(resp['subresource_uris'].keys()) ) - resp = requests.get(self._api_base_url + resp['subresource_uris'][number_type], - auth=(self.account_sid, self.auth_token)).json() + resp = requests.get( + self._api_base_url + resp['subresource_uris'][number_type], + auth=(self.account_sid, self.auth_token), + ).json() phone_numbers = resp['available_phone_numbers'] assert len(phone_numbers), 'No phone numbers available' return phone_numbers @action - def create_address(self, - customer_name: str, - street: str, - city: str, - region: str, - postal_code: str, - iso_country: str): - # noinspection SpellCheckingInspection + def create_address( + self, + customer_name: str, + street: str, + city: str, + region: str, + postal_code: str, + iso_country: str, + ): """ Create a new address associated to your account. @@ -125,7 +139,7 @@ class TwilioPlugin(Plugin): .. code-block:: json { - "account_sid": "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "account_sid": "ACXXX", "city": "city", "customer_name": "customer_name", "date_created": "Tue, 18 Aug 2015 17:07:30 +0000", @@ -135,50 +149,56 @@ class TwilioPlugin(Plugin): "iso_country": "US", "postal_code": "postal_code", "region": "region", - "sid": "ADXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "sid": "ADXXX", "street": "street", "validated": false, "verified": false, - "uri": "/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Addresses/ADXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.json" + "uri": "/2010-04-01/Accounts/ACXXX/Addresses/ADXXX.json" } """ - address = self.client.addresses.create(customer_name=customer_name, - street=street, - city=city, - region=region, - postal_code=postal_code, - iso_country=iso_country) + address = self.client.addresses.create( + customer_name=customer_name, + street=street, + city=city, + region=region, + postal_code=postal_code, + iso_country=iso_country, + ) - # noinspection PyProtectedMember return address._properties @action - def register_phone_number(self, - phone_number: str, - friendly_name: Optional[str] = None, - address_sid: Optional[str] = None, - sms_url: Optional[str] = None, - sms_fallback_url: Optional[str] = None, - status_callback: Optional[str] = None, - voice_caller_id_lookup: bool = True, - voice_url: Optional[str] = None, - voice_fallback_url: Optional[str] = None, - area_code: Optional[str] = None) -> dict: - # noinspection SpellCheckingInspection + def register_phone_number( + self, + phone_number: str, + friendly_name: Optional[str] = None, + address_sid: Optional[str] = None, + sms_url: Optional[str] = None, + sms_fallback_url: Optional[str] = None, + status_callback: Optional[str] = None, + voice_caller_id_lookup: bool = True, + voice_url: Optional[str] = None, + voice_fallback_url: Optional[str] = None, + area_code: Optional[str] = None, + ) -> dict: """ - Request to allocate a phone number on your Twilio account. The phone number should first be displayed as - available in :meth:`.get_available_phone_numbers`. + Request to allocate a phone number on your Twilio account. The phone + number should first be displayed as available in + :meth:`.get_available_phone_numbers`. :param phone_number: Phone number to be allocated. :param friendly_name: A string used to identify your new phone number. - :param address_sid: Address SID. NOTE: some countries may require you to specify a valid address in - order to register a new phone number (see meth:`create_address`). If none is specified then the + :param address_sid: Address SID. NOTE: some countries may require you + to specify a valid address in order to register a new phone number + (see meth:`create_address`). If none is specified then the configured ``address_sid`` (if available) will be applied. :param sms_url: URL to call when an SMS is received. - :param sms_fallback_url: URL to call when an error occurs on SMS delivery/receipt. + :param sms_fallback_url: URL to call when an error occurs on SMS + delivery/receipt. :param status_callback: URL to call when a status change occurs. - :param voice_caller_id_lookup: Whether to perform ID lookup for incoming caller numbers. + :param voice_caller_id_lookup: Whether to perform ID lookup for + incoming caller numbers. :param voice_url: URL to call when the number receives a call. :param voice_fallback_url: URL to call when a call fails. :param area_code: Override the area code for the new number. @@ -187,9 +207,9 @@ class TwilioPlugin(Plugin): .. code-block:: json { - "account_sid": "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "account_sid": "ACXXX", "address_requirements": "none", - "address_sid": "ADXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "address_sid": "ADXXX", "api_version": "2010-04-01", "beta": false, "capabilities": { @@ -201,12 +221,12 @@ class TwilioPlugin(Plugin): "date_created": "Thu, 30 Jul 2015 23:19:04 +0000", "date_updated": "Thu, 30 Jul 2015 23:19:04 +0000", "emergency_status": "Active", - "emergency_address_sid": "ADXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "emergency_address_sid": "ADXXX", "friendly_name": "friendly_name", - "identity_sid": "RIXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "identity_sid": "RIXXX", "origin": "origin", "phone_number": "+18089255327", - "sid": "PNXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "sid": "PNXXX", "sms_application_sid": "APXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", "sms_fallback_method": "GET", "sms_fallback_url": "https://example.com", @@ -215,7 +235,7 @@ class TwilioPlugin(Plugin): "status_callback": "https://example.com", "status_callback_method": "GET", "trunk_sid": null, - "uri": "/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/IncomingPhoneNumbers/PNXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.json", + "uri": "/2010-04-01/Accounts/ACXXX/IncomingPhoneNumbers/PNXXX.json", "voice_application_sid": "APXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", "voice_caller_id_lookup": false, "voice_fallback_method": "GET", @@ -228,57 +248,67 @@ class TwilioPlugin(Plugin): } """ - status = self.client.incoming_phone_numbers.create(phone_number=phone_number, - friendly_name=friendly_name, - sms_url=sms_url, - sms_fallback_url=sms_fallback_url, - status_callback=status_callback, - voice_caller_id_lookup=voice_caller_id_lookup, - voice_url=voice_url, - voice_fallback_url=voice_fallback_url, - area_code=area_code, - address_sid=address_sid or self.address_sid) + status = self.client.incoming_phone_numbers.create( + phone_number=phone_number, + friendly_name=friendly_name, + sms_url=sms_url, + sms_fallback_url=sms_fallback_url, + status_callback=status_callback, + voice_caller_id_lookup=voice_caller_id_lookup, + voice_url=voice_url, + voice_fallback_url=voice_fallback_url, + area_code=area_code, + address_sid=address_sid or self.address_sid, + ) - # noinspection PyProtectedMember return status._properties @action - def send_message(self, - body: str, - to: str, - from_: Optional[str] = None, - status_callback: Optional[str] = None, - max_price: Optional[str] = None, - attempt: Optional[int] = None, - validity_period: Optional[int] = None, - smart_encoded: bool = True, - media_url: Optional[str] = None) -> dict: - # noinspection SpellCheckingInspection + def send_message( + self, + body: str, + to: str, + from_: Optional[str] = None, + status_callback: Optional[str] = None, + max_price: Optional[str] = None, + attempt: Optional[int] = None, + validity_period: Optional[int] = None, + smart_encoded: bool = True, + media_url: Optional[str] = None, + ) -> dict: """ Send an SMS/MMS. - Note: WhatsApp messages are also supported (and free of charge), although the functionality is currently quite - limited. Full support is only available to WhatsApp Business profiles and indipendent software vendors approved - by WhatsApp. If that's not the case, you can send WhatsApp messages through the Twilio Test account/number - - as of now the ``from_`` field should be ``whatsapp:+14155238886`` and the ``to`` field should be - ``whatsapp:+``. More information `here `_. + + Note: WhatsApp messages are also supported (and free of charge), + although the functionality is currently quite limited. Full support is + only available to WhatsApp Business profiles and indipendent software + vendors approved by WhatsApp. If that's not the case, you can send + WhatsApp messages through the Twilio Test account/number - as of now + the ``from_`` field should be ``whatsapp:+14155238886`` and the ``to`` + field should be ``whatsapp:+``. More information + `here `_. :param body: Message body. :param to: Recipient number or address book name. - :param from_: Sender number. If none is specified then the default configured ``phone_number`` will be - used if available. - :param status_callback: The URL to call to send status information to the application. - :param max_price: The total maximum price up to 4 decimal places in US dollars acceptable for the message to be - delivered. - :param attempt: Total numer of attempts made , this inclusive to send out the message. - :param validity_period: The number of seconds that the message can remain in our outgoing queue. - :param smart_encoded: Whether to detect Unicode characters that have a similar GSM-7 character and replace them. + :param from_: Sender number. If none is specified then the default + configured ``phone_number`` will be used if available. + :param status_callback: The URL to call to send status information to + the application. + :param max_price: The total maximum price up to 4 decimal places in US + dollars acceptable for the message to be delivered. + :param attempt: Total numer of attempts made , this inclusive to send + out the message. + :param validity_period: The number of seconds that the message can + remain in our outgoing queue. + :param smart_encoded: Whether to detect Unicode characters that have a + similar GSM-7 character and replace them. :param media_url: The URL of the media to send with the message. :return: A mapping representing the status of the delivery. Example: .. code-block:: json { - "account_sid": "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "account_sid": "ACXXX", "api_version": "2010-04-01", "body": "Sent from your Twilio trial account - It works!", "date_created": "2020-08-17T16:32:09.341", @@ -296,18 +326,18 @@ class TwilioPlugin(Plugin): "sid": "XXXXXXXXXXXXX", "status": "queued", "subresource_uris": { - "media": "/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Messages/SMXXXXXXXXXXXXXXXXXX/Media.json" + "media": "/2010-04-01/Accounts/ACXXX/Messages/SMXXX/Media.json" }, "to": "+XXXXXXXXXXXXXX", - "uri": "/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Messages/SMXXXXXXXXXXXXXXXXXX.json" + "uri": "/2010-04-01/Accounts/ACXXX/Messages/SMXXX.json" } """ - # noinspection SpellCheckingInspection - assert from_ or self.phone_number, 'No valid sender phone number specified nor configured' - if to in self.address_book: - to = self.address_book[to] + assert ( + from_ or self.phone_number + ), 'No valid sender phone number specified nor configured' + to = self.address_book.get(to, to) status = self.client.messages.create( body=body, from_=from_ or self.phone_number, @@ -320,19 +350,19 @@ class TwilioPlugin(Plugin): smart_encoded=smart_encoded, ) - # noinspection PyProtectedMember return status._properties @action - def list_messages(self, - to: Optional[str] = None, - from_: Optional[str] = None, - date_sent_before: Optional[str] = None, - date_sent: Optional[str] = None, - date_sent_after: Optional[str] = None, - limit: Optional[int] = None, - page_size: Optional[int] = None) -> List[dict]: - # noinspection SpellCheckingInspection + def list_messages( + self, + to: Optional[str] = None, + from_: Optional[str] = None, + date_sent_before: Optional[str] = None, + date_sent: Optional[str] = None, + date_sent_after: Optional[str] = None, + limit: Optional[int] = None, + page_size: Optional[int] = None, + ) -> List[dict]: """ List all messages matching the specified criteria. @@ -348,7 +378,7 @@ class TwilioPlugin(Plugin): .. code-block:: json { - "account_sid": "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "account_sid": "ACXXX", "api_version": "2010-04-01", "body": "testing", "date_created": "Fri, 24 May 2019 17:44:46 +0000", @@ -366,29 +396,36 @@ class TwilioPlugin(Plugin): "sid": "SMded05904ccb347238880ca9264e8fe1c", "status": "sent", "subresource_uris": { - "media": "/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Messages/SMded05904ccb347238880ca9264e8fe1c/Media.json", - "feedback": "/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Messages/SMded05904ccb347238880ca9264e8fe1c/Feedback.json" + "media": "/2010-04-01/Accounts/ACXXX/Messages/SMXXX/Media.json", + "feedback": "/2010-04-01/Accounts/ACXXX/Messages/SMXXX/Feedback.json" }, "to": "+18182008801", - "uri": "/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Messages/SMded05904ccb347238880ca9264e8fe1c.json" + "uri": "/2010-04-01/Accounts/ACXXX/Messages/SMXXX.json" } """ - if to in self.address_book: - to = self.address_book[to] + if to: + to = self.address_book.get(to, to) - # noinspection PyTypeChecker - messages = self.client.messages.list(to=to, from_=from_, - date_sent_before=datetime.datetime.fromisoformat(date_sent_before) - if date_sent_before else None, - date_sent_after=datetime.datetime.fromisoformat(date_sent_after) - if date_sent_after else None, - date_sent=datetime.datetime.fromisoformat(date_sent) - if date_sent else None, - limit=limit, page_size=page_size) + messages = self.client.messages.list( + to=to, + from_=from_, + date_sent_before=datetime.datetime.fromisoformat(date_sent_before) + if date_sent_before + else None, + date_sent_after=datetime.datetime.fromisoformat(date_sent_after) + if date_sent_after + else None, + date_sent=datetime.datetime.fromisoformat(date_sent) if date_sent else None, + limit=limit, + page_size=page_size, + ) - # noinspection PyProtectedMember - return json.loads(json.dumps([msg._properties for msg in messages], indent=2, cls=Message.Encoder)) + return json.loads( + json.dumps( + [msg._properties for msg in messages], indent=2, cls=Message.Encoder + ) + ) @action def get_message(self, message_sid: str) -> dict: @@ -399,7 +436,6 @@ class TwilioPlugin(Plugin): :return: Message with its properties - see :meth:`.send_message`. """ msg = self.client.messages(message_sid).fetch() - # noinspection PyProtectedMember return msg._properties @action @@ -412,7 +448,6 @@ class TwilioPlugin(Plugin): :return: Updated message with its properties - see :meth:`.send_message`. """ msg = self.client.messages(message_sid).update(body) - # noinspection PyProtectedMember return msg._properties @action @@ -425,37 +460,42 @@ class TwilioPlugin(Plugin): self.client.messages(message_sid).delete() @action - def make_call(self, - twiml: str, - to: str, - from_: Optional[str] = None, - method: Optional[str] = None, - status_callback: Optional[str] = None, - status_callback_event: Optional[str] = None, - status_callback_method: Optional[str] = None, - fallback_url: Optional[str] = None, - fallback_method: Optional[str] = None, - send_digits: Optional[str] = None, - timeout: Optional[int] = 30, - record: bool = False, - recording_channels: Optional[int] = None, - recording_status_callback: Optional[str] = None, - recording_status_callback_method: Optional[str] = None, - recording_status_callback_event: Optional[str] = None, - sip_auth_username: Optional[str] = None, - sip_auth_password: Optional[str] = None, - caller_id: Optional[str] = None, - call_reason: Optional[str] = None) -> dict: - # noinspection SpellCheckingInspection + def make_call( + self, + twiml: str, + to: str, + from_: Optional[str] = None, + method: Optional[str] = None, + status_callback: Optional[str] = None, + status_callback_event: Optional[str] = None, + status_callback_method: Optional[str] = None, + fallback_url: Optional[str] = None, + fallback_method: Optional[str] = None, + send_digits: Optional[str] = None, + timeout: Optional[int] = 30, + record: bool = False, + recording_channels: Optional[int] = None, + recording_status_callback: Optional[str] = None, + recording_status_callback_method: Optional[str] = None, + recording_status_callback_event: Optional[str] = None, + sip_auth_username: Optional[str] = None, + sip_auth_password: Optional[str] = None, + caller_id: Optional[str] = None, + call_reason: Optional[str] = None, + ) -> dict: """ Make an automated phone call from a registered Twilio number. - :param twiml: TwiML containing the logic to be executed in the call (see https://www.twilio.com/docs/voice/twiml). + :param twiml: TwiML containing the logic to be executed in the call + (see https://www.twilio.com/docs/voice/twiml). :param to: Recipient phone number or address book name. - :param from_: Registered Twilio phone number that will perform the call (default: default configured phone number). + :param from_: Registered Twilio phone number that will perform the call + (default: default configured phone number). :param method: HTTP method to use to fetch TwiML if it's provided remotely. - :param status_callback: The URL that should be called to send status information to your application. - :param status_callback_event: The call progress events to be sent to the ``status_callback`` URL. + :param status_callback: The URL that should be called to send status + information to your application. + :param status_callback_event: The call progress events to be sent to + the ``status_callback`` URL. :param status_callback_method: HTTP Method to use with status_callback. :param fallback_url: Fallback URL in case of error. :param fallback_method: HTTP Method to use with fallback_url. @@ -463,21 +503,27 @@ class TwilioPlugin(Plugin): :param timeout: Number of seconds to wait for an answer. :param record: Whether to record the call. :param recording_channels: The number of channels in the final recording. - :param recording_status_callback: The URL that we call when the recording is available to be accessed. - :param recording_status_callback_method: The HTTP method to use when calling the `recording_status_callback` URL. - :param recording_status_callback_event: The recording status events that will trigger calls to the URL specified - in `recording_status_callback` - :param sip_auth_username: The username used to authenticate the caller making a SIP call. - :param sip_auth_password: The password required to authenticate the user account specified in `sip_auth_username`. - :param caller_id: The phone number, SIP address, or Client identifier that made this call. Phone numbers are in - E.164 format (e.g., +16175551212). SIP addresses are formatted as `name@company.com`. + :param recording_status_callback: The URL that we call when the + recording is available to be accessed. + :param recording_status_callback_method: The HTTP method to use when + calling the `recording_status_callback` URL. + :param recording_status_callback_event: The recording status events + that will trigger calls to the URL specified in + `recording_status_callback` + :param sip_auth_username: The username used to authenticate the caller + making a SIP call. + :param sip_auth_password: The password required to authenticate the + user account specified in `sip_auth_username`. + :param caller_id: The phone number, SIP address, or Client identifier + that made this call. Phone numbers are in E.164 format (e.g., + +16175551212). SIP addresses are formatted as `name@company.com`. :param call_reason: Reason for the call (Branded Calls Beta). :return: The call properties and details, as a dictionary. Example: .. code-block:: json { - "account_sid": "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "account_sid": "ACXXX", "annotation": null, "answered_by": null, "api_version": "2010-04-01", @@ -492,71 +538,72 @@ class TwilioPlugin(Plugin): "from_formatted": "(501) 712-2661", "group_sid": null, "parent_call_sid": null, - "phone_number_sid": "PNXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "phone_number_sid": "PNXXX", "price": "-0.03000", "price_unit": "USD", - "sid": "CAXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "sid": "CAXXX", "start_time": "Tue, 31 Aug 2010 20:36:29 +0000", "status": "completed", "subresource_uris": { - "notifications": "/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Calls/CAXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Notifications.json", - "recordings": "/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Calls/CAXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Recordings.json", - "feedback": "/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Calls/CAXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Feedback.json", - "feedback_summaries": "/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Calls/FeedbackSummary.json", - "payments": "/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Calls/CAXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Payments.json" + "notifications": "/2010-04-01/Accounts/ACXXX/Calls/CAXXX/Notifications.json", + "recordings": "/2010-04-01/Accounts/ACXXX/Calls/CAXXX/Recordings.json", + "feedback": "/2010-04-01/Accounts/ACXXX/Calls/CAXXX/Feedback.json", + "feedback_summaries": "/2010-04-01/Accounts/ACXXX/Calls/FeedbackSummary.json", + "payments": "/2010-04-01/Accounts/ACXXX/Calls/CAXXX/Payments.json" }, "to": "+14155551212", "to_formatted": "(415) 555-1212", "trunk_sid": null, - "uri": "/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Calls/CAXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.json", + "uri": "/2010-04-01/Accounts/ACXXX/Calls/CAXXX.json", "queue_time": "1000" } """ - if to in self.address_book: - to = self.address_book[to] + to = self.address_book.get(to, to) + call = self.client.calls.create( + to=to, + from_=from_ or self.phone_number, + twiml=twiml, + method=method, + status_callback=status_callback, + status_callback_event=status_callback_event, + status_callback_method=status_callback_method, + fallback_url=fallback_url, + fallback_method=fallback_method, + send_digits=send_digits, + timeout=str(timeout) if timeout else None, + record=record, + recording_channels=str(recording_channels) if recording_channels else None, + recording_status_callback=recording_status_callback, + recording_status_callback_method=recording_status_callback_method, + recording_status_callback_event=recording_status_callback_event, + sip_auth_username=sip_auth_username, + sip_auth_password=sip_auth_password, + caller_id=caller_id, + call_reason=call_reason, + ) - call = self.client.calls.create(to=to, - from_=from_ or self.phone_number, - twiml=twiml, - method=method, - status_callback=status_callback, - status_callback_event=status_callback_event, - status_callback_method=status_callback_method, - fallback_url=fallback_url, - fallback_method=fallback_method, - send_digits=send_digits, - timeout=str(timeout) if timeout else None, - record=record, - recording_channels=str(recording_channels) if recording_channels else None, - recording_status_callback=recording_status_callback, - recording_status_callback_method=recording_status_callback_method, - recording_status_callback_event=recording_status_callback_event, - sip_auth_username=sip_auth_username, - sip_auth_password=sip_auth_password, - caller_id=caller_id, - call_reason=call_reason) - - # noinspection PyProtectedMember return call._properties @action - def list_calls(self, - to: Optional[str] = None, - from_: Optional[str] = None, - parent_call_sid: Optional[str] = None, - status: Optional[str] = None, - start_time_before: Optional[str] = None, - start_time: Optional[str] = None, - start_time_after: Optional[str] = None, - end_time_before: Optional[str] = None, - end_time: Optional[str] = None, - end_time_after: Optional[str] = None, - limit: Optional[int] = None, - page_size: Optional[int] = None) -> List[dict]: - # noinspection SpellCheckingInspection + def list_calls( + self, + to: Optional[str] = None, + from_: Optional[str] = None, + parent_call_sid: Optional[str] = None, + status: Optional[str] = None, + start_time_before: Optional[str] = None, + start_time: Optional[str] = None, + start_time_after: Optional[str] = None, + end_time_before: Optional[str] = None, + end_time: Optional[str] = None, + end_time_after: Optional[str] = None, + limit: Optional[int] = None, + page_size: Optional[int] = None, + ) -> List[dict]: """ - List the calls performed by the account, either the full list or those that match some filter. + List the calls performed by the account, either the full list or those + that match some filter. :param to: Phone number or Client identifier of calls to include :param from_: Phone number or Client identifier to filter `from` on @@ -569,79 +616,86 @@ class TwilioPlugin(Plugin): :param end_time: Only include calls that ended on this date :param end_time_after: Only include calls that ended on this date :param limit: Upper limit for the number of records to return. list() guarantees - never to return more than limit. Default is no limit + never to return more than limit. Default is no limit :param page_size: Number of records to fetch per request, when not set will use - the default value of 50 records. If no page_size is defined - but a limit is defined, list() will attempt to read the limit - with the most efficient page size, i.e. min(limit, 1000) - :return: A list of dictionaries, each representing the information of a call. Example: + the default value of 50 records. If no page_size is defined + but a limit is defined, list() will attempt to read the limit + with the most efficient page size, i.e. min(limit, 1000) + :return: A list of dictionaries, each representing the information of a + call. Example: - .. code-block:: json + .. code-block:: json - [ - { - "account_sid": "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - "annotation": "billingreferencetag1", - "answered_by": "machine_start", - "api_version": "2010-04-01", - "caller_name": "callerid1", - "date_created": "Fri, 18 Oct 2019 17:00:00 +0000", - "date_updated": "Fri, 18 Oct 2019 17:01:00 +0000", - "direction": "outbound-api", - "duration": "4", - "end_time": "Fri, 18 Oct 2019 17:03:00 +0000", - "forwarded_from": "calledvia1", - "from": "+13051416799", - "from_formatted": "(305) 141-6799", - "group_sid": "GPXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - "parent_call_sid": "CAXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - "phone_number_sid": "PNXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - "price": "-0.200", - "price_unit": "USD", - "sid": "CAXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - "start_time": "Fri, 18 Oct 2019 17:02:00 +0000", - "status": "completed", - "subresource_uris": { - "feedback": "/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Calls/CAXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Feedback.json", - "feedback_summaries": "/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Calls/FeedbackSummary.json", - "notifications": "/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Calls/CAXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Notifications.json", - "recordings": "/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Calls/CAXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Recordings.json", - "payments": "/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Calls/CAXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Payments.json" - }, - "to": "+13051913581", - "to_formatted": "(305) 191-3581", - "trunk_sid": "TKXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - "uri": "/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Calls/CAXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.json", - "queue_time": "1000" - } - ] + [ + { + "account_sid": "ACXXX", + "annotation": "billingreferencetag1", + "answered_by": "machine_start", + "api_version": "2010-04-01", + "caller_name": "callerid1", + "date_created": "Fri, 18 Oct 2019 17:00:00 +0000", + "date_updated": "Fri, 18 Oct 2019 17:01:00 +0000", + "direction": "outbound-api", + "duration": "4", + "end_time": "Fri, 18 Oct 2019 17:03:00 +0000", + "forwarded_from": "calledvia1", + "from": "+13051416799", + "from_formatted": "(305) 141-6799", + "group_sid": "GPXXX", + "parent_call_sid": "CAXXX", + "phone_number_sid": "PNXXX", + "price": "-0.200", + "price_unit": "USD", + "sid": "CAXXX", + "start_time": "Fri, 18 Oct 2019 17:02:00 +0000", + "status": "completed", + "subresource_uris": { + "feedback": "/2010-04-01/Accounts/ACXXX/Calls/CAXXX/Feedback.json", + "feedback_summaries": "/2010-04-01/Accounts/ACXXX/Calls/FeedbackSummary.json", + "notifications": "/2010-04-01/Accounts/ACXXX/Calls/CAXXX/Notifications.json", + "recordings": "/2010-04-01/Accounts/ACXXX/Calls/CAXXX/Recordings.json", + "payments": "/2010-04-01/Accounts/ACXXX/Calls/CAXXX/Payments.json" + }, + "to": "+13051913581", + "to_formatted": "(305) 191-3581", + "trunk_sid": "TKXXX", + "uri": "/2010-04-01/Accounts/ACXXX/Calls/CAXXX.json", + "queue_time": "1000" + } + ] """ - # noinspection PyTypeChecker call_list = self.client.calls.list( to=to, from_=from_, parent_call_sid=parent_call_sid, status=status, start_time_before=datetime.datetime.fromisoformat(start_time_before) - if start_time_before else None, + if start_time_before + else None, start_time=datetime.datetime.fromisoformat(start_time) - if start_time else None, + if start_time + else None, start_time_after=datetime.datetime.fromisoformat(start_time_after) - if start_time_after else None, + if start_time_after + else None, end_time_before=datetime.datetime.fromisoformat(end_time_before) - if end_time_before else None, - end_time=datetime.datetime.fromisoformat(end_time) - if end_time else None, + if end_time_before + else None, + end_time=datetime.datetime.fromisoformat(end_time) if end_time else None, end_time_after=datetime.datetime.fromisoformat(end_time_after) - if end_time_after else None, + if end_time_after + else None, limit=limit, - page_size=page_size + page_size=page_size, ) - # noinspection PyProtectedMember - return json.loads(json.dumps([call._properties for call in call_list], indent=2, cls=Message.Encoder)) + return json.loads( + json.dumps( + [call._properties for call in call_list], indent=2, cls=Message.Encoder + ) + ) # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/weather/buienradar/__init__.py b/platypush/plugins/weather/buienradar/__init__.py index 09d5cfcba..11fb41971 100644 --- a/platypush/plugins/weather/buienradar/__init__.py +++ b/platypush/plugins/weather/buienradar/__init__.py @@ -1,18 +1,17 @@ from typing import Optional, Dict, Any from platypush.plugins import Plugin, action -from platypush.message.response.weather.buienradar import BuienradarWeatherResponse, BuienradarPrecipitationResponse, \ - BuienradarForecastResponse, BuienradarForecast +from platypush.message.response.weather.buienradar import ( + BuienradarWeatherResponse, + BuienradarPrecipitationResponse, + BuienradarForecastResponse, + BuienradarForecast, +) class WeatherBuienradarPlugin(Plugin): """ Plugin for getting weather updates through Buienradar - a Dutch weather app. - - Requires: - - * **buienradar** (``pip install buienradar``) - """ def __init__(self, lat: float, long: float, time_frame: int = 120, **kwargs): @@ -27,10 +26,15 @@ class WeatherBuienradarPlugin(Plugin): self.time_frame = time_frame self.latest_bulletin = {} - def get_data(self, lat: Optional[float] = None, long: Optional[float] = None, time_frame: Optional[int] = None) \ - -> Dict[str, Any]: + def get_data( + self, + lat: Optional[float] = None, + long: Optional[float] = None, + time_frame: Optional[int] = None, + ) -> Dict[str, Any]: # noinspection PyPackageRequirements from buienradar.buienradar import get_data, parse_data + # noinspection PyPackageRequirements from buienradar.constants import SUCCESS, CONTENT, RAINCONTENT, DATA @@ -48,7 +52,9 @@ class WeatherBuienradarPlugin(Plugin): return result.get(DATA, {}) @action - def get_weather(self, lat: Optional[float] = None, long: Optional[float] = None) -> BuienradarWeatherResponse: + def get_weather( + self, lat: Optional[float] = None, long: Optional[float] = None + ) -> BuienradarWeatherResponse: """ Get the current weather conditions. @@ -78,11 +84,13 @@ class WeatherBuienradarPlugin(Plugin): wind_direction=data.get('wind_irection'), wind_force=data.get('windforce'), wind_gust=data.get('windgust'), - wind_speed=data.get('windspeed') + wind_speed=data.get('windspeed'), ) @action - def get_forecast(self, lat: Optional[float] = None, long: Optional[float] = None) -> BuienradarForecastResponse: + def get_forecast( + self, lat: Optional[float] = None, long: Optional[float] = None + ) -> BuienradarForecastResponse: """ Get the weather forecast for the next days. @@ -90,29 +98,35 @@ class WeatherBuienradarPlugin(Plugin): :param long: Weather longitude (default: configured longitude) """ data = self.get_data(lat, long, 60).get('forecast', []) - return BuienradarForecastResponse([ - BuienradarForecast( - condition_name=d.get('condition', {}).get('condition'), - condition_name_long=d.get('condition', {}).get('exact'), - condition_image=d.get('condition', {}).get('image'), - date_time=d.get('datetime'), - rain=d.get('rain'), - min_rain=d.get('minrain'), - max_rain=d.get('maxrain'), - rain_chance=d.get('rainchance'), - snow=d.get('snow'), - temperature=d.get('temperature'), - wind_azimuth=d.get('windazimuth'), - wind_direction=d.get('winddirection'), - wind_force=d.get('windforce'), - wind_speed=d.get('windspeed'), - ) - for d in data - ]) + return BuienradarForecastResponse( + [ + BuienradarForecast( + condition_name=d.get('condition', {}).get('condition'), + condition_name_long=d.get('condition', {}).get('exact'), + condition_image=d.get('condition', {}).get('image'), + date_time=d.get('datetime'), + rain=d.get('rain'), + min_rain=d.get('minrain'), + max_rain=d.get('maxrain'), + rain_chance=d.get('rainchance'), + snow=d.get('snow'), + temperature=d.get('temperature'), + wind_azimuth=d.get('windazimuth'), + wind_direction=d.get('winddirection'), + wind_force=d.get('windforce'), + wind_speed=d.get('windspeed'), + ) + for d in data + ] + ) @action - def get_precipitation(self, lat: Optional[float] = None, long: Optional[float] = None, - time_frame: Optional[int] = None) -> BuienradarPrecipitationResponse: + def get_precipitation( + self, + lat: Optional[float] = None, + long: Optional[float] = None, + time_frame: Optional[int] = None, + ) -> BuienradarPrecipitationResponse: """ Get the precipitation forecast for the specified time frame. diff --git a/platypush/plugins/websocket/__init__.py b/platypush/plugins/websocket/__init__.py index 0f4eb49f5..4f2c58651 100644 --- a/platypush/plugins/websocket/__init__.py +++ b/platypush/plugins/websocket/__init__.py @@ -16,12 +16,6 @@ from platypush.utils import get_ssl_client_context class WebsocketPlugin(AsyncRunnablePlugin): """ Plugin to send and receive messages over websocket connections. - - Triggers: - - * :class:`platypush.message.event.websocket.WebsocketMessageEvent` when - a message is received on a subscribed websocket. - """ def __init__(self, subscriptions: Optional[Collection[str]] = None, **kwargs): diff --git a/platypush/plugins/xmpp/__init__.py b/platypush/plugins/xmpp/__init__.py index 15a9458eb..35e54d947 100644 --- a/platypush/plugins/xmpp/__init__.py +++ b/platypush/plugins/xmpp/__init__.py @@ -29,45 +29,6 @@ from ._types import Errors, XmppPresence class XmppPlugin(AsyncRunnablePlugin, XmppBasePlugin): """ XMPP integration. - - Requires: - - * **aioxmpp** (``pip install aioxmpp``) - * **pytz** (``pip install pytz``) - - Triggers: - - * :class:`platypush.message.event.xmpp.XmppConnectedEvent` - * :class:`platypush.message.event.xmpp.XmppContactAddRequestAcceptedEvent` - * :class:`platypush.message.event.xmpp.XmppContactAddRequestEvent` - * :class:`platypush.message.event.xmpp.XmppContactAddRequestRejectedEvent` - * :class:`platypush.message.event.xmpp.XmppConversationAddedEvent` - * :class:`platypush.message.event.xmpp.XmppConversationEnterEvent` - * :class:`platypush.message.event.xmpp.XmppConversationExitEvent` - * :class:`platypush.message.event.xmpp.XmppConversationJoinEvent` - * :class:`platypush.message.event.xmpp.XmppConversationLeaveEvent` - * :class:`platypush.message.event.xmpp.XmppConversationNickChangedEvent` - * :class:`platypush.message.event.xmpp.XmppDisconnectedEvent` - * :class:`platypush.message.event.xmpp.XmppMessageReceivedEvent` - * :class:`platypush.message.event.xmpp.XmppPresenceChangedEvent` - * :class:`platypush.message.event.xmpp.XmppRoomAffiliationChangedEvent` - * :class:`platypush.message.event.xmpp.XmppRoomEnterEvent` - * :class:`platypush.message.event.xmpp.XmppRoomExitEvent` - * :class:`platypush.message.event.xmpp.XmppRoomInviteAcceptedEvent` - * :class:`platypush.message.event.xmpp.XmppRoomInviteEvent` - * :class:`platypush.message.event.xmpp.XmppRoomInviteRejectedEvent` - * :class:`platypush.message.event.xmpp.XmppRoomJoinEvent` - * :class:`platypush.message.event.xmpp.XmppRoomLeaveEvent` - * :class:`platypush.message.event.xmpp.XmppRoomMessageReceivedEvent` - * :class:`platypush.message.event.xmpp.XmppRoomNickChangedEvent` - * :class:`platypush.message.event.xmpp.XmppRoomPresenceChangedEvent` - * :class:`platypush.message.event.xmpp.XmppRoomRoleChangedEvent` - * :class:`platypush.message.event.xmpp.XmppRoomTopicChangedEvent` - * :class:`platypush.message.event.xmpp.XmppRoomUserAvailableEvent` - * :class:`platypush.message.event.xmpp.XmppRoomUserUnavailableEvent` - * :class:`platypush.message.event.xmpp.XmppUserAvailableEvent` - * :class:`platypush.message.event.xmpp.XmppUserUnavailableEvent` - """ def __init__( diff --git a/platypush/plugins/zeroconf/__init__.py b/platypush/plugins/zeroconf/__init__.py index 9e7c77612..51275cf1d 100644 --- a/platypush/plugins/zeroconf/__init__.py +++ b/platypush/plugins/zeroconf/__init__.py @@ -3,11 +3,20 @@ import socket import time from typing import List, Dict, Any, Optional, Union -from zeroconf import Zeroconf, ServiceInfo, ServiceBrowser, ServiceListener, ZeroconfServiceTypes +from zeroconf import ( + Zeroconf, + ServiceInfo, + ServiceBrowser, + ServiceListener, + ZeroconfServiceTypes, +) from platypush.context import get_bus -from platypush.message.event.zeroconf import ZeroconfServiceAddedEvent, ZeroconfServiceRemovedEvent, \ - ZeroconfServiceUpdatedEvent +from platypush.message.event.zeroconf import ( + ZeroconfServiceAddedEvent, + ZeroconfServiceRemovedEvent, + ZeroconfServiceUpdatedEvent, +) from platypush.plugins import Plugin, action @@ -27,43 +36,53 @@ class ZeroconfListener(ServiceListener): @staticmethod def parse_service_info(info: ServiceInfo) -> dict: return { - 'addresses': [socket.inet_ntoa(addr) for addr in info.addresses if info.addresses], + 'addresses': [ + socket.inet_ntoa(addr) for addr in info.addresses if info.addresses + ], 'port': info.port, 'host_ttl': info.host_ttl, 'other_ttl': info.other_ttl, 'priority': info.priority, - 'properties': {k.decode() if isinstance(k, bytes) else k: v.decode() if isinstance(v, bytes) else v - for k, v in info.properties.items()}, + 'properties': { + k.decode() + if isinstance(k, bytes) + else k: v.decode() + if isinstance(v, bytes) + else v + for k, v in info.properties.items() + }, 'server': info.server, 'weight': info.weight, } def add_service(self, zc: Zeroconf, type_: str, name: str): info = self.get_service_info(zc, type_, name) - self.evt_queue.put(ZeroconfServiceAddedEvent(service_type=type_, service_name=name, service_info=info)) + self.evt_queue.put( + ZeroconfServiceAddedEvent( + service_type=type_, service_name=name, service_info=info + ) + ) def remove_service(self, zc: Zeroconf, type_: str, name: str): info = self.get_service_info(zc, type_, name) - self.evt_queue.put(ZeroconfServiceRemovedEvent(service_type=type_, service_name=name, service_info=info)) + self.evt_queue.put( + ZeroconfServiceRemovedEvent( + service_type=type_, service_name=name, service_info=info + ) + ) def update_service(self, zc: Zeroconf, type_: str, name: str): info = self.get_service_info(zc, type_, name) - self.evt_queue.put(ZeroconfServiceUpdatedEvent(service_type=type_, service_name=name, service_info=info)) + self.evt_queue.put( + ZeroconfServiceUpdatedEvent( + service_type=type_, service_name=name, service_info=info + ) + ) class ZeroconfPlugin(Plugin): """ Plugin for Zeroconf services discovery. - - Triggers: - - * :class:`platypush.message.event.zeroconf.ZeroconfServiceAddedEvent` when a new service is discovered. - * :class:`platypush.message.event.zeroconf.ZeroconfServiceUpdatedEvent` when a service is updated. - * :class:`platypush.message.event.zeroconf.ZeroconfServiceRemovedEvent` when a service is removed. - - Requires: - - * **zeroconf** (``pip install zeroconf``) """ def __init__(self, **kwargs): @@ -81,7 +100,9 @@ class ZeroconfPlugin(Plugin): return list(ZeroconfServiceTypes.find(timeout=timeout)) @action - def discover_service(self, service: Union[str, list], timeout: Optional[int] = 5) -> Dict[str, Any]: + def discover_service( + self, service: Union[str, list], timeout: Optional[int] = 5 + ) -> Dict[str, Any]: """ Find all the services matching the specified type. @@ -131,20 +152,23 @@ class ZeroconfPlugin(Plugin): to = discovery_start + timeout - time.time() if timeout else None try: evt = evt_queue.get(block=True, timeout=to) - if isinstance(evt, ZeroconfServiceAddedEvent) or isinstance(evt, ZeroconfServiceUpdatedEvent): + if isinstance( + evt, (ZeroconfServiceAddedEvent, ZeroconfServiceUpdatedEvent) + ): services[evt.service_name] = { 'type': evt.service_type, 'name': evt.service_name, 'info': evt.service_info, } elif isinstance(evt, ZeroconfServiceRemovedEvent): - if evt.service_name in services: - del services[evt.service_name] + services.pop(evt.service_name, None) get_bus().post(evt) except queue.Empty: if not services: - self.logger.warning('No such service discovered: {}'.format(service)) + self.logger.warning( + 'No such service discovered: {}'.format(service) + ) finally: if browser: browser.cancel() diff --git a/platypush/plugins/zigbee/mqtt/__init__.py b/platypush/plugins/zigbee/mqtt/__init__.py index a6ef93541..75bf47d70 100644 --- a/platypush/plugins/zigbee/mqtt/__init__.py +++ b/platypush/plugins/zigbee/mqtt/__init__.py @@ -112,8 +112,10 @@ class ZigbeeMqttPlugin( .. code-block:: shell - wget https://github.com/Koenkk/Z-Stack-firmware/raw/master\ - /coordinator/Z-Stack_Home_1.2/bin/default/CC2531_DEFAULT_20201127.zip + # Check out the latest version of the coordinator firmware at + # https://github.com/Koenkk/Z-Stack-firmware/tree/master/coordinator + + wget https://github.com/Koenkk/Z-Stack-firmware/raw/master/coordinator//bin/default/.zip unzip CC2531_DEFAULT_20201127.zip [sudo] cc-tool -e -w CC2531ZNP-Prod.hex @@ -129,19 +131,18 @@ class ZigbeeMqttPlugin( .. code-block:: shell # Clone zigbee2mqtt repository - [sudo] git clone https://github.com/Koenkk/zigbee2mqtt.git /opt/zigbee2mqtt - [sudo] chown -R pi:pi /opt/zigbee2mqtt # Or whichever is your user - - # Install dependencies (as user "pi") - cd /opt/zigbee2mqtt + export ZIGBEE2MQTT_DIR="$HOME/zigbee2mqtt" + git clone https://github.com/Koenkk/zigbee2mqtt.git "$ZIGBEE2MQTT_DIR" + cd "$ZIGBEE2MQTT_DIR" + # Install dependencies npm install - You need to have an MQTT broker running somewhere. If not, you can install `Mosquitto `_ through your package manager on any device in your network. - - Edit the ``/opt/zigbee2mqtt/data/configuration.yaml`` file to match - the configuration of your MQTT broker: + - Edit ``$ZIGBEE2MQTT_DIR/data/configuration.yaml`` file to match the configuration of + your MQTT broker: .. code-block:: yaml @@ -169,7 +170,7 @@ class ZigbeeMqttPlugin( .. code-block:: shell - cd /opt/zigbee2mqtt + cd "$ZIGBEE2MQTT_DIR" npm start - If you have Zigbee devices that are paired to other bridges, unlink @@ -184,32 +185,6 @@ class ZigbeeMqttPlugin( - You are now ready to use this integration. - Requires: - - * **paho-mqtt** (``pip install paho-mqtt``) - - Triggers: - - * :class:`platypush.message.event.zigbee.mqtt.ZigbeeMqttOnlineEvent` - * :class:`platypush.message.event.zigbee.mqtt.ZigbeeMqttOfflineEvent` - * :class:`platypush.message.event.zigbee.mqtt.ZigbeeMqttDevicePropertySetEvent` - * :class:`platypush.message.event.zigbee.mqtt.ZigbeeMqttDevicePairingEvent` - * :class:`platypush.message.event.zigbee.mqtt.ZigbeeMqttDeviceConnectedEvent` - * :class:`platypush.message.event.zigbee.mqtt.ZigbeeMqttDeviceBannedEvent` - * :class:`platypush.message.event.zigbee.mqtt.ZigbeeMqttDeviceRemovedEvent` - * :class:`platypush.message.event.zigbee.mqtt.ZigbeeMqttDeviceRemovedFailedEvent` - * :class:`platypush.message.event.zigbee.mqtt.ZigbeeMqttDeviceWhitelistedEvent` - * :class:`platypush.message.event.zigbee.mqtt.ZigbeeMqttDeviceRenamedEvent` - * :class:`platypush.message.event.zigbee.mqtt.ZigbeeMqttDeviceBindEvent` - * :class:`platypush.message.event.zigbee.mqtt.ZigbeeMqttDeviceUnbindEvent` - * :class:`platypush.message.event.zigbee.mqtt.ZigbeeMqttGroupAddedEvent` - * :class:`platypush.message.event.zigbee.mqtt.ZigbeeMqttGroupAddedFailedEvent` - * :class:`platypush.message.event.zigbee.mqtt.ZigbeeMqttGroupRemovedEvent` - * :class:`platypush.message.event.zigbee.mqtt.ZigbeeMqttGroupRemovedFailedEvent` - * :class:`platypush.message.event.zigbee.mqtt.ZigbeeMqttGroupRemoveAllEvent` - * :class:`platypush.message.event.zigbee.mqtt.ZigbeeMqttGroupRemoveAllFailedEvent` - * :class:`platypush.message.event.zigbee.mqtt.ZigbeeMqttErrorEvent` - """ # noqa: E501 def __init__( @@ -231,7 +206,7 @@ class ZigbeeMqttPlugin( :param host: Default MQTT broker where ``zigbee2mqtt`` publishes its messages. :param port: Broker listen port (default: 1883). :param topic_prefix: Prefix for the published topics, as specified in - ``/opt/zigbee2mqtt/data/configuration.yaml`` (default: '``zigbee2mqtt``'). + ``ZIGBEE2MQTT_DIR/data/configuration.yaml`` (default: '``zigbee2mqtt``'). :param base_topic: Legacy alias for ``topic_prefix`` (default: '``zigbee2mqtt``'). :param timeout: If the command expects from a response, then this diff --git a/platypush/plugins/zwave/mqtt/__init__.py b/platypush/plugins/zwave/mqtt/__init__.py index 47e729b46..38c367a60 100644 --- a/platypush/plugins/zwave/mqtt/__init__.py +++ b/platypush/plugins/zwave/mqtt/__init__.py @@ -101,21 +101,6 @@ class ZwaveMqttPlugin( * Gateway -> Send Zwave Events: Set to true. * Gateway -> Include Node Info: Set to true. - Requires: - - * **paho-mqtt** (``pip install paho-mqtt``) - - Triggers: - - * :class:`platypush.message.event.zwave.ZwaveNodeEvent` - * :class:`platypush.message.event.zwave.ZwaveNodeAddedEvent` - * :class:`platypush.message.event.zwave.ZwaveNodeRemovedEvent` - * :class:`platypush.message.event.zwave.ZwaveNodeRenamedEvent` - * :class:`platypush.message.event.zwave.ZwaveNodeReadyEvent` - * :class:`platypush.message.event.zwave.ZwaveValueChangedEvent` - * :class:`platypush.message.event.zwave.ZwaveNodeAsleepEvent` - * :class:`platypush.message.event.zwave.ZwaveNodeAwakeEvent` - """ # These classes are ignored by the entity parsing logic diff --git a/platypush/utils/__init__.py b/platypush/utils/__init__.py index 52516221a..8fee2ba99 100644 --- a/platypush/utils/__init__.py +++ b/platypush/utils/__init__.py @@ -5,17 +5,17 @@ import hashlib import importlib import inspect import logging -from multiprocessing import Lock as PLock import os import pathlib import re import signal import socket import ssl -import urllib.request -from threading import Lock as TLock -from tempfile import gettempdir import time +import urllib.request +from multiprocessing import Lock as PLock +from tempfile import gettempdir +from threading import Lock as TLock from typing import Generator, Optional, Tuple, Type, Union from dateutil import parser, tz diff --git a/platypush/utils/manifest.py b/platypush/utils/manifest.py index 8ca220469..8eae5d51b 100644 --- a/platypush/utils/manifest.py +++ b/platypush/utils/manifest.py @@ -28,7 +28,6 @@ from typing import ( import yaml -from platypush.message.event import Event from platypush.utils import get_src_root, is_root _available_package_manager = None @@ -52,6 +51,28 @@ class BaseImage(Enum): return self.value +@dataclass +class OSMeta: + """ + Operating system metadata. + """ + + name: str + description: str + + +class OS(Enum): + """ + Supported operating systems. + """ + + ALPINE = OSMeta('alpine', 'Alpine') + ARCH = OSMeta('arch', 'Arch Linux') + DEBIAN = OSMeta('debian', 'Debian') + FEDORA = OSMeta('fedora', 'Fedora') + UBUNTU = OSMeta('ubuntu', 'Ubuntu') + + @dataclass class PackageManager: """ @@ -60,11 +81,13 @@ class PackageManager: executable: str """ The executable name. """ - default_os: str + default_os: OS """ The default distro whose configuration we should use if this package manager is detected. """ + install_doc: str + """ The base install command that will be used in the generated documentation. """ install: Sequence[str] = field(default_factory=tuple) """ The install command, as a sequence of strings. """ uninstall: Sequence[str] = field(default_factory=tuple) @@ -79,8 +102,8 @@ class PackageManager: def _get_installed(self) -> Sequence[str]: """ - :return: The install context-aware list of installed packages. - It should only used within the context of :meth:`.get_installed`. + :return: The context-aware list of installed packages. + It should only be used within the context of :meth:`.get_installed`. """ if os.environ.get('DOCKER_CTX'): @@ -114,40 +137,57 @@ class PackageManagers(Enum): APK = PackageManager( executable='apk', + install_doc='apk add', install=('apk', 'add', '--update', '--no-interactive', '--no-cache'), uninstall=('apk', 'del', '--no-interactive'), list=('apk', 'list', '--installed'), - default_os='alpine', - parse_list_line=lambda line: re.sub(r'.*\s*\{(.+?)\}\s*.*', r'\1', line), + default_os=OS.ALPINE, + parse_list_line=lambda line: re.sub(r'.*\s*\{(.+?)}\s*.*', r'\1', line), ) APT = PackageManager( executable='apt', + install_doc='apt install', install=('apt', 'install', '-y'), uninstall=('apt', 'remove', '-y'), list=('apt', 'list', '--installed'), - default_os='debian', + default_os=OS.DEBIAN, parse_list_line=lambda line: line.split('/')[0], ) DNF = PackageManager( executable='dnf', + install_doc='yum install', install=('dnf', 'install', '-y'), uninstall=('dnf', 'remove', '-y'), list=('dnf', 'list', '--installed'), - default_os='fedora', + default_os=OS.FEDORA, parse_list_line=lambda line: re.split(r'\s+', line)[0].split('.')[0], ) PACMAN = PackageManager( executable='pacman', + install_doc='pacman -S', install=('pacman', '-S', '--noconfirm', '--needed'), uninstall=('pacman', '-R', '--noconfirm'), list=('pacman', '-Q'), - default_os='arch', + default_os=OS.ARCH, parse_list_line=lambda line: line.split(' ')[0], ) + @classmethod + def by_executable(cls, name: str) -> "PackageManagers": + """ + :param name: The name of the package manager executable to get the + package manager for. + :return: The `PackageManager` object for the given executable. + """ + pkg_manager = next(iter(pm for pm in cls if pm.value.executable == name), None) + if not pkg_manager: + raise ValueError(f'Unknown package manager: {name}') + + return pkg_manager + @classmethod def get_command(cls, name: str) -> Iterable[str]: """ @@ -230,6 +270,8 @@ class Dependencies: """ The installation context - Docker, virtual environment or bare metal. """ base_image: Optional[BaseImage] = None """ Base image used in case of Docker installations. """ + by_pkg_manager: Dict[PackageManagers, Set[str]] = field(default_factory=dict) + """ All system dependencies, grouped by package manager. """ @property def _is_venv(self) -> bool: @@ -313,7 +355,7 @@ class Dependencies: return cls._parse_requirements_file( os.path.join( - cls._get_requirements_dir(), pkg_manager.value.default_os + '.txt' + cls._get_requirements_dir(), pkg_manager.value.default_os.name + '.txt' ), install_context, ) @@ -484,15 +526,17 @@ class Manifest(ABC): deps.before += items elif key == 'after': deps.after += items - elif self._pkg_manager and key == self._pkg_manager.value.executable: - deps.packages.update(items) + else: + deps.by_pkg_manager[PackageManagers.by_executable(key)] = set(items) + if self._pkg_manager and key == self._pkg_manager.value.executable: + deps.packages.update(items) return deps @staticmethod def _init_events( events: Union[Iterable[str], Mapping[str, Optional[str]]] - ) -> Dict[Type[Event], str]: + ) -> Dict[Type, str]: evt_dict = events if isinstance(events, Mapping) else {e: None for e in events} ret = {} diff --git a/platypush/utils/mock.py b/platypush/utils/mock.py new file mode 100644 index 000000000..11ea777e6 --- /dev/null +++ b/platypush/utils/mock.py @@ -0,0 +1,197 @@ +import os +import sys +from contextlib import contextmanager +from importlib.abc import Loader, MetaPathFinder +from importlib.machinery import ModuleSpec +from types import ModuleType +from typing import Any, Iterator, Sequence, Generator, Optional, List + + +class MockObject: + """ + Generic object that can be used to mock anything. + """ + + __display_name__ = "MockObject" + __name__ = "" + __decorator_args__: tuple[Any, ...] = () + + def __new__(cls, *args: Any, **_) -> Any: + if len(args) == 3 and isinstance(args[1], tuple): + superclass = args[1][-1].__class__ + if superclass is cls: + # subclassing MockObject + return _make_subclass( + args[0], + superclass.__display_name__, + superclass=superclass, + attributes=args[2], + ) + + return super().__new__(cls) + + def __init__(self, *_, **__) -> None: + self.__qualname__ = self.__name__ + + def __len__(self) -> int: + """ + Override __len__ so it returns zero. + """ + return 0 + + def __contains__(self, _: str) -> bool: + """ + Override __contains__ so it always returns False. + """ + return False + + def __iter__(self) -> Iterator: + """ + Override __iter__ so it always returns an empty iterator. + """ + return iter([]) + + def __mro_entries__(self, _: tuple) -> tuple: + """ + Override __mro_entries__ so it always returns a tuple containing the + class itself. + """ + return (self.__class__,) + + def __getitem__(self, key: Any) -> "MockObject": + """ + Override __getitem__ so it always returns a new MockObject. + """ + return _make_subclass(str(key), self.__display_name__, self.__class__)() + + def __getattr__(self, key: str) -> "MockObject": + """ + Override __getattr__ so it always returns a new MockObject. + """ + return _make_subclass(key, self.__display_name__, self.__class__)() + + def __call__(self, *args: Any, **_) -> Any: + """ + Override __call__ so it always returns a new MockObject. + """ + call = self.__class__() + call.__decorator_args__ = args + return call + + def __repr__(self) -> str: + """ + Override __repr__ to return the display name. + """ + return self.__display_name__ + + +def _make_subclass( + name: str, + module: str, + superclass: Any = MockObject, + attributes: Any = None, + decorator_args: tuple = (), +) -> Any: + """ + Utility method that creates a mock subclass on the fly given its + parameters. + """ + attrs = { + "__module__": module, + "__display_name__": module + "." + name, + "__name__": name, + "__decorator_args__": decorator_args, + } + + attrs.update(attributes or {}) + return type(name, (superclass,), attrs) + + +# pylint: disable=too-few-public-methods +class MockModule(ModuleType): + """ + Object that can be used to mock any module. + """ + + __file__ = os.devnull + + def __init__(self, name: str): + super().__init__(name) + self.__all__ = [] + self.__path__ = [] + + def __getattr__(self, name: str): + """ + Override __getattr__ so it always returns a new MockObject. + """ + return _make_subclass(name, self.__name__)() + + def __mro_entries__(self, _: tuple) -> tuple: + """ + Override __mro_entries__ so it always returns a tuple containing the + class itself. + """ + return (self.__class__,) + + +class MockFinder(MetaPathFinder): + """A finder for mocking.""" + + def __init__(self, modules: Sequence[str]) -> None: + super().__init__() + self.modules = modules + self.loader = MockLoader(self) + self.mocked_modules: List[str] = [] + + def find_spec( + self, + fullname: str, + path: Sequence[Optional[bytes]] | None, + target: Optional[ModuleType] = None, + ) -> ModuleSpec | None: + for modname in self.modules: + # check if fullname is (or is a descendant of) one of our targets + if modname == fullname or fullname.startswith(modname + "."): + return ModuleSpec(fullname, self.loader) + + return None + + def invalidate_caches(self) -> None: + """Invalidate mocked modules on sys.modules.""" + for modname in self.mocked_modules: + sys.modules.pop(modname, None) + + +class MockLoader(Loader): + """A loader for mocking.""" + + def __init__(self, finder: MockFinder) -> None: + super().__init__() + self.finder = finder + + def create_module(self, spec: ModuleSpec) -> ModuleType: + self.finder.mocked_modules.append(spec.name) + return MockModule(spec.name) + + def exec_module(self, module: ModuleType) -> None: + pass # nothing to do + + +@contextmanager +def mock(*modules: str) -> Generator[None, None, None]: + """ + Insert mock modules during context:: + + with mock('target.module.name'): + # mock modules are enabled here + ... + """ + finder = None + try: + finder = MockFinder(modules) + sys.meta_path.insert(0, finder) + yield + finally: + if finder: + sys.meta_path.remove(finder) + finder.invalidate_caches() diff --git a/platypush/utils/reflection/__init__.py b/platypush/utils/reflection/__init__.py new file mode 100644 index 000000000..09c087838 --- /dev/null +++ b/platypush/utils/reflection/__init__.py @@ -0,0 +1,318 @@ +import contextlib +import inspect +import os +import re +import textwrap as tw +from dataclasses import dataclass, field +from importlib.machinery import SourceFileLoader +from importlib.util import spec_from_loader, module_from_spec +from typing import Optional, Type, Union, Callable, Dict, Set + +from platypush.utils import ( + get_backend_class_by_name, + get_backend_name_by_class, + get_plugin_class_by_name, + get_plugin_name_by_class, + get_decorators, +) +from platypush.utils.manifest import Manifest, ManifestType, Dependencies +from platypush.utils.reflection._parser import DocstringParser, Parameter + + +class Action(DocstringParser): + """ + Represents an integration action. + """ + + +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 +class IntegrationMetadata: + """ + Represents the metadata of an integration (plugin or backend). + """ + + _class_type_re = re.compile(r"^[\w_]+)'>$") + + name: str + type: Type + doc: Optional[str] = None + constructor: Optional[Constructor] = None + actions: Dict[str, Action] = field(default_factory=dict) + _manifest: Optional[Manifest] = None + _skip_manifest: bool = False + + def __post_init__(self): + if not self._skip_manifest: + self._init_manifest() + + @staticmethod + def _merge_params(params: Dict[str, Parameter], new_params: Dict[str, Parameter]): + """ + 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 + + @classmethod + def _merge_actions(cls, actions: Dict[str, Action], new_actions: Dict[str, Action]): + """ + Utility function to merge a new mapping of actions into an existing one. + """ + for action_name, action in new_actions.items(): + # Set the action if it doesn't exist + if action_name not in actions: + actions[action_name] = action + + # Set the action documentation if it's not set + if action.doc and not actions[action_name].doc: + actions[action_name].doc = action.doc + + # Merge the parameters + cls._merge_params(actions[action_name].params, action.params) + + @classmethod + def _merge_events(cls, events: Set[Type], new_events: Set[Type]): + """ + Utility function to merge a new mapping of actions into an existing one. + """ + events.update(new_events) + + @classmethod + def by_name(cls, name: str) -> "IntegrationMetadata": + """ + :param name: Integration name. + :return: A parsed Integration class given its type. + """ + type = ( + get_backend_class_by_name(".".join(name.split(".")[1:])) + if name.startswith("backend.") + else get_plugin_class_by_name(name) + ) + return cls.by_type(type) + + @classmethod + def by_type(cls, type: Type, _skip_manifest: bool = False) -> "IntegrationMetadata": + """ + :param type: Integration type (plugin or backend). + :param _skip_manifest: Whether we should skip parsing the manifest file for this integration + (you SHOULDN'T use this flag outside of this class!). + :return: A parsed Integration class given its type. + """ + from platypush.backend import Backend + from platypush.plugins import Plugin + + assert issubclass( + type, (Plugin, Backend) + ), f"Expected a Plugin or Backend class, got {type}" + + name = ( + get_plugin_name_by_class(type) + if issubclass(type, Plugin) + else "backend." + get_backend_name_by_class(type) + ) + + assert name + obj = cls( + name=name, + type=type, + doc=inspect.getdoc(type), + constructor=Constructor.parse(type), + actions={ + name: Action.parse(getattr(type, name)) + for name in get_decorators(type, climb_class_hierarchy=True).get( + "action", [] + ) + }, + _skip_manifest=_skip_manifest, + ) + + for p_type in inspect.getmro(type)[1:]: + with contextlib.suppress(AssertionError): + p_obj = cls.by_type(p_type, _skip_manifest=True) + # Merge constructor parameters + if obj.constructor and p_obj.constructor: + cls._merge_params(obj.constructor.params, p_obj.constructor.params) + + # Merge actions + cls._merge_actions(obj.actions, p_obj.actions) + # Merge events + try: + cls._merge_events(obj.events, p_obj.events) + except FileNotFoundError: + pass + + return obj + + @property + def cls(self) -> Optional[Type]: + """ + :return: The class of an integration. + """ + manifest_type = self.manifest.package.split(".")[1] + if manifest_type == "backend": + getter = get_backend_class_by_name + elif manifest_type == "plugins": + getter = get_plugin_class_by_name + else: + return None + + return getter(".".join(self.manifest.package.split(".")[2:])) + + @classmethod + def from_manifest(cls, manifest_file: str) -> "IntegrationMetadata": + """ + Create an `IntegrationMetadata` object from a manifest file. + + :param manifest_file: Path of the manifest file. + :return: A parsed Integration class given its manifest file. + """ + manifest = Manifest.from_file(manifest_file) + name = ".".join( + [ + "backend" if manifest.manifest_type == ManifestType.BACKEND else "", + *manifest.package.split(".")[2:], + ] + ).strip(".") + + return cls.by_name(name) + + def _init_manifest(self) -> Manifest: + """ + Initialize the manifest object. + """ + if not self._manifest: + self._manifest = Manifest.from_file(self.manifest_file) + return self._manifest + + @classmethod + def _type_str(cls, param_type) -> str: + """ + Utility method to pretty-print the type string of a parameter. + """ + type_str = str(param_type).replace("typing.", "") + if m := cls._class_type_re.match(type_str): + return m.group("name") + + return type_str + + @property + def manifest(self) -> Manifest: + """ + :return: The parsed Manifest object. + """ + return self._init_manifest() + + @property + def manifest_file(self) -> str: + """ + :return: Path of the manifest file for the integration. + """ + return os.path.join( + os.path.dirname(inspect.getfile(self.type)), "manifest.yaml" + ) + + @property + def description(self) -> Optional[str]: + """ + :return: The description of the integration. + """ + return self.manifest.description + + @property + def events(self) -> Set[Type]: + """ + :return: Events triggered by the integration. + """ + return set(self.manifest.events) + + @property + def deps(self) -> Dependencies: + """ + :return: Dependencies of the integration. + """ + return self.manifest.install + + @classmethod + def _indent_yaml_comment(cls, s: str) -> str: + return tw.indent( + "\n".join( + [ + line if line.startswith("#") else f"# {line}" + for line in s.split("\n") + ] + ), + " ", + ) + + @property + def config_snippet(self) -> str: + """ + :return: A YAML snippet with the configuration parameters of the integration. + """ + return tw.dedent( + self.name + + ":\n" + + ( + "\n".join( + f' # [{"Required" if param.required else "Optional"}]\n' + + (f"{self._indent_yaml_comment(param.doc)}" if param.doc else "") + + "\n " + + ("# " if not param.required else "") + + f"{name}: " + + (str(param.default) if param.default is not None else "") + + ( + self._indent_yaml_comment(f"type={self._type_str(param.type)}") + if param.type + else "" + ) + + "\n" + for name, param in self.constructor.params.items() + ) + if self.constructor and self.constructor.params + else " # No configuration required\n" + ) + ) + + +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 diff --git a/platypush/utils/reflection/_parser.py b/platypush/utils/reflection/_parser.py new file mode 100644 index 000000000..57f07b8ac --- /dev/null +++ b/platypush/utils/reflection/_parser.py @@ -0,0 +1,233 @@ +import inspect +import re +import textwrap as tw +from contextlib import contextmanager +from dataclasses import dataclass, field +from enum import IntEnum +from typing import ( + Any, + Optional, + Iterable, + Type, + get_type_hints, + Callable, + Tuple, + Generator, + Dict, +) + + +@dataclass +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. + """ + + _param_doc_re = re.compile(r"^:param\s+(?P[\w_]+):\s+(?P.*)$") + _type_doc_re = re.compile(r"^:type\s+[\w_]+:.*$") + _return_doc_re = re.compile(r"^:return:\s+(?P.*)$") + + def __init__( + self, + name: str, + doc: Optional[str] = None, + params: Optional[Dict[str, Parameter]] = None, + returns: Optional[ReturnValue] = None, + ): + self.name = name + self.doc = doc + self.params = params or {} + self.returns = returns + + @classmethod + @contextmanager + def _parser(cls, obj: Callable) -> Generator[ParseContext, None, None]: + """ + Manages the parsing context manager. + + :param obj: Method to parse. + :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] + tw.dedent("\n".join(lines[1:]) or "")).strip() + + ctx = ParseContext(obj) + yield ctx + + # Normalize the parameters docstring indentation + for param in ctx.parsed_params.values(): + param.doc = norm_indent(param.doc) + + # Normalize the return docstring indentation + ctx.returns.doc = norm_indent(ctx.returns.doc) + + @staticmethod + def _is_continuation_line(line: str) -> bool: + return not line.strip() or line.startswith(" ") + + @classmethod + def _parse_line(cls, line: str, ctx: ParseContext): + """ + Parse a single line of the docstring and updates the parse context accordingly. + + :param line: Docstring line. + :param ctx: Parse context. + """ + # Ignore old in-doc type hints + if cls._type_doc_re.match(line) or ( + ctx.state == ParseState.TYPE and cls._is_continuation_line(line) + ): + ctx.state = ParseState.TYPE + return + + # Update the return type docstring if required + m = cls._return_doc_re.match(line) + if m or (ctx.state == ParseState.RETURN and cls._is_continuation_line(line)): + ctx.state = ParseState.RETURN + ctx.returns.doc = ((ctx.returns.doc + "\n") if ctx.returns.doc else "") + ( + m.group("doc") if m else line + ).rstrip() + return + + # Create a new parameter entry if the docstring says so + m = cls._param_doc_re.match(line) + if m: + ctx.state = ParseState.PARAM + idx = len(ctx.parsed_params) + ctx.cur_param = m.group("name") + if ctx.cur_param not in ctx.param_names: + return + + ctx.parsed_params[ctx.cur_param] = Parameter( + 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 + + # Update the current parameter docstring if required + if ( + ctx.state == ParseState.PARAM + and cls._is_continuation_line(line) + and ctx.cur_param in ctx.parsed_params + ): + ctx.parsed_params[ctx.cur_param].doc = ( + ((ctx.parsed_params[ctx.cur_param].doc or "") + "\n" + line.rstrip()) + if ctx.parsed_params.get(ctx.cur_param) + and ctx.parsed_params[ctx.cur_param].doc + else "" + ) + return + + # Update the current docstring if required + ctx.cur_param = None + ctx.doc = ((ctx.doc + "\n") if ctx.doc else "") + line.rstrip() + ctx.state = ParseState.DOC + + @classmethod + def parse(cls, obj: Callable): + """ + Parse the parameters of a class constructor or action method. + :param obj: Method to parse. + :return: The parsed parameters. + """ + with cls._parser(obj) as ctx: + for line in ctx.doc_lines: + cls._parse_line(line, ctx) + + return cls( + name=obj.__name__, + doc=ctx.doc, + params=ctx.parsed_params, + returns=ctx.returns, + )