Merge branch 'master' into 191-support-for-general-entities-backend-and-plugin

This commit is contained in:
Fabio Manganiello 2022-10-07 00:05:54 +02:00
commit 2cc80e7f16
Signed by untrusted user: blacklight
GPG key ID: D90FBA7F76362774
20 changed files with 1097 additions and 154 deletions

View file

@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
Given the high speed of development in the first phase, changes are being Given the high speed of development in the first phase, changes are being
reported only starting from v0.20.2. reported only starting from v0.20.2.
## [Unreleased]
### Added
- Added [Wallabag integration](https://git.platypush.tech/platypush/platypush/issues/224).
- Added [Mimic3 TTS integration](https://git.platypush.tech/platypush/platypush/issues/226).
## [0.23.6] - 2022-09-19 ## [0.23.6] - 2022-09-19
### Fixed ### Fixed

View file

@ -3,7 +3,7 @@ Backends
======== ========
.. toctree:: .. toctree::
:maxdepth: 2 :maxdepth: 1
:caption: Backends: :caption: Backends:
platypush/backend/adafruit.io.rst platypush/backend/adafruit.io.rst

View file

@ -3,7 +3,7 @@ Events
====== ======
.. toctree:: .. toctree::
:maxdepth: 2 :maxdepth: 1
:caption: Events: :caption: Events:
platypush/events/adafruit.rst platypush/events/adafruit.rst

View file

@ -16,7 +16,7 @@ For more information on Platypush check out:
.. _Blog articles: https://blog.platypush.tech .. _Blog articles: https://blog.platypush.tech
.. toctree:: .. toctree::
:maxdepth: 3 :maxdepth: 2
:caption: Contents: :caption: Contents:
backends backends

View file

@ -0,0 +1,5 @@
``tts.mimic3``
==============
.. automodule:: platypush.plugins.tts.mimic3
:members:

View file

@ -0,0 +1,5 @@
``wallabag``
============
.. automodule:: platypush.plugins.wallabag
:members:

View file

@ -3,7 +3,7 @@ Plugins
======= =======
.. toctree:: .. toctree::
:maxdepth: 2 :maxdepth: 1
:caption: Plugins: :caption: Plugins:
platypush/plugins/adafruit.io.rst platypush/plugins/adafruit.io.rst
@ -133,12 +133,14 @@ Plugins
platypush/plugins/trello.rst platypush/plugins/trello.rst
platypush/plugins/tts.rst platypush/plugins/tts.rst
platypush/plugins/tts.google.rst platypush/plugins/tts.google.rst
platypush/plugins/tts.mimic3.rst
platypush/plugins/tv.samsung.ws.rst platypush/plugins/tv.samsung.ws.rst
platypush/plugins/twilio.rst platypush/plugins/twilio.rst
platypush/plugins/udp.rst platypush/plugins/udp.rst
platypush/plugins/user.rst platypush/plugins/user.rst
platypush/plugins/utils.rst platypush/plugins/utils.rst
platypush/plugins/variable.rst platypush/plugins/variable.rst
platypush/plugins/wallabag.rst
platypush/plugins/weather.buienradar.rst platypush/plugins/weather.buienradar.rst
platypush/plugins/weather.darksky.rst platypush/plugins/weather.darksky.rst
platypush/plugins/weather.openweathermap.rst platypush/plugins/weather.openweathermap.rst

View file

@ -3,7 +3,7 @@ Responses
========= =========
.. toctree:: .. toctree::
:maxdepth: 2 :maxdepth: 1
:caption: Responses: :caption: Responses:
platypush/responses/bluetooth.rst platypush/responses/bluetooth.rst

View file

@ -0,0 +1,46 @@
import requests
from urllib.parse import urljoin
from flask import abort, request, Blueprint
from platypush.backend.http.app import template_folder
mimic3 = Blueprint('mimic3', __name__, template_folder=template_folder)
# Declare routes list
__routes__ = [
mimic3,
]
@mimic3.route('/tts/mimic3/say', methods=['GET'])
def proxy_tts_request():
"""
This route is used to proxy the POST request to the Mimic3 TTS server
through a GET, so it can be easily processed as a URL through a media
plugin.
"""
required_args = {
'text',
'server_url',
'voice',
}
missing_args = required_args.difference(set(request.args.keys()))
if missing_args:
abort(400, f'Missing parameters: {missing_args}')
args = {arg: request.args[arg] for arg in required_args}
rs = requests.post(
urljoin(args['server_url'], '/api/tts'),
data=args['text'],
params={
'voice': args['voice'],
},
)
return rs.content
# vim:sw=4:ts=4:et:

View file

@ -35,13 +35,15 @@ def logger():
'format': '%(asctime)-15s|%(levelname)5s|%(name)s|%(message)s', 'format': '%(asctime)-15s|%(levelname)5s|%(name)s|%(message)s',
} }
level = (Config.get('backend.http') or {}).get('logging') or \ level = (Config.get('backend.http') or {}).get('logging') or (
(Config.get('logging') or {}).get('level') Config.get('logging') or {}
).get('level')
filename = (Config.get('backend.http') or {}).get('filename') filename = (Config.get('backend.http') or {}).get('filename')
if level: if level:
log_args['level'] = getattr(logging, level.upper()) \ log_args['level'] = (
if isinstance(level, str) else level getattr(logging, level.upper()) if isinstance(level, str) else level
)
if filename: if filename:
log_args['filename'] = filename log_args['filename'] = filename
@ -65,6 +67,7 @@ def get_message_response(msg):
# noinspection PyProtectedMember # noinspection PyProtectedMember
def get_http_port(): def get_http_port():
from platypush.backend.http import HttpBackend from platypush.backend.http import HttpBackend
http_conf = Config.get('backend.http') http_conf = Config.get('backend.http')
return http_conf.get('port', HttpBackend._DEFAULT_HTTP_PORT) return http_conf.get('port', HttpBackend._DEFAULT_HTTP_PORT)
@ -72,6 +75,7 @@ def get_http_port():
# noinspection PyProtectedMember # noinspection PyProtectedMember
def get_websocket_port(): def get_websocket_port():
from platypush.backend.http import HttpBackend from platypush.backend.http import HttpBackend
http_conf = Config.get('backend.http') http_conf = Config.get('backend.http')
return http_conf.get('websocket_port', HttpBackend._DEFAULT_WEBSOCKET_PORT) return http_conf.get('websocket_port', HttpBackend._DEFAULT_WEBSOCKET_PORT)
@ -89,17 +93,13 @@ def send_message(msg, wait_for_response=True):
if isinstance(msg, Request) and wait_for_response: if isinstance(msg, Request) and wait_for_response:
response = get_message_response(msg) response = get_message_response(msg)
logger().debug('Processing response on the HTTP backend: {}'. logger().debug('Processing response on the HTTP backend: {}'.format(response))
format(response))
return response return response
def send_request(action, wait_for_response=True, **kwargs): def send_request(action, wait_for_response=True, **kwargs):
msg = { msg = {'type': 'request', 'action': action}
'type': 'request',
'action': action
}
if kwargs: if kwargs:
msg['args'] = kwargs msg['args'] = kwargs
@ -113,8 +113,10 @@ def _authenticate_token():
if 'X-Token' in request.headers: if 'X-Token' in request.headers:
user_token = request.headers['X-Token'] user_token = request.headers['X-Token']
elif 'Authorization' in request.headers and request.headers['Authorization'].startswith('Bearer '): elif 'Authorization' in request.headers and request.headers[
user_token = request.headers['Authorization'][len('Bearer '):] 'Authorization'
].startswith('Bearer '):
user_token = request.headers['Authorization'][7:]
elif 'token' in request.args: elif 'token' in request.args:
user_token = request.args.get('token') user_token = request.args.get('token')
else: else:
@ -176,7 +178,10 @@ def _authenticate_csrf_token():
if user is None: if user is None:
return False return False
return session.csrf_token is None or request.form.get('csrf_token') == session.csrf_token return (
session.csrf_token is None
or request.form.get('csrf_token') == session.csrf_token
)
def authenticate(redirect_page='', skip_auth_methods=None, check_csrf_token=False): def authenticate(redirect_page='', skip_auth_methods=None, check_csrf_token=False):
@ -208,7 +213,9 @@ def authenticate(redirect_page='', skip_auth_methods=None, check_csrf_token=Fals
if session_auth_ok: if session_auth_ok:
return f(*args, **kwargs) return f(*args, **kwargs)
return redirect('/login?redirect=' + (redirect_page or request.url), 307) return redirect(
'/login?redirect=' + (redirect_page or request.url), 307
)
# CSRF token check # CSRF token check
if check_csrf_token: if check_csrf_token:
@ -217,15 +224,22 @@ def authenticate(redirect_page='', skip_auth_methods=None, check_csrf_token=Fals
return abort(403, 'Invalid or missing csrf_token') return abort(403, 'Invalid or missing csrf_token')
if n_users == 0 and 'session' not in skip_methods: if n_users == 0 and 'session' not in skip_methods:
return redirect('/register?redirect=' + (redirect_page or request.url), 307) return redirect(
'/register?redirect=' + (redirect_page or request.url), 307
)
if ('http' not in skip_methods and http_auth_ok) or \ if (
('token' not in skip_methods and token_auth_ok) or \ ('http' not in skip_methods and http_auth_ok)
('session' not in skip_methods and session_auth_ok): or ('token' not in skip_methods and token_auth_ok)
or ('session' not in skip_methods and session_auth_ok)
):
return f(*args, **kwargs) return f(*args, **kwargs)
return Response('Authentication required', 401, return Response(
{'WWW-Authenticate': 'Basic realm="Login required"'}) 'Authentication required',
401,
{'WWW-Authenticate': 'Basic realm="Login required"'},
)
return wrapper return wrapper
@ -233,42 +247,57 @@ def authenticate(redirect_page='', skip_auth_methods=None, check_csrf_token=Fals
def get_routes(): def get_routes():
routes_dir = os.path.join( routes_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'routes')
os.path.dirname(os.path.abspath(__file__)), 'routes')
routes = [] routes = []
base_module = '.'.join(__name__.split('.')[:-1]) base_module = '.'.join(__name__.split('.')[:-1])
for path, dirs, files in os.walk(routes_dir): for path, _, files in os.walk(routes_dir):
for f in files: for f in files:
if f.endswith('.py'): if f.endswith('.py'):
mod_name = '.'.join( mod_name = '.'.join(
(base_module + '.' + os.path.join(path, f).replace( (
os.path.dirname(__file__), '')[1:].replace(os.sep, '.')).split('.') base_module
[:(-2 if f == '__init__.py' else -1)]) + '.'
+ os.path.join(path, f)
.replace(os.path.dirname(__file__), '')[1:]
.replace(os.sep, '.')
).split('.')[: (-2 if f == '__init__.py' else -1)]
)
try: try:
mod = importlib.import_module(mod_name) mod = importlib.import_module(mod_name)
if hasattr(mod, '__routes__'): if hasattr(mod, '__routes__'):
routes.extend(mod.__routes__) routes.extend(mod.__routes__)
except Exception as e: except Exception as e:
logger().warning('Could not import routes from {}/{}: {}: {}'. logger().warning(
format(path, f, type(e), str(e))) 'Could not import routes from {}/{}: {}: {}'.format(
path, f, type(e), str(e)
)
)
return routes return routes
def get_local_base_url(): def get_local_base_url():
http_conf = Config.get('backend.http') or {} http_conf = Config.get('backend.http') or {}
return '{proto}://localhost:{port}'.format( bind_address = http_conf.get('bind_address')
if not bind_address or bind_address == '0.0.0.0':
bind_address = 'localhost'
return '{proto}://{host}:{port}'.format(
proto=('https' if http_conf.get('ssl_cert') else 'http'), proto=('https' if http_conf.get('ssl_cert') else 'http'),
port=get_http_port()) host=bind_address,
port=get_http_port(),
)
def get_remote_base_url(): def get_remote_base_url():
http_conf = Config.get('backend.http') or {} http_conf = Config.get('backend.http') or {}
return '{proto}://{host}:{port}'.format( return '{proto}://{host}:{port}'.format(
proto=('https' if http_conf.get('ssl_cert') else 'http'), proto=('https' if http_conf.get('ssl_cert') else 'http'),
host=get_ip_or_hostname(), port=get_http_port()) host=get_ip_or_hostname(),
port=get_http_port(),
)
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -3,8 +3,15 @@ import threading
from platypush.context import get_bus from platypush.context import get_bus
from platypush.plugins.media import PlayerState, MediaPlugin from platypush.plugins.media import PlayerState, MediaPlugin
from platypush.message.event.media import MediaPlayEvent, MediaPlayRequestEvent, \ from platypush.message.event.media import (
MediaPauseEvent, MediaStopEvent, NewPlayingMediaEvent, MediaSeekEvent, MediaResumeEvent MediaPlayEvent,
MediaPlayRequestEvent,
MediaPauseEvent,
MediaStopEvent,
NewPlayingMediaEvent,
MediaSeekEvent,
MediaResumeEvent,
)
from platypush.plugins import action from platypush.plugins import action
@ -66,29 +73,58 @@ class MediaMpvPlugin(MediaPlugin):
def _event_callback(self): def _event_callback(self):
def callback(event): def callback(event):
from mpv import MpvEventID as Event from mpv import (
from mpv import MpvEventEndFile as EndFile MpvEvent,
MpvEventID as Event,
MpvEventEndFile as EndFile,
)
self.logger.info('Received mpv event: {}'.format(event)) self.logger.info('Received mpv event: {}'.format(event))
if isinstance(event, MpvEvent):
event = event.as_dict()
evt = event.get('event_id') evt = event.get('event_id')
if not evt: if not evt:
return return
if (evt == Event.FILE_LOADED or evt == Event.START_FILE) and self._get_current_resource(): if (
evt == Event.FILE_LOADED or evt == Event.START_FILE
) and self._get_current_resource():
self._playback_rebounce_event.set() self._playback_rebounce_event.set()
self._post_event(NewPlayingMediaEvent, resource=self._get_current_resource(), self._post_event(
title=self._player.filename) NewPlayingMediaEvent,
resource=self._get_current_resource(),
title=self._player.filename,
)
elif evt == Event.PLAYBACK_RESTART: elif evt == Event.PLAYBACK_RESTART:
self._playback_rebounce_event.set() self._playback_rebounce_event.set()
self._post_event(MediaPlayEvent, resource=self._get_current_resource(), title=self._player.filename) self._post_event(
MediaPlayEvent,
resource=self._get_current_resource(),
title=self._player.filename,
)
elif evt == Event.PAUSE: elif evt == Event.PAUSE:
self._post_event(MediaPauseEvent, resource=self._get_current_resource(), title=self._player.filename) self._post_event(
MediaPauseEvent,
resource=self._get_current_resource(),
title=self._player.filename,
)
elif evt == Event.UNPAUSE: elif evt == Event.UNPAUSE:
self._post_event(MediaResumeEvent, resource=self._get_current_resource(), title=self._player.filename) self._post_event(
elif evt == Event.SHUTDOWN or evt == Event.IDLE or ( MediaResumeEvent,
evt == Event.END_FILE and event.get('event', {}).get('reason') in resource=self._get_current_resource(),
[EndFile.EOF, EndFile.ABORTED, EndFile.QUIT]): title=self._player.filename,
)
elif (
evt == Event.SHUTDOWN
or evt == Event.IDLE
or (
evt == Event.END_FILE
and event.get('event', {}).get('reason')
in [EndFile.EOF, EndFile.ABORTED, EndFile.QUIT]
)
):
playback_rebounced = self._playback_rebounce_event.wait(timeout=0.5) playback_rebounced = self._playback_rebounce_event.wait(timeout=0.5)
if playback_rebounced: if playback_rebounced:
self._playback_rebounce_event.clear() self._playback_rebounce_event.clear()
@ -211,8 +247,7 @@ class MediaMpvPlugin(MediaPlugin):
return None, 'No mpv instance is running' return None, 'No mpv instance is running'
if not self._player.seekable: if not self._player.seekable:
return None, 'The resource is not seekable' return None, 'The resource is not seekable'
pos = min(self._player.time_pos + self._player.time_remaining, pos = min(self._player.time_pos + self._player.time_remaining, max(0, position))
max(0, position))
self._player.time_pos = pos self._player.time_pos = pos
return self.status() return self.status()
@ -233,8 +268,10 @@ class MediaMpvPlugin(MediaPlugin):
return None, 'No mpv instance is running' return None, 'No mpv instance is running'
if not self._player.seekable: if not self._player.seekable:
return None, 'The resource is not seekable' return None, 'The resource is not seekable'
pos = min(self._player.time_pos + self._player.time_remaining, pos = min(
self._player.time_pos + offset) self._player.time_pos + self._player.time_remaining,
self._player.time_pos + offset,
)
return self.seek(pos) return self.seek(pos)
@action @action
@ -261,11 +298,6 @@ class MediaMpvPlugin(MediaPlugin):
"""Add a subtitles file""" """Add a subtitles file"""
return self._player.sub_add(filename) return self._player.sub_add(filename)
@action
def remove_subtitles(self, sub_id):
""" Remove a subtitles track by id """
return self._player.sub_remove(sub_id)
@action @action
def toggle_fullscreen(self): def toggle_fullscreen(self):
"""Toggle the fullscreen mode""" """Toggle the fullscreen mode"""
@ -324,10 +356,12 @@ class MediaMpvPlugin(MediaPlugin):
return self.set_property(subfile=filename, sub_visibility=True) return self.set_property(subfile=filename, sub_visibility=True)
@action @action
def remove_subtitles(self): def remove_subtitles(self, sub_id=None):
"""Removes (hides) the subtitles""" """Removes (hides) the subtitles"""
if not self._player: if not self._player:
return None, 'No mpv instance is running' return None, 'No mpv instance is running'
if sub_id:
return self._player.sub_remove(sub_id)
self._player.sub_visibility = False self._player.sub_visibility = False
@action @action
@ -382,28 +416,35 @@ class MediaMpvPlugin(MediaPlugin):
return {'state': PlayerState.STOP.value} return {'state': PlayerState.STOP.value}
return { return {
'audio_channels': getattr(self._player, 'audio_channels'), 'audio_channels': getattr(self._player, 'audio_channels', None),
'audio_codec': getattr(self._player, 'audio_codec_name'), 'audio_codec': getattr(self._player, 'audio_codec_name', None),
'delay': getattr(self._player, 'delay'), 'delay': getattr(self._player, 'delay', None),
'duration': getattr(self._player, 'playback_time', 0) + getattr(self._player, 'playtime_remaining', 0) 'duration': getattr(self._player, 'playback_time', 0)
if getattr(self._player, 'playtime_remaining') else None, + getattr(self._player, 'playtime_remaining', 0)
'filename': getattr(self._player, 'filename'), if getattr(self._player, 'playtime_remaining', None)
'file_size': getattr(self._player, 'file_size'), else None,
'fullscreen': getattr(self._player, 'fs'), 'filename': getattr(self._player, 'filename', None),
'mute': getattr(self._player, 'mute'), 'file_size': getattr(self._player, 'file_size', None),
'name': getattr(self._player, 'name'), 'fullscreen': getattr(self._player, 'fs', None),
'pause': getattr(self._player, 'pause'), 'mute': getattr(self._player, 'mute', None),
'percent_pos': getattr(self._player, 'percent_pos'), 'name': getattr(self._player, 'name', None),
'position': getattr(self._player, 'playback_time'), 'pause': getattr(self._player, 'pause', None),
'seekable': getattr(self._player, 'seekable'), 'percent_pos': getattr(self._player, 'percent_pos', None),
'state': (PlayerState.PAUSE.value if self._player.pause else PlayerState.PLAY.value), 'position': getattr(self._player, 'playback_time', None),
'title': getattr(self._player, 'media_title') or getattr(self._player, 'filename'), 'seekable': getattr(self._player, 'seekable', None),
'state': (
PlayerState.PAUSE.value
if self._player.pause
else PlayerState.PLAY.value
),
'title': getattr(self._player, 'media_title', None)
or getattr(self._player, 'filename', None),
'url': self._get_current_resource(), 'url': self._get_current_resource(),
'video_codec': getattr(self._player, 'video_codec'), 'video_codec': getattr(self._player, 'video_codec', None),
'video_format': getattr(self._player, 'video_format'), 'video_format': getattr(self._player, 'video_format', None),
'volume': getattr(self._player, 'volume'), 'volume': getattr(self._player, 'volume', None),
'volume_max': getattr(self._player, 'volume_max'), 'volume_max': getattr(self._player, 'volume_max', None),
'width': getattr(self._player, 'width'), 'width': getattr(self._player, 'width', None),
} }
def on_stop(self, callback): def on_stop(self, callback):
@ -413,8 +454,9 @@ class MediaMpvPlugin(MediaPlugin):
if not self._player or not self._player.stream_path: if not self._player or not self._player.stream_path:
return return
return ('file://' if os.path.isfile(self._player.stream_path) return (
else '') + self._player.stream_path 'file://' if os.path.isfile(self._player.stream_path) else ''
) + self._player.stream_path
def _get_resource(self, resource): def _get_resource(self, resource):
if self._is_youtube_resource(resource): if self._is_youtube_resource(resource):

View file

@ -21,44 +21,68 @@ class MqttPlugin(Plugin):
""" """
def __init__(self, host=None, port=1883, tls_cafile=None, def __init__(
tls_certfile=None, tls_keyfile=None, self,
tls_version=None, tls_ciphers=None, tls_insecure=False, host=None,
username=None, password=None, client_id=None, timeout=None, **kwargs): port=1883,
tls_cafile=None,
tls_certfile=None,
tls_keyfile=None,
tls_version=None,
tls_ciphers=None,
tls_insecure=False,
username=None,
password=None,
client_id=None,
timeout=None,
**kwargs,
):
""" """
:param host: If set, MQTT messages will by default routed to this host unless overridden in `send_message` (default: None) :param host: If set, MQTT messages will by default routed to this host
unless overridden in `send_message` (default: None)
:type host: str :type host: str
:param port: If a default host is set, specify the listen port (default: 1883) :param port: If a default host is set, specify the listen port
(default: 1883)
:type port: int :type port: int
:param tls_cafile: If a default host is set and requires TLS/SSL, specify the certificate authority file (default: None) :param tls_cafile: If a default host is set and requires TLS/SSL,
specify the certificate authority file (default: None)
:type tls_cafile: str :type tls_cafile: str
:param tls_certfile: If a default host is set and requires TLS/SSL, specify the certificate file (default: None) :param tls_certfile: If a default host is set and requires TLS/SSL,
specify the certificate file (default: None)
:type tls_certfile: str :type tls_certfile: str
:param tls_keyfile: If a default host is set and requires TLS/SSL, specify the key file (default: None) :param tls_keyfile: If a default host is set and requires TLS/SSL,
specify the key file (default: None)
:type tls_keyfile: str :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 :param tls_version: If TLS/SSL is enabled on the MQTT server and it
here (default: None). Supported versions: ``tls`` (automatic), ``tlsv1``, ``tlsv1.1``, ``tlsv1.2``. requires a certain TLS version, specify it here (default: None).
Supported versions: ``tls`` (automatic), ``tlsv1``, ``tlsv1.1``,
``tlsv1.2``.
:type tls_version: str :type tls_version: str
:param tls_ciphers: If a default host is set and requires TLS/SSL, specify the supported ciphers (default: None) :param tls_ciphers: If a default host is set and requires TLS/SSL,
specify the supported ciphers (default: None)
:type tls_ciphers: str :type tls_ciphers: str
:param tls_insecure: Set to True to ignore TLS insecure warnings (default: False). :param tls_insecure: Set to True to ignore TLS insecure warnings
(default: False).
:type tls_insecure: bool :type tls_insecure: bool
:param username: If a default host is set and requires user authentication, specify the username ciphers (default: None) :param username: If a default host is set and requires user
authentication, specify the username ciphers (default: None)
:type username: str :type username: str
:param password: If a default host is set and requires user authentication, specify the password ciphers (default: None) :param password: If a default host is set and requires user
authentication, specify the password ciphers (default: None)
:type password: str :type password: str
:param client_id: ID used to identify the client on the MQTT server (default: None). :param client_id: ID used to identify the client on the MQTT server
If None is specified then ``Config.get('device_id')`` will be used. (default: None). If None is specified then
``Config.get('device_id')`` will be used.
:type client_id: str :type client_id: str
:param timeout: Client timeout in seconds (default: None). :param timeout: Client timeout in seconds (default: None).
@ -83,10 +107,11 @@ class MqttPlugin(Plugin):
@staticmethod @staticmethod
def get_tls_version(version: Optional[str] = None): def get_tls_version(version: Optional[str] = None):
import ssl import ssl
if not version: if not version:
return None return None
if type(version) == type(ssl.PROTOCOL_TLS): if isinstance(version, type(ssl.PROTOCOL_TLS)):
return version return version
if isinstance(version, str): if isinstance(version, str):
@ -120,10 +145,17 @@ class MqttPlugin(Plugin):
def _expandpath(path: Optional[str] = None) -> Optional[str]: def _expandpath(path: Optional[str] = None) -> Optional[str]:
return os.path.abspath(os.path.expanduser(path)) if path else None return os.path.abspath(os.path.expanduser(path)) if path else None
def _get_client(self, tls_cafile: Optional[str] = None, tls_certfile: Optional[str] = None, def _get_client(
tls_keyfile: Optional[str] = None, tls_version: Optional[str] = None, self,
tls_ciphers: Optional[str] = None, tls_insecure: Optional[bool] = None, tls_cafile: Optional[str] = None,
username: Optional[str] = None, password: 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: Optional[bool] = None,
username: Optional[str] = None,
password: Optional[str] = None,
):
from paho.mqtt.client import Client from paho.mqtt.client import Client
tls_cafile = self._expandpath(tls_cafile or self.tls_cafile) tls_cafile = self._expandpath(tls_cafile or self.tls_cafile)
@ -144,43 +176,77 @@ class MqttPlugin(Plugin):
if username and password: if username and password:
client.username_pw_set(username, password) client.username_pw_set(username, password)
if tls_cafile: if tls_cafile:
client.tls_set(ca_certs=tls_cafile, certfile=tls_certfile, keyfile=tls_keyfile, client.tls_set(
tls_version=tls_version, ciphers=tls_ciphers) ca_certs=tls_cafile,
certfile=tls_certfile,
keyfile=tls_keyfile,
tls_version=tls_version,
ciphers=tls_ciphers,
)
client.tls_insecure_set(tls_insecure) client.tls_insecure_set(tls_insecure)
return client return client
@action @action
def publish(self, topic: str, msg: Any, host: Optional[str] = None, port: Optional[int] = None, def publish(
reply_topic: Optional[str] = None, timeout: int = 60, self,
tls_cafile: Optional[str] = None, tls_certfile: Optional[str] = None, topic: str,
tls_keyfile: Optional[str] = None, tls_version: Optional[str] = None, msg: Any,
tls_ciphers: Optional[str] = None, tls_insecure: Optional[bool] = None, host: Optional[str] = None,
username: Optional[str] = None, password: Optional[str] = None): port: Optional[int] = None,
reply_topic: Optional[str] = None,
timeout: int = 60,
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: Optional[bool] = None,
username: Optional[str] = None,
password: Optional[str] = None,
qos: int = 0,
):
""" """
Sends a message to a topic. Sends a message to a topic.
:param topic: Topic/channel where the message will be delivered :param topic: Topic/channel where the message will be delivered
:param msg: Message to be sent. It can be a list, a dict, or a Message object. :param msg: Message to be sent. It can be a list, a dict, or a Message
:param host: MQTT broker hostname/IP (default: default host configured on the plugin). object.
:param port: MQTT broker port (default: default port configured on the plugin). :param host: MQTT broker hostname/IP (default: default host configured
:param reply_topic: If a ``reply_topic`` is specified, then the action will wait for a response on this topic. on the plugin).
:param timeout: If ``reply_topic`` is set, use this parameter to specify the maximum amount of time to :param port: MQTT broker port (default: default port configured on the
wait for a response (default: 60 seconds). plugin).
:param tls_cafile: If TLS/SSL is enabled on the MQTT server and the certificate requires a certificate authority :param reply_topic: If a ``reply_topic`` is specified, then the action
to authenticate it, `ssl_cafile` will point to the provided ca.crt file (default: None). will wait for a response on this topic.
:param tls_certfile: If TLS/SSL is enabled on the MQTT server and a client certificate it required, specify it :param timeout: If ``reply_topic`` is set, use this parameter to
here (default: None). specify the maximum amount of time to wait for a response (default:
:param tls_keyfile: If TLS/SSL is enabled on the MQTT server and a client certificate key it required, specify 60 seconds).
it here (default: None). :param tls_cafile: If TLS/SSL is enabled on the MQTT server and the
:param tls_version: If TLS/SSL is enabled on the MQTT server and it requires a certain TLS version, specify it certificate requires a certificate authority to authenticate it,
here (default: None). Supported versions: ``tls`` (automatic), ``tlsv1``, ``tlsv1.1``, ``tlsv1.2``. `ssl_cafile` will point to the provided ca.crt file (default:
:param tls_insecure: Set to True to ignore TLS insecure warnings (default: False). None).
:param tls_ciphers: If TLS/SSL is enabled on the MQTT server and an explicit list of supported ciphers is :param tls_certfile: If TLS/SSL is enabled on the MQTT server and a
required, specify it here (default: None). client certificate it required, specify it here (default: None).
:param username: Specify it if the MQTT server requires authentication (default: None). :param tls_keyfile: If TLS/SSL is enabled on the MQTT server and a
:param password: Specify it if the MQTT server requires authentication (default: None). client certificate key it required, specify it here (default:
None).
: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_insecure: Set to True to ignore TLS insecure warnings
(default: False).
: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 username: Specify it if the MQTT server requires authentication
(default: None).
:param password: Specify it if the MQTT server requires authentication
(default: None).
:param qos: Quality of Service (_QoS_) for the message - see `MQTT QoS
<https://assetwolf.com/learn/mqtt-qos-understanding-quality-of-service>`_
(default: 0).
""" """
response_buffer = io.BytesIO() response_buffer = io.BytesIO()
client = None client = None
@ -199,20 +265,29 @@ class MqttPlugin(Plugin):
port = port or self.port or 1883 port = port or self.port or 1883
assert host, 'No host specified' assert host, 'No host specified'
client = self._get_client(tls_cafile=tls_cafile, tls_certfile=tls_certfile, tls_keyfile=tls_keyfile, client = self._get_client(
tls_version=tls_version, tls_ciphers=tls_ciphers, tls_insecure=tls_insecure, tls_cafile=tls_cafile,
username=username, password=password) tls_certfile=tls_certfile,
tls_keyfile=tls_keyfile,
tls_version=tls_version,
tls_ciphers=tls_ciphers,
tls_insecure=tls_insecure,
username=username,
password=password,
)
client.connect(host, port, keepalive=timeout) client.connect(host, port, keepalive=timeout)
response_received = threading.Event() response_received = threading.Event()
if reply_topic: if reply_topic:
client.on_message = self._response_callback(reply_topic=reply_topic, client.on_message = self._response_callback(
reply_topic=reply_topic,
event=response_received, event=response_received,
buffer=response_buffer) buffer=response_buffer,
)
client.subscribe(reply_topic) client.subscribe(reply_topic)
client.publish(topic, str(msg)) client.publish(topic, str(msg), qos=qos)
if not reply_topic: if not reply_topic:
return return
@ -241,6 +316,7 @@ class MqttPlugin(Plugin):
buffer.write(msg.payload) buffer.write(msg.payload)
client.loop_stop() client.loop_stop()
event.set() event.set()
return on_message return on_message
@action @action

View file

@ -0,0 +1,119 @@
import requests
from typing import Optional
from urllib.parse import urljoin, urlencode
from platypush.backend.http.app.utils import get_local_base_url
from platypush.context import get_backend
from platypush.plugins import action
from platypush.plugins.tts import TtsPlugin
from platypush.schemas.tts.mimic3 import Mimic3VoiceSchema
class TtsMimic3Plugin(TtsPlugin):
"""
TTS plugin that uses the `Mimic3 webserver
<https://github.com/MycroftAI/mimic3>`_ provided by `Mycroft
<https://mycroft.ai/>`_ as a text-to-speech engine.
The easiest way to deploy a Mimic3 instance is probably via Docker:
.. code-block:: bash
$ mkdir -p "$HOME/.local/share/mycroft/mimic3"
$ chmod a+rwx "$HOME/.local/share/mycroft/mimic3"
$ docker run --rm \
-p 59125:59125 \
-v "%h/.local/share/mycroft/mimic3:/home/mimic3/.local/share/mycroft/mimic3" \
'mycroftai/mimic3'
Requires:
* At least a *media plugin* (see
:class:`platypush.plugins.media.MediaPlugin`) enabled/configured -
used for speech playback.
* The ``http`` backend (:class:`platypush.backend.http.HttpBackend`)
enabled - used for proxying the API calls.
"""
def __init__(
self,
server_url: str,
voice: str = 'en_UK/apope_low',
media_plugin: Optional[str] = None,
player_args: Optional[dict] = None,
**kwargs
):
"""
:param server_url: Base URL of the web server that runs the Mimic3 engine.
:param voice: Default voice to be used (default: ``en_UK/apope_low``).
You can get a full list of the voices available on the server
through :meth:`.voices`.
:param media_plugin: Media plugin to be used for audio playback. Supported:
- ``media.gstreamer``
- ``media.omxplayer``
- ``media.mplayer``
- ``media.mpv``
- ``media.vlc``
:param player_args: Optional arguments that should be passed to the player plugin's
:meth:`platypush.plugins.media.MediaPlugin.play` method.
"""
super().__init__(media_plugin=media_plugin, player_args=player_args, **kwargs)
self.server_url = server_url
self.voice = voice
@action
def say(
self,
text: str,
server_url: Optional[str] = None,
voice: Optional[str] = None,
player_args: Optional[dict] = None,
):
"""
Say some text.
:param text: Text to say.
:param server_url: Default ``server_url`` override.
:param voice: Default ``voice`` override.
:param player_args: Default ``player_args`` override.
"""
server_url = server_url or self.server_url
voice = voice or self.voice
player_args = player_args or self.player_args
http = get_backend('http')
assert http, 'http backend not configured'
assert self.media_plugin, 'No media plugin configured'
url = (
urljoin(get_local_base_url(), '/tts/mimic3/say')
+ '?'
+ urlencode(
{
'text': text,
'server_url': server_url,
'voice': voice,
}
)
)
self.media_plugin.play(url, **player_args)
@action
def voices(self, server_url: Optional[str] = None):
"""
List the voices available on the server.
:param server_url: Default ``server_url`` override.
:return: .. schema:: tts.mimic3.Mimic3VoiceSchema(many=True)
"""
server_url = server_url or self.server_url
rs = requests.get(urljoin(server_url, '/api/voices'))
rs.raise_for_status()
return Mimic3VoiceSchema().dump(rs.json(), many=True)
# vim:sw=4:ts=4:et:

View file

@ -0,0 +1,6 @@
manifest:
events: {}
install:
pip: []
package: platypush.plugins.tts.mimic3
type: plugin

View file

@ -0,0 +1,405 @@
import json
import os
import pathlib
import requests
import time
from datetime import datetime, timedelta
from typing import Iterable, List, Optional
from urllib.parse import urljoin
from platypush.config import Config
from platypush.plugins import Plugin, action
from platypush.schemas.wallabag import WallabagEntrySchema
class WallabagPlugin(Plugin):
"""
Plugin to interact with Wallabag (https://wallabag.it),
an open-source alternative to Instapaper and Pocket.
"""
_default_credentials_file = os.path.join(
str(Config.get('workdir')), 'wallabag', 'credentials.json'
)
def __init__(
self,
client_id: str,
client_secret: str,
server_url: str = 'https://wallabag.it',
username: Optional[str] = None,
password: Optional[str] = None,
credentials_file: str = _default_credentials_file,
**kwargs,
):
"""
:param client_id: Client ID for your application - you can create one
at ``<server_url>/developer``.
:param client_secret: Client secret for your application - you can
create one at ``<server_url>/developer``.
:param server_url: Base URL of the Wallabag server (default: ``https://wallabag.it``).
:param username: Wallabag username. Only needed for the first login,
you can remove it afterwards. Alternatively, you can provide it
on the :meth:`.login` method.
:param password: Wallabag password. Only needed for the first login,
you can remove it afterwards. Alternatively, you can provide it
on the :meth:`.login` method.
:param credentials_file: Path to the file where the OAuth session
parameters will be stored (default:
``<WORKDIR>/wallabag/credentials.json``).
"""
super().__init__(**kwargs)
self._client_id = client_id
self._client_secret = client_secret
self._server_url = server_url
self._username = username
self._password = password
self._credentials_file = os.path.expanduser(credentials_file)
self._session = {}
def _oauth_open_saved_session(self):
try:
with open(self._credentials_file, 'r') as f:
data = json.load(f)
except Exception as e:
self.logger.warning('Could not load %s: %s', self._credentials_file, e)
return
self._session = {
'username': data['username'],
'client_id': data.get('client_id', self._client_id),
'client_secret': data.get('client_secret', self._client_secret),
'access_token': data['access_token'],
'refresh_token': data['refresh_token'],
}
if data.get('expires_at') and time.time() > data['expires_at']:
self.logger.info('OAuth token expired, refreshing it')
self._oauth_refresh_token()
def _oauth_refresh_token(self):
url = urljoin(self._server_url, '/oauth/v2/token')
rs = requests.post(
url,
json={
'grant_type': 'refresh_token',
'client_id': self._client_id,
'client_secret': self._client_secret,
'access_token': self._session['access_token'],
'refresh_token': self._session['refresh_token'],
},
)
rs.raise_for_status()
rs = rs.json()
self._session.update(
{
'access_token': rs['access_token'],
'refresh_token': rs['refresh_token'],
'expires_at': (
int(
(
datetime.now() + timedelta(seconds=rs['expires_in'])
).timestamp()
)
if rs.get('expires_in')
else None
),
}
)
self._oauth_flush_session()
def _oauth_create_new_session(self, username: str, password: str):
url = urljoin(self._server_url, '/oauth/v2/token')
rs = requests.post(
url,
json={
'grant_type': 'password',
'client_id': self._client_id,
'client_secret': self._client_secret,
'username': username,
'password': password,
},
)
rs.raise_for_status()
rs = rs.json()
self._session = {
'client_id': self._client_id,
'client_secret': self._client_secret,
'username': username,
'access_token': rs['access_token'],
'refresh_token': rs['refresh_token'],
'expires_at': (
int((datetime.now() + timedelta(seconds=rs['expires_in'])).timestamp())
if rs.get('expires_in')
else None
),
}
self._oauth_flush_session()
def _oauth_flush_session(self):
pathlib.Path(self._credentials_file).parent.mkdir(parents=True, exist_ok=True)
pathlib.Path(self._credentials_file).touch(mode=0o600, exist_ok=True)
with open(self._credentials_file, 'w') as f:
f.write(json.dumps(self._session))
@action
def login(self, username: Optional[str] = None, password: Optional[str] = None):
"""
Create a new user session if not logged in.
:param username: Default ``username`` override.
:param password: Default ``password`` override.
"""
self._oauth_open_saved_session()
if self._session:
return
username = username or self._username
password = password or self._password
assert (
username and password
), 'No stored user session and no username/password provided'
self._oauth_create_new_session(username, password)
def _request(self, url: str, method: str, *args, as_json=True, **kwargs):
url = urljoin(self._server_url, f'api/{url}')
func = getattr(requests, method.lower())
self.login()
kwargs['headers'] = {
**kwargs.get('headers', {}),
'Authorization': f'Bearer {self._session["access_token"]}',
}
rs = func(url, *args, **kwargs)
rs.raise_for_status()
return rs.json() if as_json else rs.text
@action
def list(
self,
archived: bool = True,
starred: bool = False,
sort: str = 'created',
descending: bool = False,
page: int = 1,
limit: int = 30,
tags: Optional[Iterable[str]] = None,
since: Optional[int] = None,
full: bool = True,
) -> List[dict]:
"""
List saved links.
:param archived: Include archived items (default: ``True``).
:param starred: Include only starred items (default: ``False``).
:param sort: Timestamp sort criteria. Supported: ``created``,
``updated``, ``archived`` (default: ``created``).
:param descending: Sort in descending order (default: ``False``).
:param page: Results page to be retrieved (default: ``1``).
:param limit: Maximum number of entries per page (default: ``30``).
:param tags: Filter by a list of tags.
:param since: Return entries created after this timestamp (as a UNIX
timestamp).
:param full: Include the full parsed body of the saved entry.
:return: .. schema:: wallabag.WallabagEntrySchema(many=True)
"""
rs = self._request(
'/entries.json',
method='get',
params={
'archived': int(archived),
'starred': int(starred),
'sort': sort,
'order': 'desc' if descending else 'asc',
'page': page,
'perPage': limit,
'tags': ','.join(tags or []),
'since': since or 0,
'detail': 'full' if full else 'metadata',
},
)
return WallabagEntrySchema().dump(
rs.get('_embedded', {}).get('items', []), many=True
)
@action
def search(
self,
term: str,
page: int = 1,
limit: int = 30,
) -> List[dict]:
"""
Search links by some text.
:param term: Term to be searched.
:param page: Results page to be retrieved (default: ``1``).
:param limit: Maximum number of entries per page (default: ``30``).
:return: .. schema:: wallabag.WallabagEntrySchema(many=True)
"""
rs = self._request(
'/search.json',
method='get',
params={
'term': term,
'page': page,
'perPage': limit,
},
)
return WallabagEntrySchema().dump(
rs.get('_embedded', {}).get('items', []), many=True
)
@action
def get(self, id: int) -> Optional[dict]:
"""
Get the content and metadata of a link by ID.
:param id: Entry ID.
:return: .. schema:: wallabag.WallabagEntrySchema
"""
rs = self._request(f'/entries/{id}.json', method='get')
return WallabagEntrySchema().dump(rs) # type: ignore
@action
def export(self, id: int, file: str, format: str = 'txt'):
"""
Export a saved entry to a file in the specified format.
:param id: Entry ID.
:param file: Output filename.
:param format: Output format. Supported: ``txt``, ``xml``, ``csv``,
``pdf``, ``epub`` and ``mobi`` (default: ``txt``).
"""
rs = self._request(
f'/entries/{id}/export.{format}', method='get', as_json=False
)
if isinstance(rs, str):
rs = rs.encode()
with open(os.path.expanduser(file), 'wb') as f:
f.write(rs)
@action
def save(
self,
url: str,
title: Optional[str] = None,
content: Optional[str] = None,
tags: Optional[Iterable[str]] = None,
authors: Optional[Iterable[str]] = None,
archived: bool = False,
starred: bool = False,
public: bool = False,
language: Optional[str] = None,
preview_picture: Optional[str] = None,
) -> Optional[dict]:
"""
Save a link to Wallabag.
:param url: URL to be saved.
:param title: Entry title (default: parsed from the page content).
:param content: Entry content (default: parsed from the entry itself).
:param tags: List of tags to attach to the entry.
:param authors: List of authors of the entry (default: parsed from the
page content).
:param archived: Whether the entry should be created in the archive
(default: ``False``).
:param starred: Whether the entry should be starred (default:
``False``).
:param public: Whether the entry should be publicly available. If so, a
public URL will be generated (default: ``False``).
:param language: Language of the entry.
:param preview_picture: URL of a picture to be used for the preview
(default: parsed from the page itself).
:return: .. schema:: wallabag.WallabagEntrySchema
"""
rs = self._request(
'/entries.json',
method='post',
json={
'url': url,
'title': title,
'content': content,
'tags': ','.join(tags or []),
'authors': ','.join(authors or []),
'archive': int(archived),
'starred': int(starred),
'public': int(public),
'language': language,
'preview_picture': preview_picture,
},
)
return WallabagEntrySchema().dump(rs) # type: ignore
@action
def update(
self,
id: int,
title: Optional[str] = None,
content: Optional[str] = None,
tags: Optional[Iterable[str]] = None,
authors: Optional[Iterable[str]] = None,
archived: bool = False,
starred: bool = False,
public: bool = False,
language: Optional[str] = None,
preview_picture: Optional[str] = None,
) -> Optional[dict]:
"""
Update a link entry saved to Wallabag.
:param id: Entry ID.
:param title: New entry title.
:param content: New entry content.
:param tags: List of tags to attach to the entry.
:param authors: List of authors of the entry.
:param archived: Archive/unarchive the entry.
:param starred: Star/unstar the entry.
:param public: Mark the entry as public/private.
:param language: Change the language of the entry.
:param preview_picture: Change the preview picture URL.
:return: .. schema:: wallabag.WallabagEntrySchema
"""
rs = self._request(
f'/entries/{id}.json',
method='patch',
json={
'title': title,
'content': content,
'tags': ','.join(tags or []),
'authors': ','.join(authors or []),
'archive': int(archived),
'starred': int(starred),
'public': int(public),
'language': language,
'preview_picture': preview_picture,
},
)
return WallabagEntrySchema().dump(rs) # type: ignore
@action
def delete(self, id: int) -> Optional[dict]:
"""
Delete an entry by ID.
:param id: Entry ID.
:return: .. schema:: wallabag.WallabagEntrySchema
"""
rs = self._request(
f'/entries/{id}.json',
method='delete',
)
return WallabagEntrySchema().dump(rs) # type: ignore

View file

@ -0,0 +1,3 @@
manifest:
package: platypush.plugins.wallabag
type: plugin

View file

View file

@ -0,0 +1,51 @@
from marshmallow import Schema, fields
class Mimic3Schema(Schema):
pass
class Mimic3VoiceSchema(Mimic3Schema):
key = fields.String(
required=True,
dump_only=True,
metadata={
'description': 'Unique voice ID',
'example': 'en_UK/apope_low',
},
)
language = fields.String(
required=True,
dump_only=True,
metadata={
'example': 'en_UK',
},
)
language_english = fields.String(
metadata={
'description': 'Name of the language (in English)',
}
)
language_native = fields.String(
metadata={
'description': 'Name of the language (in the native language)',
}
)
name = fields.String(
metadata={
'example': 'apope_low',
}
)
sample_text = fields.String(
metadata={
'example': 'Some text',
}
)
description = fields.String()
aliases = fields.List(fields.String)

View file

@ -0,0 +1,147 @@
from marshmallow import Schema, fields
from platypush.schemas import DateTime
class WallabagSchema(Schema):
pass
class WallabagAnnotationSchema(WallabagSchema):
id = fields.Integer(
required=True,
dump_only=True,
metadata={'example': 2345},
)
text = fields.String(
attribute='quote',
metadata={
'example': 'Some memorable quote',
},
)
comment = fields.String(
attribute='text',
metadata={
'example': 'My comment on this memorable quote',
},
)
ranges = fields.Function(
lambda data: [
[int(r['startOffset']), int(r['endOffset'])] for r in data.get('ranges', [])
],
metadata={
'example': [[100, 180]],
},
)
created_at = DateTime(
metadata={
'description': 'When the annotation was created',
},
)
updated_at = DateTime(
metadata={
'description': 'When the annotation was last updated',
},
)
class WallabagEntrySchema(WallabagSchema):
id = fields.Integer(
required=True,
dump_only=True,
metadata={'example': 1234},
)
url = fields.URL(
required=True,
metadata={
'description': 'Original URL',
'example': 'https://example.com/article/some-title',
},
)
preview_picture = fields.URL(
metadata={
'description': 'Preview picture URL',
'example': 'https://example.com/article/some-title.jpg',
},
)
is_archived = fields.Boolean()
is_starred = fields.Boolean()
is_public = fields.Boolean()
mimetype = fields.String(
metadata={
'example': 'text/html',
},
)
title = fields.String(
metadata={
'description': 'Title of the saved page',
},
)
content = fields.String(
metadata={
'description': 'Parsed content',
}
)
language = fields.String(
metadata={
'example': 'en',
}
)
annotations = fields.List(fields.Nested(WallabagAnnotationSchema))
published_by = fields.List(
fields.String,
metadata={
'example': ['Author 1', 'Author 2'],
},
)
tags = fields.Function(
lambda data: [tag['label'] for tag in data.get('tags', [])],
metadata={
'example': ['tech', 'programming'],
},
)
reading_time = fields.Integer(
metadata={
'description': 'Estimated reading time, in minutes',
'example': 10,
}
)
created_at = DateTime(
metadata={
'description': 'When the entry was created',
},
)
updated_at = DateTime(
metadata={
'description': 'When the entry was last updated',
},
)
starred_at = DateTime(
metadata={
'description': 'If the entry is starred, when was it last marked',
},
)
published_at = DateTime(
metadata={
'description': 'When the entry was initially published',
},
)