From a420375cc7ad65d2d5172965d4436a303ffa85ae Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sat, 7 Sep 2019 18:39:18 -0400 Subject: [PATCH] Pushjet refactored; dropped pushjet library and deps (#147) --- .coveragerc | 2 +- README.md | 2 +- apprise/plugins/NotifyPushjet.py | 292 ++++++++++++++++ apprise/plugins/NotifyPushjet/__init__.py | 175 ---------- .../plugins/NotifyPushjet/pushjet/__init__.py | 6 - .../plugins/NotifyPushjet/pushjet/errors.py | 48 --- .../plugins/NotifyPushjet/pushjet/pushjet.py | 313 ------------------ .../NotifyPushjet/pushjet/utilities.py | 64 ---- apprise/plugins/__init__.py | 6 - packaging/redhat/python-apprise.spec | 4 - requirements.txt | 1 - setup.cfg | 2 +- test/test_pushjet_plugin.py | 204 ------------ test/test_rest_plugins.py | 61 ++++ 14 files changed, 356 insertions(+), 824 deletions(-) create mode 100644 apprise/plugins/NotifyPushjet.py delete mode 100644 apprise/plugins/NotifyPushjet/__init__.py delete mode 100644 apprise/plugins/NotifyPushjet/pushjet/__init__.py delete mode 100644 apprise/plugins/NotifyPushjet/pushjet/errors.py delete mode 100644 apprise/plugins/NotifyPushjet/pushjet/pushjet.py delete mode 100644 apprise/plugins/NotifyPushjet/pushjet/utilities.py delete mode 100644 test/test_pushjet_plugin.py diff --git a/.coveragerc b/.coveragerc index 22e316a8..d6fa055f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,5 +1,5 @@ [run] -omit=*/gntp/*,*/pushjet/* +omit=*/gntp/* disable_warnings = no-data-collected branch = True source = diff --git a/README.md b/README.md index 59d53864..df10750c 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ The table below identifies the services this tool supports and some example serv | [Microsoft Teams](https://github.com/caronc/apprise/wiki/Notify_msteams) | msteams:// | (TCP) 443 | msteams://TokenA/TokenB/TokenC/ | [Prowl](https://github.com/caronc/apprise/wiki/Notify_prowl) | prowl:// | (TCP) 443 | prowl://apikey
prowl://apikey/providerkey | [PushBullet](https://github.com/caronc/apprise/wiki/Notify_pushbullet) | pbul:// | (TCP) 443 | pbul://accesstoken
pbul://accesstoken/#channel
pbul://accesstoken/A_DEVICE_ID
pbul://accesstoken/email@address.com
pbul://accesstoken/#channel/#channel2/email@address.net/DEVICE -| [Pushjet](https://github.com/caronc/apprise/wiki/Notify_pushjet) | pjet:// or pjets:// | (TCP) 80 or 443 | pjet://secret@hostname
pjet://secret@hostname:port
pjets://secret@hostname
pjets://secret@hostname:port +| [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 | [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 diff --git a/apprise/plugins/NotifyPushjet.py b/apprise/plugins/NotifyPushjet.py new file mode 100644 index 00000000..5f2d3b5e --- /dev/null +++ b/apprise/plugins/NotifyPushjet.py @@ -0,0 +1,292 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2019 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import requests +from json import dumps + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..AppriseLocale import gettext_lazy as _ + + +class NotifyPushjet(NotifyBase): + """ + A wrapper for Pushjet Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Pushjet' + + # The default protocol + protocol = 'pjet' + + # The default secure protocol + secure_protocol = 'pjets' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_pushjet' + + # Disable throttle rate for Pushjet requests since they are normally + # local anyway (the remote/online service is no more) + request_rate_per_sec = 0 + + # Define object templates + templates = ( + '{schema}://{host}:{port}/{secret_key}', + '{schema}://{host}/{secret_key}', + '{schema}://{user}:{password}@{host}:{port}/{secret_key}', + '{schema}://{user}:{password}@{host}/{secret_key}', + + # Kept for backwards compatibility; will be depricated eventually + '{schema}://{secret_key}@{host}', + '{schema}://{secret_key}@{host}:{port}', + ) + + # Define our tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'host': { + 'name': _('Hostname'), + 'type': 'string', + 'required': True, + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + 'secret_key': { + 'name': _('Secret Key'), + 'type': 'string', + 'required': True, + 'private': True, + }, + 'user': { + 'name': _('Username'), + 'type': 'string', + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + }, + }) + + template_args = dict(NotifyBase.template_args, **{ + 'secret': { + 'alias_of': 'secret_key', + }, + }) + + def __init__(self, secret_key, **kwargs): + """ + Initialize Pushjet Object + """ + super(NotifyPushjet, self).__init__(**kwargs) + + if not secret_key: + # You must provide a Pushjet key to work with + msg = 'You must specify a Pushjet Secret Key.' + self.logger.warning(msg) + raise TypeError(msg) + + # store our key + self.secret_key = secret_key + + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'format': self.notify_format, + 'overflow': self.overflow_mode, + 'secret': self.secret_key, + 'verify': 'yes' if self.verify_certificate else 'no', + } + + default_port = 443 if self.secure else 80 + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=NotifyPushjet.quote(self.user, safe=''), + password=NotifyPushjet.quote(self.password, safe=''), + ) + + return '{schema}://{auth}{hostname}{port}/?{args}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + auth=auth, + hostname=NotifyPushjet.quote(self.host, safe=''), + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + args=NotifyPushjet.urlencode(args), + ) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Pushjet Notification + """ + + params = { + 'secret': self.secret_key, + } + + # prepare Pushjet Object + payload = { + 'message': body, + 'title': title, + 'link': None, + 'level': None, + } + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', + } + + auth = None + if self.user: + auth = (self.user, self.password) + + notify_url = '{schema}://{host}{port}/message/'.format( + schema="https" if self.secure else "http", + host=self.host, + port=':{}'.format(self.port) if self.port else '') + + self.logger.debug('Pushjet POST URL: %s (cert_verify=%r)' % ( + notify_url, self.verify_certificate, + )) + self.logger.debug('Pushjet Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + notify_url, + params=params, + data=dumps(payload), + headers=headers, + auth=auth, + verify=self.verify_certificate, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyPushjet.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send Pushjet 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 Pushjet notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending Pushjet ' + 'notification to %s.' % self.host) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to substantiate this object. + + Syntax: + pjet://hostname/secret_key + pjet://hostname:port/secret_key + pjet://user:pass@hostname/secret_key + pjet://user:pass@hostname:port/secret_key + pjets://hostname/secret_key + pjets://hostname:port/secret_key + pjets://user:pass@hostname/secret_key + pjets://user:pass@hostname:port/secret_key + + Legacy (Depricated) Syntax: + pjet://secret_key@hostname + pjet://secret_key@hostname:port + pjets://secret_key@hostname + pjets://secret_key@hostname:port + + """ + results = NotifyBase.parse_url(url) + + if not results: + # We're done early as we couldn't load the results + return results + + try: + # Retrieve our secret_key from the first entry in the url path + results['secret_key'] = \ + NotifyPushjet.split_path(results['fullpath'])[0] + + except IndexError: + # no secret key specified + results['secret_key'] = None + + # Allow over-riding the secret by specifying it as an argument + # this allows people who have http-auth infront to login + # through it in addition to supporting the secret key + if 'secret' in results['qsd'] and len(results['qsd']['secret']): + results['secret_key'] = \ + NotifyPushjet.parse_list(results['qsd']['secret']) + + if results.get('secret_key') is None: + # Deprication Notice issued for v0.7.9 + NotifyPushjet.logger.deprecate( + 'The Pushjet URL contains secret_key in the user field' + ' which will be deprecated in an upcoming ' + 'release. Please place this in the path of the URL instead.' + ) + + # Store it as it's value based on the user field + results['secret_key'] = \ + NotifyPushjet.unquote(results.get('user')) + + # there is no way http-auth is enabled, be sure to unset the + # current defined user (if present). This is done due to some + # logic that takes place in the send() since we support http-auth. + results['user'] = None + results['password'] = None + + return results diff --git a/apprise/plugins/NotifyPushjet/__init__.py b/apprise/plugins/NotifyPushjet/__init__.py deleted file mode 100644 index a71fe7e9..00000000 --- a/apprise/plugins/NotifyPushjet/__init__.py +++ /dev/null @@ -1,175 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright (C) 2019 Chris Caron -# All rights reserved. -# -# This code is licensed under the MIT License. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files(the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions : -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -import re -from . import pushjet - -from ..NotifyBase import NotifyBase -from ...common import NotifyType -from ...AppriseLocale import gettext_lazy as _ - -PUBLIC_KEY_RE = re.compile( - r'^[a-z0-9]{4}-[a-z0-9]{6}-[a-z0-9]{12}-[a-z0-9]{5}-[a-z0-9]{9}$', re.I) - -SECRET_KEY_RE = re.compile(r'^[a-z0-9]{32}$', re.I) - - -class NotifyPushjet(NotifyBase): - """ - A wrapper for Pushjet Notifications - """ - - # The default descriptive name associated with the Notification - service_name = 'Pushjet' - - # The default protocol - protocol = 'pjet' - - # The default secure protocol - secure_protocol = 'pjets' - - # A URL that takes you to the setup/help of the specific protocol - setup_url = 'https://github.com/caronc/apprise/wiki/Notify_pushjet' - - # Disable throttle rate for Pushjet requests since they are normally - # local anyway (the remote/online service is no more) - request_rate_per_sec = 0 - - # Define object templates - templates = ( - '{schema}://{secret_key}@{host}', - '{schema}://{secret_key}@{host}:{port}', - ) - - # Define our tokens - template_tokens = dict(NotifyBase.template_tokens, **{ - 'host': { - 'name': _('Hostname'), - 'type': 'string', - 'required': True, - }, - 'port': { - 'name': _('Port'), - 'type': 'int', - 'min': 1, - 'max': 65535, - }, - 'secret_key': { - 'name': _('Secret Key'), - 'type': 'string', - 'required': True, - 'private': True, - }, - }) - - def __init__(self, secret_key, **kwargs): - """ - Initialize Pushjet Object - """ - super(NotifyPushjet, self).__init__(**kwargs) - - if not secret_key: - # You must provide a Pushjet key to work with - msg = 'You must specify a Pushjet Secret Key.' - self.logger.warning(msg) - raise TypeError(msg) - - # store our key - self.secret_key = secret_key - - def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): - """ - Perform Pushjet Notification - """ - # Always call throttle before any remote server i/o is made - self.throttle() - - server = "https://" if self.secure else "http://" - - server += self.host - if self.port: - server += ":" + str(self.port) - - try: - api = pushjet.pushjet.Api(server) - service = api.Service(secret_key=self.secret_key) - - service.send(body, title) - self.logger.info('Sent Pushjet notification.') - - except (pushjet.errors.PushjetError, ValueError) as e: - self.logger.warning('Failed to send Pushjet notification.') - self.logger.debug('Pushjet Exception: %s' % str(e)) - return False - - return True - - def url(self): - """ - Returns the URL built dynamically based on specified arguments. - """ - - # Define any arguments set - args = { - 'format': self.notify_format, - 'overflow': self.overflow_mode, - 'verify': 'yes' if self.verify_certificate else 'no', - } - - default_port = 443 if self.secure else 80 - - return '{schema}://{secret_key}@{hostname}{port}/?{args}'.format( - schema=self.secure_protocol if self.secure else self.protocol, - secret_key=NotifyPushjet.quote(self.secret_key, safe=''), - hostname=NotifyPushjet.quote(self.host, safe=''), - port='' if self.port is None or self.port == default_port - else ':{}'.format(self.port), - args=NotifyPushjet.urlencode(args), - ) - - @staticmethod - def parse_url(url): - """ - Parses the URL and returns enough arguments that can allow - us to substantiate this object. - - Syntax: - pjet://secret_key@hostname - pjet://secret_key@hostname:port - pjets://secret_key@hostname - pjets://secret_key@hostname:port - - """ - results = NotifyBase.parse_url(url) - - if not results: - # We're done early as we couldn't load the results - return results - - # Store it as it's value - results['secret_key'] = \ - NotifyPushjet.unquote(results.get('user')) - - return results diff --git a/apprise/plugins/NotifyPushjet/pushjet/__init__.py b/apprise/plugins/NotifyPushjet/pushjet/__init__.py deleted file mode 100644 index 929160da..00000000 --- a/apprise/plugins/NotifyPushjet/pushjet/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# -*- coding: utf-8 -*- - -"""A Python API for Pushjet. Send notifications to your phone from Python scripts!""" - -from .pushjet import Service, Device, Subscription, Message, Api -from .errors import PushjetError, AccessError, NonexistentError, SubscriptionError, RequestError, ServerError diff --git a/apprise/plugins/NotifyPushjet/pushjet/errors.py b/apprise/plugins/NotifyPushjet/pushjet/errors.py deleted file mode 100644 index bfa16b2a..00000000 --- a/apprise/plugins/NotifyPushjet/pushjet/errors.py +++ /dev/null @@ -1,48 +0,0 @@ -# -*- coding: utf-8 -*- - - -from requests import RequestException - -import sys -if sys.version_info[0] < 3: - # This is built into Python 3. - class ConnectionError(Exception): - pass - -class PushjetError(Exception): - """All the errors inherit from this. Therefore, ``except PushjetError`` catches all errors.""" - -class AccessError(PushjetError): - """Raised when a secret key is missing for a service method that needs one.""" - -class NonexistentError(PushjetError): - """Raised when an attempt to access a nonexistent service is made.""" - -class SubscriptionError(PushjetError): - """Raised when an attempt to subscribe to a service that's already subscribed to, - or to unsubscribe from a service that isn't subscribed to, is made.""" - -class RequestError(PushjetError, ConnectionError): - """Raised if something goes wrong in the connection to the API server. - Inherits from ``ConnectionError`` on Python 3, and can therefore be caught - with ``except ConnectionError`` there. - - :ivar requests_exception: The underlying `requests `__ - exception. Access this if you want to handle different HTTP request errors in different ways. - """ - - def __str__(self): - return "requests.{error}: {description}".format( - error=self.requests_exception.__class__.__name__, - description=str(self.requests_exception) - ) - - def __init__(self, requests_exception): - self.requests_exception = requests_exception - -class ServerError(PushjetError): - """Raised if the API server has an error while processing your request. - This getting raised means there's a bug in the server! If you manage to - track down what caused it, you can `open an issue on Pushjet's GitHub page - `__. - """ diff --git a/apprise/plugins/NotifyPushjet/pushjet/pushjet.py b/apprise/plugins/NotifyPushjet/pushjet/pushjet.py deleted file mode 100644 index 1289f3f0..00000000 --- a/apprise/plugins/NotifyPushjet/pushjet/pushjet.py +++ /dev/null @@ -1,313 +0,0 @@ -# -*- coding: utf-8 -*- - -import sys -import requests -from functools import partial - -from six import text_type -from six.moves.urllib.parse import urljoin - -from .utilities import ( - NoNoneDict, - requires_secret_key, with_api_bound, - is_valid_uuid, is_valid_public_key, is_valid_secret_key, repr_format -) -from .errors import NonexistentError, SubscriptionError, RequestError, ServerError - -DEFAULT_API_URL = 'https://api.pushjet.io/' - -class PushjetModel(object): - _api = None # This is filled in later. - -class Service(PushjetModel): - """A Pushjet service to send messages through. To receive messages, devices - subscribe to these. - - :param secret_key: The service's API key for write access. If provided, - :func:`~pushjet.Service.send`, :func:`~pushjet.Service.edit`, and - :func:`~pushjet.Service.delete` become available. - Either this or the public key parameter must be present. - :param public_key: The service's public API key for read access only. - Either this or the secret key parameter must be present. - - :ivar name: The name of the service. - :ivar icon_url: The URL to the service's icon. May be ``None``. - :ivar created: When the service was created, as seconds from epoch. - :ivar secret_key: The service's secret API key, or ``None`` if the service is read-only. - :ivar public_key: The service's public API key, to be used when subscribing to the service. - """ - - def __repr__(self): - return "".format(repr_format(self.name)) - - def __init__(self, secret_key=None, public_key=None): - if secret_key is None and public_key is None: - raise ValueError("Either a secret key or public key " - "must be provided.") - elif secret_key and not is_valid_secret_key(secret_key): - raise ValueError("Invalid secret key provided.") - elif public_key and not is_valid_public_key(public_key): - raise ValueError("Invalid public key provided.") - self.secret_key = text_type(secret_key) if secret_key else None - self.public_key = text_type(public_key) if public_key else None - self.refresh() - - def _request(self, endpoint, method, is_secret, params=None, data=None): - params = params or {} - if is_secret: - params['secret'] = self.secret_key - else: - params['service'] = self.public_key - return self._api._request(endpoint, method, params, data) - - @requires_secret_key - def send(self, message, title=None, link=None, importance=None): - """Send a message to the service's subscribers. - - :param message: The message body to be sent. - :param title: (optional) The message's title. Messages can be without title. - :param link: (optional) An URL to be sent with the message. - :param importance: (optional) The priority level of the message. May be - a number between 1 and 5, where 1 is least important and 5 is most. - """ - data = NoNoneDict({ - 'message': message, - 'title': title, - 'link': link, - 'level': importance - }) - self._request('message', 'POST', is_secret=True, data=data) - - @requires_secret_key - def edit(self, name=None, icon_url=None): - """Edit the service's attributes. - - :param name: (optional) A new name to give the service. - :param icon_url: (optional) A new URL to use as the service's icon URL. - Set to an empty string to remove the service's icon entirely. - """ - data = NoNoneDict({ - 'name': name, - 'icon': icon_url - }) - if not data: - return - self._request('service', 'PATCH', is_secret=True, data=data) - self.name = text_type(name) - self.icon_url = text_type(icon_url) - - @requires_secret_key - def delete(self): - """Delete the service. Irreversible.""" - self._request('service', 'DELETE', is_secret=True) - - def _update_from_data(self, data): - self.name = data['name'] - self.icon_url = data['icon'] or None - self.created = data['created'] - self.public_key = data['public'] - self.secret_key = data.get('secret', getattr(self, 'secret_key', None)) - - def refresh(self): - """Refresh the server's information, in case it could be edited from elsewhere. - - :raises: :exc:`~pushjet.NonexistentError` if the service was deleted before refreshing. - """ - key_name = 'public' - secret = False - if self.secret_key is not None: - key_name = 'secret' - secret = True - - status, response = self._request('service', 'GET', is_secret=secret) - if status == requests.codes.NOT_FOUND: - raise NonexistentError("A service with the provided {} key " - "does not exist (anymore, at least).".format(key_name)) - self._update_from_data(response['service']) - - @classmethod - def _from_data(cls, data): - # This might be a no-no, but I see little alternative if - # different constructors with different parameters are needed, - # *and* a default __init__ constructor should be present. - # This, along with the subclassing for custom API URLs, may - # very well be one of those pieces of code you look back at - # years down the line - or maybe just a couple of weeks - and say - # "what the heck was I thinking"? I assure you, though, future me. - # This was the most reasonable thing to get the API + argspecs I wanted. - obj = cls.__new__(cls) - obj._update_from_data(data) - return obj - - @classmethod - def create(cls, name, icon_url=None): - """Create a new service. - - :param name: The name of the new service. - :param icon_url: (optional) An URL to an image to be used as the service's icon. - :return: The newly-created :class:`~pushjet.Service`. - """ - data = NoNoneDict({ - 'name': name, - 'icon': icon_url - }) - _, response = cls._api._request('service', 'POST', data=data) - return cls._from_data(response['service']) - -class Device(PushjetModel): - """The "receiver" for messages. Subscribes to services and receives any - messages they send. - - :param uuid: The device's unique ID as a UUID. Does not need to be registered - before using it. A UUID can be generated with ``uuid.uuid4()``, for example. - :ivar uuid: The UUID the device was initialized with. - """ - - def __repr__(self): - return "".format(self.uuid) - - def __init__(self, uuid): - uuid = text_type(uuid) - if not is_valid_uuid(uuid): - raise ValueError("Invalid UUID provided. Try uuid.uuid4().") - self.uuid = text_type(uuid) - - def _request(self, endpoint, method, params=None, data=None): - params = (params or {}) - params['uuid'] = self.uuid - return self._api._request(endpoint, method, params, data) - - def subscribe(self, service): - """Subscribe the device to a service. - - :param service: The service to subscribe to. May be a public key or a :class:`~pushjet.Service`. - :return: The :class:`~pushjet.Service` subscribed to. - - :raises: :exc:`~pushjet.NonexistentError` if the provided service does not exist. - :raises: :exc:`~pushjet.SubscriptionError` if the provided service is already subscribed to. - """ - data = {} - data['service'] = service.public_key if isinstance(service, Service) else service - status, response = self._request('subscription', 'POST', data=data) - if status == requests.codes.CONFLICT: - raise SubscriptionError("The device is already subscribed to that service.") - elif status == requests.codes.NOT_FOUND: - raise NonexistentError("A service with the provided public key " - "does not exist (anymore, at least).") - return self._api.Service._from_data(response['service']) - - def unsubscribe(self, service): - """Unsubscribe the device from a service. - - :param service: The service to unsubscribe from. May be a public key or a :class:`~pushjet.Service`. - :raises: :exc:`~pushjet.NonexistentError` if the provided service does not exist. - :raises: :exc:`~pushjet.SubscriptionError` if the provided service isn't subscribed to. - """ - data = {} - data['service'] = service.public_key if isinstance(service, Service) else service - status, _ = self._request('subscription', 'DELETE', data=data) - if status == requests.codes.CONFLICT: - raise SubscriptionError("The device is not subscribed to that service.") - elif status == requests.codes.NOT_FOUND: - raise NonexistentError("A service with the provided public key " - "does not exist (anymore, at least).") - - def get_subscriptions(self): - """Get all the subscriptions the device has. - - :return: A list of :class:`~pushjet.Subscription`. - """ - _, response = self._request('subscription', 'GET') - subscriptions = [] - for subscription_dict in response['subscriptions']: - subscriptions.append(Subscription(subscription_dict)) - return subscriptions - - def get_messages(self): - """Get all new (that is, as of yet unretrieved) messages. - - :return: A list of :class:`~pushjet.Message`. - """ - _, response = self._request('message', 'GET') - messages = [] - for message_dict in response['messages']: - messages.append(Message(message_dict)) - return messages - -class Subscription(object): - """A subscription to a service, with the metadata that entails. - - :ivar service: The service the subscription is to, as a :class:`~pushjet.Service`. - :ivar time_subscribed: When the subscription was made, as seconds from epoch. - :ivar last_checked: When the device last retrieved messages from the subscription, - as seconds from epoch. - :ivar device_uuid: The UUID of the device that owns the subscription. - """ - - def __repr__(self): - return "".format(repr_format(self.service.name)) - - def __init__(self, subscription_dict): - self.service = Service._from_data(subscription_dict['service']) - self.time_subscribed = subscription_dict['timestamp'] - self.last_checked = subscription_dict['timestamp_checked'] - self.device_uuid = subscription_dict['uuid'] # Not sure this is needed, but... - -class Message(object): - """A message received from a service. - - :ivar message: The message body. - :ivar title: The message title. May be ``None``. - :ivar link: The URL the message links to. May be ``None``. - :ivar time_sent: When the message was sent, as seconds from epoch. - :ivar importance: The message's priority level between 1 and 5, where 1 is - least important and 5 is most. - :ivar service: The :class:`~pushjet.Service` that sent the message. - """ - - def __repr__(self): - return "".format(repr_format(self.title or self.message)) - - def __init__(self, message_dict): - self.message = message_dict['message'] - self.title = message_dict['title'] or None - self.link = message_dict['link'] or None - self.time_sent = message_dict['timestamp'] - self.importance = message_dict['level'] - self.service = Service._from_data(message_dict['service']) - -class Api(object): - """An API with a custom URL. Use this if you're connecting to a self-hosted - Pushjet API instance, or a non-standard one in general. - - :param url: The URL to the API instance. - :ivar url: The URL to the API instance, as supplied. - """ - - def __repr__(self): - return "".format(self.url).encode(sys.stdout.encoding, errors='replace') - - def __init__(self, url): - self.url = text_type(url) - self.Service = with_api_bound(Service, self) - self.Device = with_api_bound(Device, self) - - def _request(self, endpoint, method, params=None, data=None): - url = urljoin(self.url, endpoint) - try: - r = requests.request(method, url, params=params, data=data) - except requests.RequestException as e: - raise RequestError(e) - status = r.status_code - if status == requests.codes.INTERNAL_SERVER_ERROR: - raise ServerError( - "An error occurred in the server while processing your request. " - "This should probably be reported to: " - "https://github.com/Pushjet/Pushjet-Server-Api/issues" - ) - try: - response = r.json() - except ValueError: - response = {} - return status, response - diff --git a/apprise/plugins/NotifyPushjet/pushjet/utilities.py b/apprise/plugins/NotifyPushjet/pushjet/utilities.py deleted file mode 100644 index 3a2af502..00000000 --- a/apprise/plugins/NotifyPushjet/pushjet/utilities.py +++ /dev/null @@ -1,64 +0,0 @@ -# -*- coding: utf-8 -*- - -import re -import sys -from decorator import decorator -from .errors import AccessError - -# Help class(...es? Nah. Just singular for now.) - -class NoNoneDict(dict): - """A dict that ignores values that are None. Not completely API-compatible - with dict, but contains all that's needed. - """ - def __repr__(self): - return "NoNoneDict({dict})".format(dict=dict.__repr__(self)) - - def __init__(self, initial={}): - self.update(initial) - - def __setitem__(self, key, value): - if value is not None: - dict.__setitem__(self, key, value) - - def update(self, data): - for key, value in data.items(): - self[key] = value - -# Decorators / factories - -@decorator -def requires_secret_key(func, self, *args, **kwargs): - """Raise an error if the method is called without a secret key.""" - if self.secret_key is None: - raise AccessError("The Service doesn't have a secret " - "key provided, and therefore lacks write permission.") - return func(self, *args, **kwargs) - -def with_api_bound(cls, api): - new_cls = type(cls.__name__, (cls,), { - '_api': api, - '__doc__': ( - "Create a :class:`~pushjet.{name}` bound to the API. " - "See :class:`pushjet.{name}` for documentation." - ).format(name=cls.__name__) - }) - return new_cls - -# Helper functions - -UUID_RE = re.compile(r'^[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}$') -PUBLIC_KEY_RE = re.compile(r'^[A-Za-z0-9]{4}-[A-Za-z0-9]{6}-[A-Za-z0-9]{12}-[A-Za-z0-9]{5}-[A-Za-z0-9]{9}$') -SECRET_KEY_RE = re.compile(r'^[A-Za-z0-9]{32}$') - -is_valid_uuid = lambda s: UUID_RE.match(s) is not None -is_valid_public_key = lambda s: PUBLIC_KEY_RE.match(s) is not None -is_valid_secret_key = lambda s: SECRET_KEY_RE.match(s) is not None - -def repr_format(s): - s = s.replace('\n', ' ').replace('\r', '') - original_length = len(s) - s = s[:30] - s += '...' if len(s) != original_length else '' - s = s.encode(sys.stdout.encoding, errors='replace') - return s diff --git a/apprise/plugins/__init__.py b/apprise/plugins/__init__.py index ec6780a6..6faaa201 100644 --- a/apprise/plugins/__init__.py +++ b/apprise/plugins/__init__.py @@ -33,9 +33,6 @@ from os.path import abspath # Used for testing from . import NotifyEmail as NotifyEmailBase - -# Required until re-factored into base code -from .NotifyPushjet import pushjet from .NotifyGrowl import gntp # NotifyBase object is passed in as a module not class @@ -62,9 +59,6 @@ __all__ = [ # gntp (used for NotifyGrowl Testing) 'gntp', - - # pushjet (used for NotifyPushjet Testing) - 'pushjet', ] # we mirror our base purely for the ability to reset everything; this diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec index b4d47b35..124cb76c 100644 --- a/packaging/redhat/python-apprise.spec +++ b/packaging/redhat/python-apprise.spec @@ -75,7 +75,6 @@ Summary: A simple wrapper to many popular notification services used today %{?python_provide:%python_provide python2-%{pypi_name}} BuildRequires: python2-devel -BuildRequires: python-decorator BuildRequires: python-requests BuildRequires: python2-requests-oauthlib BuildRequires: python-six @@ -89,7 +88,6 @@ BuildRequires: python2-babel BuildRequires: python2-yaml %endif # using rhel7 -Requires: python-decorator Requires: python-requests Requires: python2-requests-oauthlib Requires: python-six @@ -134,7 +132,6 @@ Summary: A simple wrapper to many popular notification services used today %{?python_provide:%python_provide python%{python3_pkgversion}-%{pypi_name}} BuildRequires: python%{python3_pkgversion}-devel -BuildRequires: python%{python3_pkgversion}-decorator BuildRequires: python%{python3_pkgversion}-requests BuildRequires: python%{python3_pkgversion}-requests-oauthlib BuildRequires: python%{python3_pkgversion}-six @@ -142,7 +139,6 @@ BuildRequires: python%{python3_pkgversion}-click >= 5.0 BuildRequires: python%{python3_pkgversion}-markdown BuildRequires: python%{python3_pkgversion}-yaml BuildRequires: python%{python3_pkgversion}-babel -Requires: python%{python3_pkgversion}-decorator Requires: python%{python3_pkgversion}-requests Requires: python%{python3_pkgversion}-requests-oauthlib Requires: python%{python3_pkgversion}-six diff --git a/requirements.txt b/requirements.txt index 5ac1ad7c..32510239 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ -decorator requests requests-oauthlib six diff --git a/setup.cfg b/setup.cfg index bf961acf..3a773551 100644 --- a/setup.cfg +++ b/setup.cfg @@ -7,7 +7,7 @@ license_file = LICENSE [flake8] # We exclude packages we don't maintain -exclude = .eggs,.tox,gntp,pushjet +exclude = .eggs,.tox,gntp ignore = E722,W503,W504 statistics = true builtins = _ diff --git a/test/test_pushjet_plugin.py b/test/test_pushjet_plugin.py deleted file mode 100644 index 00564f56..00000000 --- a/test/test_pushjet_plugin.py +++ /dev/null @@ -1,204 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright (C) 2019 Chris Caron -# All rights reserved. -# -# This code is licensed under the MIT License. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files(the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions : -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -import six -from apprise import plugins -from apprise import NotifyType -from apprise import Apprise - -import mock - -# Disable logging for a cleaner testing output -import logging -logging.disable(logging.CRITICAL) - - -TEST_URLS = ( - ################################## - # NotifyPushjet - ################################## - ('pjet://', { - 'instance': None, - }), - ('pjets://', { - 'instance': None, - }), - ('pjet://:@/', { - 'instance': None, - }), - # You must specify a username - ('pjet://%s' % ('a' * 32), { - 'instance': TypeError, - }), - # Specify your own server - ('pjet://%s@localhost' % ('a' * 32), { - 'instance': plugins.NotifyPushjet, - }), - # Specify your own server with port - ('pjets://%s@localhost:8080' % ('a' * 32), { - 'instance': plugins.NotifyPushjet, - }), - ('pjet://%s@localhost:8081' % ('a' * 32), { - 'instance': plugins.NotifyPushjet, - # Throws a series of connection and transfer exceptions when this flag - # is set and tests that we gracfully handle them - 'test_notify_exceptions': True, - }), -) - - -@mock.patch('apprise.plugins.pushjet.pushjet.Service.send') -@mock.patch('apprise.plugins.pushjet.pushjet.Service.refresh') -def test_plugin(mock_refresh, mock_send): - """ - API: NotifyPushjet Plugin() (pt1) - - """ - - # iterate over our dictionary and test it out - for (url, meta) in TEST_URLS: - - # Our expected instance - instance = meta.get('instance', None) - - # Our expected server objects - self = meta.get('self', None) - - # Our expected Query response (True, False, or exception type) - response = meta.get('response', True) - - # Allow us to force the server response code to be something other then - # the defaults - response = meta.get( - 'response', True if response else False) - - test_notify_exceptions = meta.get( - 'test_notify_exceptions', False) - - test_exceptions = ( - plugins.pushjet.errors.AccessError( - 0, 'pushjet.AccessError() not handled'), - plugins.pushjet.errors.NonexistentError( - 0, 'pushjet.NonexistentError() not handled'), - plugins.pushjet.errors.SubscriptionError( - 0, 'gntp.SubscriptionError() not handled'), - plugins.pushjet.errors.RequestError( - 'pushjet.RequestError() not handled'), - ) - - try: - obj = Apprise.instantiate(url, suppress_exceptions=False) - - if obj is None: - if instance is not None: - # We're done (assuming this is what we were expecting) - print("{} didn't instantiate itself " - "(we expected it to)".format(url)) - assert False - continue - - if instance is None: - # Expected None but didn't get it - print('%s instantiated %s (but expected None)' % ( - url, str(obj))) - assert(False) - - assert(isinstance(obj, instance)) - - if isinstance(obj, plugins.NotifyBase): - # We loaded okay; now lets make sure we can reverse this url - assert(isinstance(obj.url(), six.string_types) is True) - - # Instantiate the exact same object again using the URL from - # the one that was already created properly - obj_cmp = Apprise.instantiate(obj.url()) - - # Our object should be the same instance as what we had - # originally expected above. - if not isinstance(obj_cmp, plugins.NotifyBase): - # Assert messages are hard to trace back with the way - # these tests work. Just printing before throwing our - # assertion failure makes things easier to debug later on - print('TEST FAIL: {} regenerated as {}'.format( - url, obj.url())) - assert(False) - - if self: - # Iterate over our expected entries inside of our object - for key, val in self.items(): - # Test that our object has the desired key - assert(hasattr(key, obj)) - assert(getattr(key, obj) == val) - - try: - if test_notify_exceptions is False: - # Store our response - mock_send.return_value = response - mock_send.side_effect = None - - # check that we're as expected - assert obj.notify( - title='test', body='body', - notify_type=NotifyType.INFO) == response - - else: - for exception in test_exceptions: - mock_send.side_effect = exception - mock_send.return_value = None - try: - assert obj.notify( - title='test', body='body', - notify_type=NotifyType.INFO) is False - - except AssertionError: - # Don't mess with these entries - raise - - except Exception: - # We can't handle this exception type - raise - - except AssertionError: - # Don't mess with these entries - print('%s AssertionError' % url) - raise - - except Exception as e: - # Check that we were expecting this exception to happen - if not isinstance(e, response): - raise - - except AssertionError: - # Don't mess with these entries - print('%s AssertionError' % url) - raise - - except Exception as e: - # Handle our exception - if(instance is None): - raise - - if not isinstance(e, instance): - raise diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py index 211d54b3..e009a223 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -1509,6 +1509,67 @@ TEST_URLS = ( 'test_requests_exceptions': True, }), + ################################## + # NotifyPushjet + ################################## + ('pjet://', { + 'instance': None, + }), + ('pjets://', { + 'instance': None, + }), + ('pjet://:@/', { + 'instance': None, + }), + # You must specify a secret key + ('pjet://%s' % ('a' * 32), { + 'instance': TypeError, + }), + # Legacy method of logging in (soon to be depricated) + ('pjet://%s@localhost' % ('a' * 32), { + 'instance': plugins.NotifyPushjet, + }), + # The proper way to log in + ('pjet://user:pass@localhost/%s' % ('a' * 32), { + 'instance': plugins.NotifyPushjet, + }), + # The proper way to log in + ('pjets://localhost/%s' % ('a' * 32), { + 'instance': plugins.NotifyPushjet, + }), + # Specify your own server with login (secret= MUST be provided) + ('pjet://user:pass@localhost?secret=%s' % ('a' * 32), { + 'instance': plugins.NotifyPushjet, + }), + # Specify your own server with login (no secret = fail normally) + # however this will work since we're providing depricated support + # at this time so the 'user' get's picked up as being the secret_key + ('pjet://user:pass@localhost', { + 'instance': plugins.NotifyPushjet, + }), + # Specify your own server with port + ('pjets://localhost:8080/%s' % ('a' * 32), { + 'instance': plugins.NotifyPushjet, + }), + ('pjets://localhost:8080/%s' % ('a' * 32), { + 'instance': plugins.NotifyPushjet, + # force a failure + 'response': False, + 'requests_response_code': requests.codes.internal_server_error, + }), + ('pjets://localhost:4343/%s' % ('a' * 32), { + 'instance': plugins.NotifyPushjet, + # throw a bizzare code forcing us to fail to look it up + 'response': False, + 'requests_response_code': 999, + }), + ('pjet://localhost:8081/%s' % ('a' * 32), { + 'instance': plugins.NotifyPushjet, + # Throws a series of connection and transfer exceptions when this flag + # is set and tests that we gracfully handle them + 'test_requests_exceptions': True, + }), + ################################## # NotifyPushover ##################################