From f55032a2ec7b62b4aca9e2c54011aad1b9f73721 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sat, 19 Aug 2023 17:14:44 -0400 Subject: [PATCH] Added support for serialization via pickle library (#929) --- KEYWORDS | 1 + README.md | 1 + apprise/Apprise.py | 30 ++++ apprise/AppriseLocale.py | 16 +++ apprise/plugins/NotifyPushMe.py | 206 +++++++++++++++++++++++++++ packaging/redhat/python-apprise.spec | 9 +- test/test_apprise_pickle.py | 108 ++++++++++++++ test/test_apprise_utils.py | 2 +- test/test_plugin_pushme.py | 99 +++++++++++++ 9 files changed, 467 insertions(+), 5 deletions(-) create mode 100644 apprise/plugins/NotifyPushMe.py create mode 100644 test/test_apprise_pickle.py create mode 100644 test/test_plugin_pushme.py diff --git a/KEYWORDS b/KEYWORDS index 9a726c734d..d2dfb22604 100644 --- a/KEYWORDS +++ b/KEYWORDS @@ -60,6 +60,7 @@ Prowl PushBullet Pushed Pushjet +PushMe Push Notifications Pushover PushSafer diff --git a/README.md b/README.md index 4af9a7dd77..a57b3074a4 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ The table below identifies the services this tool supports and some example serv | [Pushjet](https://github.com/caronc/apprise/wiki/Notify_pushjet) | pjet:// or pjets:// | (TCP) 80 or 443 | pjet://hostname/secret
pjet://hostname:port/secret
pjets://secret@hostname/secret
pjets://hostname:port/secret | [Push (Techulus)](https://github.com/caronc/apprise/wiki/Notify_techulus) | push:// | (TCP) 443 | push://apikey/ | [Pushed](https://github.com/caronc/apprise/wiki/Notify_pushed) | pushed:// | (TCP) 443 | pushed://appkey/appsecret/
pushed://appkey/appsecret/#ChannelAlias
pushed://appkey/appsecret/#ChannelAlias1/#ChannelAlias2/#ChannelAliasN
pushed://appkey/appsecret/@UserPushedID
pushed://appkey/appsecret/@UserPushedID1/@UserPushedID2/@UserPushedIDN +| [PushMe](https://github.com/caronc/apprise/wiki/Notify_pushme) | pushme:// | (TCP) 443 | pushme://Token/ | [Pushover](https://github.com/caronc/apprise/wiki/Notify_pushover) | pover:// | (TCP) 443 | pover://user@token
pover://user@token/DEVICE
pover://user@token/DEVICE1/DEVICE2/DEVICEN
**Note**: you must specify both your user_id and token | [PushSafer](https://github.com/caronc/apprise/wiki/Notify_pushsafer) | psafer:// or psafers:// | (TCP) 80 or 443 | psafer://privatekey
psafers://privatekey/DEVICE
psafer://privatekey/DEVICE1/DEVICE2/DEVICEN | [Pushy](https://github.com/caronc/apprise/wiki/Notify_pushy) | pushy:// | (TCP) 443 | pushy://apikey/DEVICE
pushy://apikey/DEVICE1/DEVICE2/DEVICEN
pushy://apikey/TOPIC
pushy://apikey/TOPIC1/TOPIC2/TOPICN diff --git a/apprise/Apprise.py b/apprise/Apprise.py index 67f44b44ca..aa681d93f7 100644 --- a/apprise/Apprise.py +++ b/apprise/Apprise.py @@ -819,6 +819,36 @@ def __getitem__(self, index): # If we reach here, then we indexed out of range raise IndexError('list index out of range') + def __getstate__(self): + """ + Pickle Support dumps() + """ + attributes = { + 'asset': self.asset, + # Prepare our URL list as we need to extract the associated tags + # and asset details associated with it + 'urls': [{ + 'url': server.url(privacy=False), + 'tag': server.tags if server.tags else None, + 'asset': server.asset} for server in self.servers], + 'locale': self.locale, + 'debug': self.debug, + 'location': self.location, + } + + return attributes + + def __setstate__(self, state): + """ + Pickle Support loads() + """ + self.servers = list() + self.asset = state['asset'] + self.locale = state['locale'] + self.location = state['location'] + for entry in state['urls']: + self.add(entry['url'], asset=entry['asset'], tag=entry['tag']) + def __bool__(self): """ Allows the Apprise object to be wrapped in an 'if statement'. diff --git a/apprise/AppriseLocale.py b/apprise/AppriseLocale.py index ce61d0c9b3..bf424af8c7 100644 --- a/apprise/AppriseLocale.py +++ b/apprise/AppriseLocale.py @@ -223,3 +223,19 @@ def detect_language(lang=None, detect_fallback=True): return None return None if not lang else lang[0:2].lower() + + def __getstate__(self): + """ + Pickle Support dumps() + """ + state = self.__dict__.copy() + # Remove the unpicklable entries. + del state['_gtobjs'] + return state + + def __setstate__(self, state): + """ + Pickle Support loads() + """ + self.__dict__.update(state) + self._gtobjs = {} diff --git a/apprise/plugins/NotifyPushMe.py b/apprise/plugins/NotifyPushMe.py new file mode 100644 index 0000000000..d5e5ed46f1 --- /dev/null +++ b/apprise/plugins/NotifyPushMe.py @@ -0,0 +1,206 @@ +# -*- coding: utf-8 -*- +# BSD 3-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +import requests + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..common import NotifyFormat +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +class NotifyPushMe(NotifyBase): + """ + A wrapper for PushMe Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'PushMe' + + # The services URL + service_url = 'https://push.i-i.me/' + + # Insecure protocol (for those self hosted requests) + protocol = 'pushme' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_pushme' + + # PushMe URL + notify_url = 'https://push.i-i.me/' + + # Define object templates + templates = ( + '{schema}://{token}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'token': { + 'name': _('Token'), + 'type': 'string', + 'private': True, + 'required': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'token': { + 'alias_of': 'token', + }, + 'push_key': { + 'alias_of': 'token', + }, + }) + + def __init__(self, token, **kwargs): + """ + Initialize PushMe Object + """ + super().__init__(**kwargs) + + # Token (associated with project) + self.token = validate_regex(token) + if not self.token: + msg = 'An invalid PushMe Token ' \ + '({}) was specified.'.format(token) + self.logger.warning(msg) + raise TypeError(msg) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform PushMe Notification + """ + + headers = { + 'User-Agent': self.app_id, + } + + # Prepare our payload + params = { + 'push_key': self.token, + 'title': title, + 'content': body, + 'type': 'markdown' + if self.notify_format == NotifyFormat.MARKDOWN else 'text' + } + + self.logger.debug('PushMe POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('PushMe Payload: %s' % str(params)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + self.notify_url, + params=params, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyPushMe.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send PushMe notification:' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return False + + else: + self.logger.info('Sent PushMe notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending PushMe notification.', + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Our URL parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + # Official URLs are easy to assemble + return '{schema}://{token}/?{params}'.format( + schema=self.protocol, + token=self.pprint(self.token, privacy, safe=''), + params=NotifyPushMe.urlencode(params), + ) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + results = NotifyBase.parse_url(url, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + # Store our token using the host + results['token'] = NotifyPushMe.unquote(results['host']) + + # The 'token' makes it easier to use yaml configuration + if 'token' in results['qsd'] and len(results['qsd']['token']): + results['token'] = NotifyPushMe.unquote(results['qsd']['token']) + + elif 'push_key' in results['qsd'] and len(results['qsd']['push_key']): + # Support 'push_key' if specified + results['token'] = NotifyPushMe.unquote(results['qsd']['push_key']) + + return results diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec index 27a69f432c..9734ccee50 100644 --- a/packaging/redhat/python-apprise.spec +++ b/packaging/redhat/python-apprise.spec @@ -50,10 +50,11 @@ LaMetric, Line, MacOSX, Mailgun, Mastodon, Mattermost, Matrix, MessageBird, Microsoft Windows, Microsoft Teams, Misskey, MQTT, MSG91, MyAndroid, Nexmo, Nextcloud, NextcloudTalk, Notica, Notifico, ntfy, Office365, OneSignal, Opsgenie, PagerDuty, PagerTree, ParsePlatform, PopcornNotify, Prowl, Pushalot, -PushBullet, Pushjet, Pushover, PushSafer, Pushy, PushDeer, Reddit, Rocket.Chat, -SendGrid, ServerChan, Signal, SimplePush, Sinch, Slack, SMSEagle, SMTP2Go, Spontit, -SparkPost, Super Toasty, Streamlabs, Stride, Syslog, Techulus Push, Telegram, -Twilio, Twitter, Twist, XBMC, Voipms, Vonage, WhatsApp, Webex Teams} +PushBullet, Pushjet, PushMe, Pushover, PushSafer, Pushy, PushDeer, Reddit, +Rocket.Chat, SendGrid, ServerChan, Signal, SimplePush, Sinch, Slack, SMSEagle, +SMTP2Go, Spontit, SparkPost, Super Toasty, Streamlabs, Stride, Syslog, +Techulus Push, Telegram, Twilio, Twitter, Twist, XBMC, Voipms, Vonage, +WhatsApp, Webex Teams} Name: python-%{pypi_name} Version: 1.4.5 diff --git a/test/test_apprise_pickle.py b/test/test_apprise_pickle.py new file mode 100644 index 0000000000..d09d17b0c0 --- /dev/null +++ b/test/test_apprise_pickle.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +# BSD 3-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +from __future__ import print_function +import sys +import pickle +from apprise import Apprise, AppriseAsset, AppriseLocale + +# Disable logging for a cleaner testing output +import logging +logging.disable(logging.CRITICAL) + +# Ensure we don't create .pyc files for these tests +sys.dont_write_bytecode = True + + +def test_apprise_pickle_asset(tmpdir): + """pickle: AppriseAsset + """ + asset = AppriseAsset() + serialized = pickle.dumps(asset) + new_asset = pickle.loads(serialized) + + # iterate over some keys to verify they're still the same: + keys = ( + 'app_id', 'app_desc', 'app_url', 'html_notify_map', + 'ascii_notify_map', 'default_html_color', 'default_extension', + 'theme', 'image_url_mask', 'image_url_logo', 'image_path_mask', + 'body_format', 'async_mode', 'interpret_escapes', 'encoding', + 'secure_logging', '_recursion', + ) + + for key in keys: + assert getattr(asset, key) == getattr(new_asset, key) + + +def test_apprise_pickle_locale(tmpdir): + """pickle: AppriseLocale + """ + _locale = AppriseLocale.AppriseLocale() + serialized = pickle.dumps(_locale) + new_locale = pickle.loads(serialized) + + assert _locale.lang == new_locale.lang + + # Ensure internal functions still call in new object + new_locale.detect_language() + + +def test_apprise_pickle_core(tmpdir): + """pickle: Apprise + """ + asset = AppriseAsset(app_id="default") + apobj = Apprise(asset=asset) + + # Create a custom asset so we can verify it gets correctly serialized + xml_asset = AppriseAsset(app_id="xml") + + # Store our Entries + apobj.add("json://localhost") + apobj.add("xml://localhost", asset=xml_asset) + apobj.add("form://localhost") + apobj.add("mailto://user:pass@localhost", tag="email") + serialized = pickle.dumps(apobj) + + # Unserialize our object + new_apobj = pickle.loads(serialized) + + # Verify that it loaded our URLs back + assert len(new_apobj) == 4 + + # Our assets were kept (note the XML altered entry) + assert apobj[0].app_id == "default" + assert apobj[1].app_id == "xml" + assert apobj[2].app_id == "default" + assert apobj[3].app_id == "default" + + # Our tag was kept + assert "email" in apobj[3].tags diff --git a/test/test_apprise_utils.py b/test/test_apprise_utils.py index ad0789cbab..182d19a628 100644 --- a/test/test_apprise_utils.py +++ b/test/test_apprise_utils.py @@ -2016,7 +2016,7 @@ def __str__(self): def test_import_module(tmpdir): - """utils: imort_module testing + """utils: import_module testing """ # Prepare ourselves a file to work with bad_file_base = tmpdir.mkdir('a') diff --git a/test/test_plugin_pushme.py b/test/test_plugin_pushme.py new file mode 100644 index 0000000000..ab7117aff3 --- /dev/null +++ b/test/test_plugin_pushme.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +# BSD 3-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +import requests + +from apprise.plugins.NotifyPushMe import NotifyPushMe +from helpers import AppriseURLTester + +# Disable logging for a cleaner testing output +import logging +logging.disable(logging.CRITICAL) + +# Our Testing URLs +apprise_url_tests = ( + ('pushme://', { + 'instance': TypeError, + }), + ('pushme://:@/', { + 'instance': TypeError, + }), + # Token specified + ('pushme://%s' % ('a' * 6), { + 'instance': NotifyPushMe, + + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'pushme://a...a/', + }), + # Token specified + ('pushme://?token=%s' % ('b' * 6), { + 'instance': NotifyPushMe, + + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'pushme://b...b/', + }), + # Token specified + ('pushme://?push_key=%s' % ('p' * 6), { + 'instance': NotifyPushMe, + + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'pushme://p...p/', + }), + ('pushme://%s' % ('c' * 6), { + 'instance': NotifyPushMe, + # force a failure + 'response': False, + 'requests_response_code': requests.codes.internal_server_error, + }), + ('pushme://%s' % ('d' * 7), { + 'instance': NotifyPushMe, + # throw a bizzare code forcing us to fail to look it up + 'response': False, + 'requests_response_code': 999, + }), + ('pushme://%s' % ('e' * 8), { + 'instance': NotifyPushMe, + # Throws a series of connection and transfer exceptions when this flag + # is set and tests that we gracfully handle them + 'test_requests_exceptions': True, + }), +) + + +def test_plugin_pushme_urls(): + """ + NotifyPushMe() Apprise URLs + + """ + + # Run our general tests + AppriseURLTester(tests=apprise_url_tests).run_all()