From c3337ccc6c7a761bc76ecdb9e7006d5617569a9f Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sun, 24 Sep 2023 16:54:43 +0200 Subject: [PATCH 1/4] [#311] Docs deps autogen sphinx plugin. Added an `add_dependencies` plugin to the Sphinx build process that parses the manifest files of the scanned backends and plugins and automatically generates the documentation for the required dependencies and triggered events. This means that those dependencies are no longer required to be listed in the docstring of the class itself. Also in this commit: - Black/LINT for some integrations that hadn't been touched in a long time. - Deleted some leftovers from previous refactors (deprecated `backend.mqtt`, `backend.zwave.mqtt`, `backend.http.request.rss`). - Deleted deprecated `inotify` backend - replaced by `file.monitor` (see #289). --- .drone.yml | 2 +- docs/source/_ext/add_dependencies.py | 100 +++ docs/source/backends.rst | 3 - docs/source/conf.py | 1 + docs/source/platypush/backend/inotify.rst | 6 - docs/source/platypush/backend/mqtt.rst | 6 - docs/source/platypush/backend/zwave.mqtt.rst | 5 - .../platypush/plugins/http.request.rss.rst | 6 - docs/source/plugins.rst | 1 - platypush/backend/adafruit/io/__init__.py | 26 +- platypush/backend/alarm/__init__.py | 113 ++- .../backend/assistant/google/__init__.py | 34 - .../backend/assistant/snowboy/__init__.py | 11 +- platypush/backend/button/flic/__init__.py | 54 +- platypush/backend/camera/pi/__init__.py | 67 +- platypush/backend/chat/telegram/__init__.py | 11 - platypush/backend/file/monitor/__init__.py | 29 +- platypush/backend/foursquare/__init__.py | 16 +- platypush/backend/github/__init__.py | 21 - platypush/backend/google/fit/__init__.py | 77 +- platypush/backend/google/pubsub/__init__.py | 10 - platypush/backend/gps/__init__.py | 100 ++- .../backend/http/request/rss/__init__.py | 9 - platypush/backend/inotify/__init__.py | 101 --- platypush/backend/inotify/manifest.yaml | 21 - platypush/backend/joystick/__init__.py | 12 +- platypush/backend/joystick/jstest/__init__.py | 78 +- .../backend/joystick/jstest/manifest.yaml | 18 +- platypush/backend/joystick/linux/__init__.py | 141 ++-- platypush/backend/kafka/__init__.py | 39 +- platypush/backend/log/http/__init__.py | 52 +- platypush/backend/mail/__init__.py | 8 - platypush/backend/midi/__init__.py | 47 +- platypush/backend/mqtt/__init__.py | 475 ------------- platypush/backend/mqtt/manifest.yaml | 17 - platypush/backend/music/mopidy/__init__.py | 172 +++-- platypush/backend/music/mpd/__init__.py | 115 ++- platypush/backend/music/snapcast/__init__.py | 14 +- platypush/backend/music/spotify/__init__.py | 158 +++-- platypush/backend/nextcloud/__init__.py | 138 ++-- platypush/backend/nfc/__init__.py | 12 - platypush/backend/nodered/__init__.py | 20 +- platypush/backend/ping/__init__.py | 20 +- platypush/backend/pushbullet/__init__.py | 58 +- platypush/backend/scard/__init__.py | 35 +- .../backend/sensor/ir/zeroborg/__init__.py | 22 +- platypush/backend/sensor/leap/__init__.py | 151 ++-- platypush/backend/tcp/__init__.py | 13 +- platypush/backend/todoist/__init__.py | 47 +- platypush/backend/trello/__init__.py | 7 - .../backend/weather/buienradar/__init__.py | 33 +- platypush/backend/weather/darksky/__init__.py | 8 +- .../weather/openweathermap/__init__.py | 8 +- platypush/backend/wiimote/__init__.py | 6 +- platypush/backend/wiimote/manifest.yaml | 3 +- platypush/backend/zwave/__init__.py | 0 platypush/backend/zwave/mqtt/__init__.py | 34 - platypush/backend/zwave/mqtt/manifest.yaml | 28 - platypush/plugins/adafruit/io/__init__.py | 45 +- platypush/plugins/arduino/__init__.py | 11 - platypush/plugins/assistant/echo/__init__.py | 12 - .../assistant/google/pushtotalk/__init__.py | 16 - platypush/plugins/bluetooth/_plugin.py | 23 - platypush/plugins/calendar/ical/__init__.py | 5 - platypush/plugins/camera/__init__.py | 19 - platypush/plugins/camera/cv/__init__.py | 24 +- platypush/plugins/camera/ffmpeg/__init__.py | 45 +- .../plugins/camera/gstreamer/__init__.py | 14 - .../plugins/camera/ir/mlx90640/__init__.py | 42 +- platypush/plugins/camera/pi/__init__.py | 74 +- platypush/plugins/chat/irc/__init__.py | 26 - platypush/plugins/chat/telegram/__init__.py | 668 ++++++++++-------- platypush/plugins/clipboard/__init__.py | 9 - platypush/plugins/dbus/__init__.py | 67 +- platypush/plugins/dropbox/__init__.py | 90 ++- platypush/plugins/ffmpeg/__init__.py | 61 +- platypush/plugins/google/__init__.py | 5 - platypush/plugins/google/calendar/__init__.py | 8 +- platypush/plugins/google/drive/__init__.py | 146 ++-- platypush/plugins/google/fit/__init__.py | 52 +- platypush/plugins/google/mail/__init__.py | 6 - platypush/plugins/google/maps/__init__.py | 6 - platypush/plugins/google/pubsub/__init__.py | 28 +- .../plugins/google/translate/__init__.py | 39 +- platypush/plugins/google/youtube/__init__.py | 6 - platypush/plugins/gotify/__init__.py | 61 +- platypush/plugins/gpio/__init__.py | 10 - platypush/plugins/gpio/zeroborg/__init__.py | 29 +- platypush/plugins/hid/__init__.py | 9 - .../plugins/http/request/rss/__init__.py | 23 - .../plugins/http/request/rss/manifest.yaml | 15 - platypush/plugins/http/webpage/__init__.py | 2 - platypush/plugins/inputs/__init__.py | 7 +- platypush/plugins/kafka/__init__.py | 21 +- platypush/plugins/lastfm/__init__.py | 4 - platypush/plugins/lcd/__init__.py | 15 +- platypush/plugins/lcd/gpio/__init__.py | 59 +- platypush/plugins/lcd/i2c/__init__.py | 86 ++- platypush/plugins/light/hue/__init__.py | 12 - platypush/plugins/linode/__init__.py | 8 - platypush/plugins/luma/oled/__init__.py | 276 +++++--- platypush/plugins/mail/imap/__init__.py | 238 +++++-- platypush/plugins/matrix/__init__.py | 51 -- platypush/plugins/matrix/manifest.yaml | 91 +-- platypush/plugins/media/__init__.py | 9 - .../plugins/media/chromecast/__init__.py | 215 ++++-- platypush/plugins/media/gstreamer/__init__.py | 14 - platypush/plugins/media/kodi/__init__.py | 194 +++-- platypush/plugins/media/mplayer/__init__.py | 188 +++-- platypush/plugins/media/mpv/__init__.py | 7 +- platypush/plugins/media/omxplayer/__init__.py | 86 ++- platypush/plugins/media/plex/__init__.py | 137 ++-- platypush/plugins/media/subtitles/__init__.py | 86 ++- platypush/plugins/media/vlc/__init__.py | 153 ++-- .../plugins/media/webtorrent/__init__.py | 235 +++--- platypush/plugins/midi/__init__.py | 15 +- platypush/plugins/ml/cv/__init__.py | 9 +- platypush/plugins/mqtt/__init__.py | 12 +- platypush/plugins/music/mpd/__init__.py | 99 ++- platypush/plugins/music/tidal/__init__.py | 10 - platypush/plugins/nextcloud/__init__.py | 5 - platypush/plugins/ngrok/__init__.py | 72 +- platypush/plugins/ntfy/__init__.py | 5 - platypush/plugins/otp/__init__.py | 80 ++- platypush/plugins/printer/cups/__init__.py | 154 ++-- platypush/plugins/pwm/pca9685/__init__.py | 50 +- platypush/plugins/qrcode/__init__.py | 12 - platypush/plugins/qrcode/manifest.yaml | 3 +- platypush/plugins/rss/__init__.py | 10 - platypush/plugins/rtorrent/__init__.py | 124 +++- platypush/plugins/sensor/__init__.py | 7 - platypush/plugins/sensor/bme280/__init__.py | 13 +- platypush/plugins/sensor/dht/__init__.py | 11 - .../sensor/distance/vl53l1x/__init__.py | 12 +- .../plugins/sensor/envirophat/__init__.py | 11 - platypush/plugins/sensor/hcsr04/__init__.py | 13 - platypush/plugins/sensor/lis3dh/__init__.py | 11 - platypush/plugins/sensor/ltr559/__init__.py | 14 +- platypush/plugins/sensor/mcp3008/__init__.py | 11 - platypush/plugins/sensor/pmw3901/__init__.py | 13 +- platypush/plugins/serial/__init__.py | 11 - platypush/plugins/slack/__init__.py | 100 +-- platypush/plugins/smartthings/__init__.py | 5 - platypush/plugins/sound/__init__.py | 20 - platypush/plugins/ssh/__init__.py | 296 +++++--- platypush/plugins/stt/__init__.py | 93 ++- platypush/plugins/stt/deepspeech/__init__.py | 35 +- .../plugins/stt/picovoice/hotword/__init__.py | 70 +- .../plugins/stt/picovoice/speech/__init__.py | 56 +- platypush/plugins/sun/__init__.py | 74 +- platypush/plugins/switch/tplink/__init__.py | 5 - platypush/plugins/system/__init__.py | 6 - platypush/plugins/tensorflow/__init__.py | 23 - platypush/plugins/todoist/__init__.py | 22 +- platypush/plugins/torrent/__init__.py | 5 - platypush/plugins/trello/__init__.py | 420 ++++++----- platypush/plugins/tts/google/__init__.py | 110 ++- platypush/plugins/tv/samsung/ws/__init__.py | 104 ++- platypush/plugins/twilio/__init__.py | 646 +++++++++-------- .../plugins/weather/buienradar/__init__.py | 80 ++- platypush/plugins/websocket/__init__.py | 6 - platypush/plugins/xmpp/__init__.py | 39 - platypush/plugins/zeroconf/__init__.py | 72 +- platypush/plugins/zigbee/mqtt/__init__.py | 26 - platypush/plugins/zwave/mqtt/__init__.py | 15 - 165 files changed, 5131 insertions(+), 4691 deletions(-) create mode 100644 docs/source/_ext/add_dependencies.py delete mode 100644 docs/source/platypush/backend/inotify.rst delete mode 100644 docs/source/platypush/backend/mqtt.rst delete mode 100644 docs/source/platypush/backend/zwave.mqtt.rst delete mode 100644 docs/source/platypush/plugins/http.request.rss.rst delete mode 100644 platypush/backend/inotify/__init__.py delete mode 100644 platypush/backend/inotify/manifest.yaml delete mode 100644 platypush/backend/mqtt/__init__.py delete mode 100644 platypush/backend/mqtt/manifest.yaml delete mode 100644 platypush/backend/zwave/__init__.py delete mode 100644 platypush/backend/zwave/mqtt/__init__.py delete mode 100644 platypush/backend/zwave/mqtt/manifest.yaml delete mode 100644 platypush/plugins/http/request/rss/__init__.py delete mode 100644 platypush/plugins/http/request/rss/manifest.yaml diff --git a/.drone.yml b/.drone.yml index 004ab1f1..1f0b78df 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 00000000..8000fd8d --- /dev/null +++ b/docs/source/_ext/add_dependencies.py @@ -0,0 +1,100 @@ +import os +import re + +import yaml + +from sphinx.application import Sphinx + + +def add_events(source: list[str], manifest: dict, idx: int) -> int: + events = manifest.get('events', []) + if not events: + return idx + + source.insert( + idx, + 'Triggered events\n----------------\n\n' + + '\n'.join(f'\t- :class:`{event}`' for event in events) + + '\n\n', + ) + + return idx + 1 + + +def add_install_deps(source: list[str], manifest: dict, idx: int) -> int: + install_deps = manifest.get('install', {}) + install_cmds = { + 'pip': 'pip install', + 'Alpine': 'apk add', + 'Arch Linux': 'pacman -S', + 'Debian': 'apt install', + 'Fedora': 'yum install', + } + + parsed_deps = { + 'pip': install_deps.get('pip', []), + 'Alpine': install_deps.get('apk', []), + 'Arch Linux': install_deps.get('pacman', []), + 'Debian': install_deps.get('apt', []), + 'Fedora': install_deps.get('dnf', install_deps.get('yum', [])), + } + + if not any(parsed_deps.values()): + return idx + + source.insert(idx, 'Dependencies\n^^^^^^^^^^^^\n\n') + idx += 1 + + for env, deps in parsed_deps.items(): + if deps: + install_cmd = install_cmds[env] + source.insert( + idx, + f'**{env}**\n\n' + + '.. code-block:: bash\n\n\t' + + f'{install_cmd} ' + + ' '.join(deps) + + '\n\n', + ) + + idx += 1 + + return idx + + +def parse_dependencies(_: 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 + + base_path = os.path.abspath( + os.path.join(os.path.dirname(os.path.relpath(__file__)), '..', '..', '..') + ) + 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 open(manifest_file) as f: + manifest: dict = yaml.safe_load(f).get('manifest', {}) + + idx = add_install_deps(src, manifest, idx=3) + add_events(src, manifest, idx=idx) + source[0] = '\n'.join(src) + + +def setup(app: Sphinx): + app.connect('source-read', parse_dependencies) + + 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 60fe7847..9ac610bb 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 0f1f9836..a656a28a 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -52,6 +52,7 @@ extensions = [ 'sphinx.ext.githubpages', 'sphinx_rtd_theme', 'sphinx_marshmallow', + 'add_dependencies', ] # Add any paths that contain templates here, relative to this directory. diff --git a/docs/source/platypush/backend/inotify.rst b/docs/source/platypush/backend/inotify.rst deleted file mode 100644 index 0f0967dd..00000000 --- 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 5e788af8..00000000 --- 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 ed0175d7..00000000 --- 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 65b54c70..00000000 --- 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 a585a7a7..be9e6324 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 a403ed86..a22856ae 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 9879f482..a6004062 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 f35c269f..7e48a3e4 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( diff --git a/platypush/backend/assistant/snowboy/__init__.py b/platypush/backend/assistant/snowboy/__init__.py index 7d1c192b..6d4bd4f1 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 3e9ef4d1..038795be 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 d6f990cd..169a1588 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 c6c29aea..26bfa725 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 b2eb5802..b52ad8df 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 f560efe5..29f2122a 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 6922db39..a66b6364 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 486597c1..523db0a8 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 7d1aa984..689e4205 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 a9ab9ba3..2c6b9a79 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 6c624ae4..dc6b2c42 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 d8d47bd6..00000000 --- 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 d881d032..00000000 --- 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 ce962987..247f9a01 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 e7d3547e..79bb1a71 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 b4f94b68..d0269b76 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 9130e2a6..61ed80c5 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 bd4af2c9..a86b715c 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 c2fbb021..4ba1fad6 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 5dd4474f..1616c233 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 30db06ac..c396c13c 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 2619ce7d..00000000 --- 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 c1697bfe..00000000 --- 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 0448038a..6914c931 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 6ad71881..30ced92f 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 89d6303f..509776e0 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 cd59432f..1017db0d 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 66301326..a873b051 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 c481b77a..e6dc13a5 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 b8b4bedf..91bc0639 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 15dc321a..4577bbad 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 71f26db7..c328697b 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 532539cf..210467bb 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 076a6ea4..9f583a2d 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 5b8c09d1..9b2fff0f 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 2d83fedd..8b829ecb 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 1f1f4f12..ab2a5757 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 0c968cae..bfaf87c8 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 281b42d2..64a7dc8a 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 1051d5d3..5d89c0aa 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 90e0050e..26f35b1d 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 0fa1ae27..1d581961 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 b1f41b0c..44a6af1a 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 e69de29b..00000000 diff --git a/platypush/backend/zwave/mqtt/__init__.py b/platypush/backend/zwave/mqtt/__init__.py deleted file mode 100644 index 9bf93da5..00000000 --- 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 f539c555..00000000 --- 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 2f7e100e..31dcbfae 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 06b00543..a191bbec 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 8d8292c2..c9429ba8 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 b094534c..addcd0b8 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/_plugin.py b/platypush/plugins/bluetooth/_plugin.py index c9ea3be2..d3d826d6 100644 --- a/platypush/plugins/bluetooth/_plugin.py +++ b/platypush/plugins/bluetooth/_plugin.py @@ -53,29 +53,6 @@ class BluetoothPlugin(RunnablePlugin, EnumSwitchEntityManager): 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 diff --git a/platypush/plugins/calendar/ical/__init__.py b/platypush/plugins/calendar/ical/__init__.py index bfe4ec20..c5a74c93 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 632a0978..c2d4f049 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 6ed063b6..a2afe403 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 e2149efb..14801bc8 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 bb745b3c..f7a206e7 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 7a2c1ce4..db67b283 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 8e98d2a7..74e67a42 100644 --- a/platypush/plugins/camera/pi/__init__.py +++ b/platypush/plugins/camera/pi/__init__.py @@ -12,30 +12,44 @@ 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 +70,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 +115,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 +141,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 +154,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 +187,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 +197,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 681c5915..e5e11ec7 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 0d0d8826..504f2121 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 c9cef49d..c3b32ddb 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 8f588a92..86bd6301 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 615e36f5..2cf1a228 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 2942eadc..95473e26 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 310efcb8..39cbb1f1 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 5a3ac74f..0a0032d8 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 959e4830..ddee0907 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 ec66ffcc..f5043a31 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 081c07b1..0941d95d 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 73a5e39c..cc234c29 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 1f752113..1484cdd4 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 28d173a2..632a26cc 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 5f150206..82dfe0f0 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 bd041e34..8928cb71 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 07f18989..d17a7242 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 5ab97e26..44c6a216 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 7c9ac5fb..78716dbc 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 f1ec501f..00000000 --- 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 fbe46f14..00000000 --- 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 425fbade..20b07bb0 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 24787a79..7919f3f4 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 0ed8481c..376a6b9f 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 b34d2541..ff414d91 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 933ff2fd..4d07d3c3 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 1fe9f024..46c7c2a7 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 92fa03e6..77ed68f6 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 a901f82e..edb42a6d 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 diff --git a/platypush/plugins/linode/__init__.py b/platypush/plugins/linode/__init__.py index dbc37128..2214e7cc 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 842e5afa..02052c14 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 23dafcb1..2a57daa4 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 205da044..45aeeddc 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 9a397084..5a771c1c 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 67949857..e12b2c41 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 7ba9cb1d..1092edca 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 44e6bbf5..eab25552 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 b5669481..784bb912 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 f4219dca..79c1fdfa 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 19853cba..01eae6ea 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 d9eb868e..a33597c9 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 da3cb272..fcb6b983 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 b65277d1..337c1c37 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 7428a02e..05f3d2a8 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 3e654a22..4f484280 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 226a1c87..894ca1c1 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 2f49c105..24dd4b87 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 a43c0377..cdb6e490 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 e5d43244..d7acfaf0 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 c269db5f..7e3a2e35 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 682c97d5..abf63c6a 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 b3ba367c..115de07b 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 a7eddbad..3ca3b8f1 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 616a447f..73f1aa81 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 e7f7f403..d6bd1e82 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 6da28116..413259c4 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 93bfc1d4..b211cb23 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 4aab80eb..faee6bfb 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 1ef908c7..e7ad1d17 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 3fb76f75..a9bce8eb 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 c0559709..dd52809f 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 cf81f2c7..f33c9718 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 a5f9d642..80ec02fe 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 0fc373bf..140c97f4 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 366a8ae3..02e365e4 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 2f69586c..20cfe8a7 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 aee045c1..b85a2143 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 b0280da0..83dd6654 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 c6f4443e..5cc7244a 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 d566de51..7684cea7 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 69d20f0a..ee07cdaf 100644 --- a/platypush/plugins/serial/__init__.py +++ b/platypush/plugins/serial/__init__.py @@ -52,17 +52,6 @@ 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 diff --git a/platypush/plugins/slack/__init__.py b/platypush/plugins/slack/__init__.py index 69a97a55..0d9c7280 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 825eb981..0bb47bfe 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 5d158dc6..cb54f011 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 8aef1dab..ee7e5c9b 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 bca339f8..1df2ae45 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 afdcaf52..ca64b02c 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 4b41f877..5c776783 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 df12db5f..4043ec53 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 d757eb4c..e01e643d 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 c6b456e0..d06bc03b 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 099790ed..924149b1 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 00b512b3..b63f35ef 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 96b9a108..73210da4 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 71370795..eb645900 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 5db5d473..0eba5c16 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 c3965c56..e12672ed 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 31af99f1..0f537740 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 e70a949c..05fbd71a 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 09d5cfcb..11fb4197 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 0f4eb49f..4f2c5865 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 15a9458e..35e54d94 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 9e7c7761..51275cf1 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 a6ef9354..c725d918 100644 --- a/platypush/plugins/zigbee/mqtt/__init__.py +++ b/platypush/plugins/zigbee/mqtt/__init__.py @@ -184,32 +184,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__( diff --git a/platypush/plugins/zwave/mqtt/__init__.py b/platypush/plugins/zwave/mqtt/__init__.py index 47e729b4..38c367a6 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 From 9298f52443d9ec9bcb2007399cc0d095cd49eae5 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sun, 24 Sep 2023 19:20:30 +0200 Subject: [PATCH 2/4] Moved `BluetoothPlugin` to `__init__.py`. This is for consistency with other plugins, that all have their main plugin class definition inside of `__init__.py`. --- docs/source/conf.py | 5 - .../backend/assistant/google/__init__.py | 4 +- platypush/plugins/bluetooth/__init__.py | 649 ++++++++++++++++- platypush/plugins/bluetooth/_plugin.py | 651 ------------------ 4 files changed, 650 insertions(+), 659 deletions(-) delete mode 100644 platypush/plugins/bluetooth/_plugin.py diff --git a/docs/source/conf.py b/docs/source/conf.py index a656a28a..f44f1488 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -191,11 +191,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/platypush/backend/assistant/google/__init__.py b/platypush/backend/assistant/google/__init__.py index 7e48a3e4..f6967bcd 100644 --- a/platypush/backend/assistant/google/__init__.py +++ b/platypush/backend/assistant/google/__init__.py @@ -130,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/plugins/bluetooth/__init__.py b/platypush/plugins/bluetooth/__init__.py index e91e061b..e730c7d5 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 d3d826d6..00000000 --- a/platypush/plugins/bluetooth/_plugin.py +++ /dev/null @@ -1,651 +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. - """ - - _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: From 905d6632e01c700695e20807d2cadc4f87226662 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Fri, 29 Sep 2023 18:08:16 +0200 Subject: [PATCH 3/4] Misc documentation improvements. --- docs/source/conf.py | 7 ++----- platypush/plugins/bluetooth/__init__.py | 4 ++-- platypush/plugins/camera/pi/__init__.py | 5 +++-- platypush/plugins/light/hue/__init__.py | 2 +- platypush/plugins/serial/__init__.py | 8 ++------ platypush/plugins/zigbee/mqtt/__init__.py | 23 ++++++++++++----------- 6 files changed, 22 insertions(+), 27 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index f44f1488..4b159dc6 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 = '' diff --git a/platypush/plugins/bluetooth/__init__.py b/platypush/plugins/bluetooth/__init__.py index e730c7d5..ae8be5de 100644 --- a/platypush/plugins/bluetooth/__init__.py +++ b/platypush/plugins/bluetooth/__init__.py @@ -41,8 +41,8 @@ class BluetoothPlugin(RunnablePlugin, EnumSwitchEntityManager): """ Plugin to interact with Bluetooth devices. - This plugin uses `_Bleak_ `_ to interact - with the Bluetooth stack and `_Theengs_ `_ + 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 diff --git a/platypush/plugins/camera/pi/__init__.py b/platypush/plugins/camera/pi/__init__.py index 74e67a42..cd558221 100644 --- a/platypush/plugins/camera/pi/__init__.py +++ b/platypush/plugins/camera/pi/__init__.py @@ -13,8 +13,9 @@ class CameraPiPlugin(CameraPlugin): Plugin to control a Pi camera. .. 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. + 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. """ diff --git a/platypush/plugins/light/hue/__init__.py b/platypush/plugins/light/hue/__init__.py index edb42a6d..1bc8769f 100644 --- a/platypush/plugins/light/hue/__init__.py +++ b/platypush/plugins/light/hue/__init__.py @@ -76,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/serial/__init__.py b/platypush/plugins/serial/__init__.py index ee07cdaf..e9cfc0ca 100644 --- a/platypush/plugins/serial/__init__.py +++ b/platypush/plugins/serial/__init__.py @@ -58,7 +58,7 @@ class SerialPlugin(SensorPlugin): def __init__( self, - device: Optional[str] = None, + device: str, baud_rate: int = 9600, max_size: int = 1 << 19, timeout: float = _default_lock_timeout, @@ -67,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 @@ -195,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) @@ -262,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/zigbee/mqtt/__init__.py b/platypush/plugins/zigbee/mqtt/__init__.py index c725d918..75bf47d7 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 @@ -205,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 From 343972b520664a176461006663aee1cbc3376042 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sat, 30 Sep 2023 02:28:20 +0200 Subject: [PATCH 4/4] New `IntegrationMetadata` generic util class. This object is in charge of parsing all the metadata of a plugin/backend. --- docs/source/_ext/add_dependencies.py | 232 ++++++++++++------ platypush/utils/__init__.py | 8 +- platypush/utils/manifest.py | 70 +++++- platypush/utils/mock.py | 197 +++++++++++++++ platypush/utils/reflection/__init__.py | 318 +++++++++++++++++++++++++ platypush/utils/reflection/_parser.py | 233 ++++++++++++++++++ 6 files changed, 970 insertions(+), 88 deletions(-) create mode 100644 platypush/utils/mock.py create mode 100644 platypush/utils/reflection/__init__.py create mode 100644 platypush/utils/reflection/_parser.py diff --git a/docs/source/_ext/add_dependencies.py b/docs/source/_ext/add_dependencies.py index 8000fd8d..18c646c0 100644 --- a/docs/source/_ext/add_dependencies.py +++ b/docs/source/_ext/add_dependencies.py @@ -1,98 +1,188 @@ +import inspect import os import re - -import yaml +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__)), '..', '..', '..') +) -def add_events(source: list[str], manifest: dict, idx: int) -> int: - events = manifest.get('events', []) - if not events: - return idx +sys.path.insert(0, base_path) - source.insert( - idx, - 'Triggered events\n----------------\n\n' - + '\n'.join(f'\t- :class:`{event}`' for event in events) - + '\n\n', - ) - - return idx + 1 +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 -def add_install_deps(source: list[str], manifest: dict, idx: int) -> int: - install_deps = manifest.get('install', {}) - install_cmds = { - 'pip': 'pip install', - 'Alpine': 'apk add', - 'Arch Linux': 'pacman -S', - 'Debian': 'apt install', - 'Fedora': 'yum install', - } +class IntegrationEnricher: + @staticmethod + def add_events(source: list[str], manifest: IntegrationMetadata, idx: int) -> int: + if not manifest.events: + return idx - parsed_deps = { - 'pip': install_deps.get('pip', []), - 'Alpine': install_deps.get('apk', []), - 'Arch Linux': install_deps.get('pacman', []), - 'Debian': install_deps.get('apt', []), - 'Fedora': install_deps.get('dnf', install_deps.get('yum', [])), - } + source.insert( + idx, + 'Triggered events\n----------------\n\n' + + '\n'.join( + f'\t- :class:`{event.__module__}.{event.__qualname__}`' + for event in manifest.events + ) + + '\n\n', + ) - if not any(parsed_deps.values()): - return idx + return idx + 1 - source.insert(idx, 'Dependencies\n^^^^^^^^^^^^\n\n') - 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 - for env, deps in parsed_deps.items(): - if deps: - install_cmd = install_cmds[env] source.insert( idx, - f'**{env}**\n\n' - + '.. code-block:: bash\n\n\t' - + f'{install_cmd} ' - + ' '.join(deps) - + '\n\n', + cls._shellify( + pkg_manager.value.default_os.value.description, + pkg_manager.value.install_doc + ' ' + ' '.join(sys_deps), + ), ) idx += 1 - return idx + 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) -def parse_dependencies(_: 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 - - base_path = os.path.abspath( - os.path.join(os.path.dirname(os.path.relpath(__file__)), '..', '..', '..') - ) - 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 open(manifest_file) as f: - manifest: dict = yaml.safe_load(f).get('manifest', {}) - - idx = add_install_deps(src, manifest, idx=3) - add_events(src, manifest, idx=idx) - 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', parse_dependencies) - + app.connect('source-read', IntegrationEnricher()) return { 'version': '0.1', 'parallel_read_safe': True, diff --git a/platypush/utils/__init__.py b/platypush/utils/__init__.py index 52516221..8fee2ba9 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 8ca22046..8eae5d51 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 00000000..11ea777e --- /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 00000000..09c08783 --- /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 00000000..57f07b8a --- /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, + )